From 36357843d057c5cfd8d0e91dd629568b509dbf34 Mon Sep 17 00:00:00 2001 From: Joey Date: Sat, 15 Aug 2026 22:17:26 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=BC=80=E5=8F=91=E9=87=87=E9=9B=86?= =?UTF-8?q?=E3=80=81=E9=87=87=E9=9B=86=E7=AE=B1=E5=92=8C=E5=95=86=E5=93=81?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 23 + .gitignore | 3 + alembic.ini | 39 + docs/ozon-seller-api/01-authentication.md | 234 + docs/ozon-seller-api/02-category-tree.md | 333 ++ .../ozon-seller-api/03-category-attributes.md | 660 +++ docs/ozon-seller-api/04-product-import.md | 616 +++ docs/ozon-seller-api/05-product-info.md | 557 +++ docs/ozon-seller-api/06-product-list.md | 477 ++ docs/ozon-seller-api/09-stocks.md | 599 +++ docs/ozon-seller-api/README.md | 102 + docs/ozon-seller-api/SUMMARY.md | 143 + docs/v2/README.md | 112 + docs/v2/api.md | 219 + docs/v2/architecture.md | 182 + docs/v2/capability-inventory.md | 218 + docs/v2/database.md | 278 ++ docs/v2/image-strategy.md | 148 + docs/v2/migration.md | 139 + docs/v2/multi-sku.md | 138 + docs/v2/ozon-publish.md | 175 + {extension => extension-v1}/README.md | 0 .../entrypoints/background.ts | 0 .../entrypoints/content/index.ts | 0 .../entrypoints/sidepanel/App.tsx | 0 .../entrypoints/sidepanel/index.html | 0 {extension => extension-v1}/package.json | 0 {extension => extension-v1}/pnpm-lock.yaml | 0 .../src/collector/dom.ts | 0 .../src/collector/image.ts | 0 .../src/collector/scan.ts | 0 .../src/collector/ssr-builder.ts | 0 .../src/collector/ssr.ts | 0 .../src/collector/text.ts | 0 .../src/collector/url.ts | 0 .../src/profiles/1688.ts | 0 .../src/profiles/index.ts | 0 .../src/profiles/taobao.ts | 0 .../src/profiles/types.ts | 0 .../src/schema/product.ts | 0 {extension => extension-v1}/tsconfig.json | 0 {extension => extension-v1}/wxt.config.ts | 0 extension-v2/README.md | 124 + extension-v2/entrypoints/background.ts | 44 + extension-v2/entrypoints/content/index.ts | 20 + extension-v2/entrypoints/sidepanel/App.tsx | 710 +++ extension-v2/entrypoints/sidepanel/index.html | 30 + extension-v2/package.json | 26 + extension-v2/pnpm-lock.yaml | 4419 +++++++++++++++++ extension-v2/scripts/verify-pages.ts | 97 + extension-v2/src/api/client.ts | 104 + extension-v2/src/collector/dom.ts | 54 + extension-v2/src/collector/image.ts | 146 + extension-v2/src/collector/jsonld.ts | 107 + extension-v2/src/collector/ozon-api.ts | 283 ++ extension-v2/src/collector/ozon-state.ts | 244 + extension-v2/src/collector/scan.ts | 278 ++ extension-v2/src/collector/text.ts | 110 + extension-v2/src/collector/url.ts | 132 + extension-v2/src/export/builder.ts | 167 + extension-v2/src/export/filesystem.ts | 103 + extension-v2/src/export/idb.ts | 63 + extension-v2/src/profiles/index.ts | 19 + extension-v2/src/profiles/ozon.ts | 162 + extension-v2/src/profiles/types.ts | 69 + extension-v2/src/schema/product.ts | 80 + extension-v2/src/storage/settings.ts | 23 + extension-v2/tsconfig.json | 11 + extension-v2/wxt.config.ts | 29 + server/api/auth.py | 23 + server/api/categories.py | 121 + server/api/collection.py | 272 + server/api/fx.py | 14 + server/api/products.py | 186 + server/api/publish.py | 183 + server/api/shops.py | 124 + server/config/settings.py | 30 +- server/core/__init__.py | 0 server/core/security.py | 56 + server/db.py | 50 + server/deps.py | 26 + server/main.py | 43 +- server/migrations/env.py | 53 + server/migrations/script.py.mako | 25 + server/migrations/versions/.gitkeep | 0 .../51715d16e5c3_initial_v2_schema.py | 203 + .../658b0503f71c_add_shop_id_to_products.py | 29 + server/models/__init__.py | 18 + server/models/asset.py | 34 + server/models/category.py | 60 + server/models/enums.py | 39 + server/models/product.py | 76 + server/models/publish_task.py | 33 + server/models/shop.py | 30 + server/models/types.py | 8 + server/models/user.py | 19 + server/requirements.txt | 9 + server/schemas/auth.py | 14 + server/schemas/collection.py | 42 + server/schemas/product.py | 102 + server/schemas/shop.py | 33 + server/services/fx.py | 58 + server/services/ozon_client.py | 51 + server/services/publish.py | 79 + server/services/storage.py | 105 + studio/src/components/RequireAuth.tsx | 16 + studio/src/layouts/menuConfig.tsx | 27 +- .../src/pages/collection/CollectionPage.tsx | 183 + studio/src/pages/login/LoginPage.tsx | 46 + studio/src/pages/product/AttributePanel.tsx | 400 ++ studio/src/pages/product/CopyPanel.tsx | 185 + studio/src/pages/product/FieldLabel.tsx | 25 + studio/src/pages/product/ImagePanel.tsx | 82 + studio/src/pages/product/MainInfoPanel.tsx | 451 ++ studio/src/pages/product/PriceInfoPanel.tsx | 242 + .../pages/product/ProductAttributesPanel.tsx | 161 + studio/src/pages/product/ProductEditPage.tsx | 162 + studio/src/pages/product/PublishPanel.tsx | 153 + studio/src/pages/shops/ShopsPage.tsx | 179 + studio/src/pricing/pricing.ts | 84 + studio/src/router/index.tsx | 10 +- studio/src/services/ai.ts | 36 + studio/src/services/api.ts | 17 + studio/src/services/auth.ts | 31 + studio/src/services/category.ts | 57 + studio/src/services/fx.ts | 11 + studio/src/services/product.ts | 94 + studio/src/services/publish.ts | 32 + studio/src/services/shop.ts | 34 + studio/tsconfig.app.tsbuildinfo | 2 +- 130 files changed, 18005 insertions(+), 12 deletions(-) create mode 100644 alembic.ini create mode 100644 docs/ozon-seller-api/01-authentication.md create mode 100644 docs/ozon-seller-api/02-category-tree.md create mode 100644 docs/ozon-seller-api/03-category-attributes.md create mode 100644 docs/ozon-seller-api/04-product-import.md create mode 100644 docs/ozon-seller-api/05-product-info.md create mode 100644 docs/ozon-seller-api/06-product-list.md create mode 100644 docs/ozon-seller-api/09-stocks.md create mode 100644 docs/ozon-seller-api/README.md create mode 100644 docs/ozon-seller-api/SUMMARY.md create mode 100644 docs/v2/README.md create mode 100644 docs/v2/api.md create mode 100644 docs/v2/architecture.md create mode 100644 docs/v2/capability-inventory.md create mode 100644 docs/v2/database.md create mode 100644 docs/v2/image-strategy.md create mode 100644 docs/v2/migration.md create mode 100644 docs/v2/multi-sku.md create mode 100644 docs/v2/ozon-publish.md rename {extension => extension-v1}/README.md (100%) rename {extension => extension-v1}/entrypoints/background.ts (100%) rename {extension => extension-v1}/entrypoints/content/index.ts (100%) rename {extension => extension-v1}/entrypoints/sidepanel/App.tsx (100%) rename {extension => extension-v1}/entrypoints/sidepanel/index.html (100%) rename {extension => extension-v1}/package.json (100%) rename {extension => extension-v1}/pnpm-lock.yaml (100%) rename {extension => extension-v1}/src/collector/dom.ts (100%) rename {extension => extension-v1}/src/collector/image.ts (100%) rename {extension => extension-v1}/src/collector/scan.ts (100%) rename {extension => extension-v1}/src/collector/ssr-builder.ts (100%) rename {extension => extension-v1}/src/collector/ssr.ts (100%) rename {extension => extension-v1}/src/collector/text.ts (100%) rename {extension => extension-v1}/src/collector/url.ts (100%) rename {extension => extension-v1}/src/profiles/1688.ts (100%) rename {extension => extension-v1}/src/profiles/index.ts (100%) rename {extension => extension-v1}/src/profiles/taobao.ts (100%) rename {extension => extension-v1}/src/profiles/types.ts (100%) rename {extension => extension-v1}/src/schema/product.ts (100%) rename {extension => extension-v1}/tsconfig.json (100%) rename {extension => extension-v1}/wxt.config.ts (100%) create mode 100644 extension-v2/README.md create mode 100644 extension-v2/entrypoints/background.ts create mode 100644 extension-v2/entrypoints/content/index.ts create mode 100644 extension-v2/entrypoints/sidepanel/App.tsx create mode 100644 extension-v2/entrypoints/sidepanel/index.html create mode 100644 extension-v2/package.json create mode 100644 extension-v2/pnpm-lock.yaml create mode 100644 extension-v2/scripts/verify-pages.ts create mode 100644 extension-v2/src/api/client.ts create mode 100644 extension-v2/src/collector/dom.ts create mode 100644 extension-v2/src/collector/image.ts create mode 100644 extension-v2/src/collector/jsonld.ts create mode 100644 extension-v2/src/collector/ozon-api.ts create mode 100644 extension-v2/src/collector/ozon-state.ts create mode 100644 extension-v2/src/collector/scan.ts create mode 100644 extension-v2/src/collector/text.ts create mode 100644 extension-v2/src/collector/url.ts create mode 100644 extension-v2/src/export/builder.ts create mode 100644 extension-v2/src/export/filesystem.ts create mode 100644 extension-v2/src/export/idb.ts create mode 100644 extension-v2/src/profiles/index.ts create mode 100644 extension-v2/src/profiles/ozon.ts create mode 100644 extension-v2/src/profiles/types.ts create mode 100644 extension-v2/src/schema/product.ts create mode 100644 extension-v2/src/storage/settings.ts create mode 100644 extension-v2/tsconfig.json create mode 100644 extension-v2/wxt.config.ts create mode 100644 server/api/auth.py create mode 100644 server/api/categories.py create mode 100644 server/api/collection.py create mode 100644 server/api/fx.py create mode 100644 server/api/products.py create mode 100644 server/api/publish.py create mode 100644 server/api/shops.py create mode 100644 server/core/__init__.py create mode 100644 server/core/security.py create mode 100644 server/db.py create mode 100644 server/deps.py create mode 100644 server/migrations/env.py create mode 100644 server/migrations/script.py.mako create mode 100644 server/migrations/versions/.gitkeep create mode 100644 server/migrations/versions/51715d16e5c3_initial_v2_schema.py create mode 100644 server/migrations/versions/658b0503f71c_add_shop_id_to_products.py create mode 100644 server/models/__init__.py create mode 100644 server/models/asset.py create mode 100644 server/models/category.py create mode 100644 server/models/enums.py create mode 100644 server/models/product.py create mode 100644 server/models/publish_task.py create mode 100644 server/models/shop.py create mode 100644 server/models/types.py create mode 100644 server/models/user.py create mode 100644 server/schemas/auth.py create mode 100644 server/schemas/collection.py create mode 100644 server/schemas/product.py create mode 100644 server/schemas/shop.py create mode 100644 server/services/fx.py create mode 100644 server/services/ozon_client.py create mode 100644 server/services/publish.py create mode 100644 server/services/storage.py create mode 100644 studio/src/components/RequireAuth.tsx create mode 100644 studio/src/pages/collection/CollectionPage.tsx create mode 100644 studio/src/pages/login/LoginPage.tsx create mode 100644 studio/src/pages/product/AttributePanel.tsx create mode 100644 studio/src/pages/product/CopyPanel.tsx create mode 100644 studio/src/pages/product/FieldLabel.tsx create mode 100644 studio/src/pages/product/ImagePanel.tsx create mode 100644 studio/src/pages/product/MainInfoPanel.tsx create mode 100644 studio/src/pages/product/PriceInfoPanel.tsx create mode 100644 studio/src/pages/product/ProductAttributesPanel.tsx create mode 100644 studio/src/pages/product/ProductEditPage.tsx create mode 100644 studio/src/pages/product/PublishPanel.tsx create mode 100644 studio/src/pages/shops/ShopsPage.tsx create mode 100644 studio/src/pricing/pricing.ts create mode 100644 studio/src/services/ai.ts create mode 100644 studio/src/services/auth.ts create mode 100644 studio/src/services/category.ts create mode 100644 studio/src/services/fx.ts create mode 100644 studio/src/services/product.ts create mode 100644 studio/src/services/publish.ts create mode 100644 studio/src/services/shop.ts diff --git a/.env.example b/.env.example index df583d6..e2d74a2 100644 --- a/.env.example +++ b/.env.example @@ -11,3 +11,26 @@ HOST=127.0.0.1 PORT=8800 # Comma-separated origins when frontend runs on another port. Same-origin mount can leave empty. CORS_ORIGINS= + +# ── V2:数据层 ── +# 本地过渡用 SQLite(默认);上线腾讯云切 PostgreSQL: +# DATABASE_URL=postgresql+asyncpg://user:pass@host:5432/ozon_seller +# DATABASE_URL=sqlite+aiosqlite:///./data/app.db + +# ── V2:鉴权 ── +# MVP 单用户登录 token(studio 登录页 / 插件 options 页填同一个值),可随意生成一长串随机字符串 +APP_TOKEN=change-me-to-a-long-random-token +# 店铺 Client-Id/Api-Key 的 AES-GCM 加密密钥 + JWT 签名密钥 +SECRET_KEY=change-me-to-a-long-random-secret + +# ── V2:七牛(图片存储)── +# 留空则用本地文件系统兜底(开发期);填了并设 STORAGE_BACKEND=qiniu 则走七牛 +QINIU_ACCESS_KEY= +QINIU_SECRET_KEY= +QINIU_BUCKET= +# 必须 https!Ozon 拉取商品图片只接受 https 直链,http 会被拒绝 +QINIU_DOMAIN=https://your-cdn-domain.example.com +STORAGE_BACKEND=local + +# ── V2:对外地址(插件/前端回写、生成图回调)── +APP_BASE_URL=http://127.0.0.1:8800 diff --git a/.gitignore b/.gitignore index 9edea78..1020a09 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ .DS_Store web/ozonSeller.html.bak +# V2 运行时数据(SQLite + 本地媒体) +data/ + # 反编译参考资料(约 40 个 bundle),设计结论已写入 docs/extension/plan.md §2 reference/ diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..51efb23 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,39 @@ +[alembic] +script_location = server/migrations +prepend_sys_path = server +# URL 由 env.py 从 server/config/settings.py 读取(DATABASE_URL),此处留空 +sqlalchemy.url = + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/docs/ozon-seller-api/01-authentication.md b/docs/ozon-seller-api/01-authentication.md new file mode 100644 index 0000000..6c2980e --- /dev/null +++ b/docs/ozon-seller-api/01-authentication.md @@ -0,0 +1,234 @@ +# Ozon Seller API 鉴权与基础 + +> 官方文档:https://docs.ozon.ru/api/seller/zh/#tag/Introduction + +--- + +## 1. 鉴权方式 + +Ozon Seller API 使用 **API Key 鉴权**(非 OAuth),每个请求需在请求头携带: + +```http +Client-Id: <你的 Client ID> +Api-Key: <你的 API Key> +Content-Type: application/json +``` + +### 获取凭证 + +1. 登录 Ozon 卖家后台 +2. 进入「设置」→「Seller API」 +3. 点击「生成 API Key」 +4. 选择权限级别: + - **只读**(Read):仅查询 + - **读写**(Read & Write):查询 + 创建/更新商品 + - **管理员**(Admin):所有权限 +5. 保存 `Client-Id` 和 `Api-Key`(**Api-Key 仅显示一次**) + +### 安全约束 + +- ⚠️ **Api-Key 等同密码**:泄露后任何人可操作你的店铺 +- 🔒 **服务端存储**:加密落库(AES-GCM),前端永不传输/回显明文 +- 🔄 **定期轮换**:建议每 90 天更换一次 +- 🚫 **前端禁用**:插件/studio 不得持有店铺凭证,只能持有用户 token + +--- + +## 2. Base URL + +``` +https://api-seller.ozon.ru +``` + +所有接口路径都基于此 URL,例如: +``` +POST https://api-seller.ozon.ru/v3/product/import +``` + +--- + +## 3. 请求示例 + +### cURL + +```bash +curl -X POST "https://api-seller.ozon.ru/v1/description-category/tree" \ + -H "Client-Id: 123456" \ + -H "Api-Key: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" \ + -H "Content-Type: application/json" \ + -d '{"language":"RU"}' +``` + +### Python (httpx) + +```python +import httpx + +headers = { + "Client-Id": "123456", + "Api-Key": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "Content-Type": "application/json" +} + +async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.post( + "https://api-seller.ozon.ru/v1/description-category/tree", + headers=headers, + json={"language": "RU"} + ) + data = resp.json() +``` + +--- + +## 4. 通用响应结构 + +### 成功响应(200/201) + +```json +{ + "result": { /* 业务数据 */ } +} +``` + +部分接口直接返回数组或对象,不包裹 `result`。 + +### 错误响应(4xx/5xx) + +```json +{ + "code": 400, + "message": "INVALID_ARGUMENT", + "details": [ + { + "typeUrl": "type.googleapis.com/ozon.ValidationError", + "value": "..." + } + ] +} +``` + +或简化版: + +```json +{ + "error": { + "code": "INVALID_PARAMETER", + "message": "offer_id is required" + } +} +``` + +--- + +## 5. 错误码 + +| HTTP 状态码 | 含义 | 常见原因 | 处理建议 | +|---|---|---|---| +| **400** | 参数错误 | 必填字段缺失 / 格式错误 / 枚举值非法 | 检查请求体字段,读 `details` 定位 | +| **401** | 未鉴权 | 请求头缺 `Client-Id` 或 `Api-Key` | 检查请求头 | +| **403** | 权限不足 | Api-Key 权限级别不够(如只读 key 调创建接口) | 重新生成读写权限 key | +| **404** | 资源不存在 | `product_id` / `category_id` 不存在 | 检查 ID 是否正确 | +| **409** | 资源冲突 | `offer_id` 重复 / 商品已存在 | 改用唯一 offer_id 或走更新接口 | +| **429** | 限流 | 请求频率超限 | 指数退避重试(1s → 2s → 4s) | +| **500** | 服务端错误 | Ozon 内部错误 | 重试 1-2 次,仍失败则联系支持 | +| **503** | 服务不可用 | 维护中 | 稍后重试 | + +--- + +## 6. 限流规则 + +官方未公开明确的限流阈值,根据社区经验: + +- **常规接口**:~10 req/s +- **批量接口**(如 `/v3/product/list`):~5 req/s +- **同一 task_id 轮询**:建议间隔 ≥5s + +触发 429 后: +1. 解析响应头 `Retry-After`(秒数) +2. 若无此头,使用指数退避:1s → 2s → 4s → 8s +3. 最多重试 3 次 + +--- + +## 7. 超时建议 + +| 接口类型 | 超时时间 | 理由 | +|---|---|---| +| 查询类(类目/属性/商品列表) | 30s | 轻量请求 | +| 导入类(`/v3/product/import`) | 60-90s | 后端需校验 + 入库 | +| 轮询状态(`/v1/product/import/info`) | 30s | 单次轮询快,但需多次 | +| 图片上传 | 90s | 网络传输耗时 | + +--- + +## 8. 测试凭证有效性 + +### `/v1/roles` —— 获取当前 Key 的角色与权限 + +```http +POST https://api-seller.ozon.ru/v1/roles +``` + +**请求体**:空 `{}` + +**响应**: + +```json +{ + "result": [ + { + "role_name": "Seller", + "permissions": [ + "read:products", + "write:products", + "read:categories", + ... + ] + } + ] +} +``` + +**用途**: +- ✅ 验证凭证有效性(200 = 有效,401/403 = 无效) +- ✅ 查看权限范围(判断是否有 `write:products`) +- ✅ 零业务副作用(不消耗额度,不修改数据) + +**V2 集成点**:`POST /api/shops/:id/test` 调此接口作连通性校验。 + +--- + +## 9. 请求 ID 追踪 + +部分接口响应包含 `request_id`(如图生图、导入任务),用于: +- 问题排查:联系 Ozon 支持时提供此 ID +- 幂等重试:某些接口可根据 `request_id` 避免重复创建 + +建议:每次请求在日志里记录 `request_id`(若有)与请求体摘要,便于回溯。 + +--- + +## 10. 环境 + +Ozon Seller API **仅生产环境**,无测试沙箱。调试时需注意: +- ⚠️ 所有操作都在真实店铺 +- 💡 建议用「测试商品」标识(如 offer_id 前缀 `TEST-`) +- 🗑️ 测试后及时删除/归档测试商品 + +--- + +## 11. SDK 与工具 + +官方未提供 Python SDK,社区方案: +- 自封装 `httpx` 客户端(V2 采用,见 `server/services/ozon_client.py`) +- 第三方库:`ozon-api`(PyPI,非官方,更新滞后) + +--- + +## 12. 相关链接 + +- [官方文档(中文)](https://docs.ozon.ru/api/seller/zh/) +- [官方文档(俄文)](https://docs.ozon.ru/api/seller/) +- [卖家后台](https://seller.ozon.ru/) +- [API 状态页](https://status.ozon.ru/)(维护公告) diff --git a/docs/ozon-seller-api/02-category-tree.md b/docs/ozon-seller-api/02-category-tree.md new file mode 100644 index 0000000..31e0d82 --- /dev/null +++ b/docs/ozon-seller-api/02-category-tree.md @@ -0,0 +1,333 @@ +# 类目树查询 API + +> 官方文档:https://docs.ozon.ru/api/seller/zh/#operation/DescriptionCategoryAPI_GetTree + +--- + +## 接口信息 + +| 项 | 值 | +|---|---| +| 方法 | POST | +| 路径 | `/v1/description-category/tree` | +| 鉴权 | 需要 `Client-Id` + `Api-Key` | +| 用途 | 获取 Ozon 商品类目树(选择类目后才能发布商品) | + +--- + +## 请求 + +### 请求体 + +```json +{ + "language": "RU" +} +``` + +### 参数说明 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| language | string | 可选 | 语言代码,可选值:`DEFAULT`(英文)、`RU`(俄文)、`EN`(英文)、`ZH_HANS`(简体中文)。默认 `DEFAULT` | + +--- + +## 响应 + +### 成功响应(200) + +```json +{ + "result": [ + { + "description_category_id": 17033876, + "category_name": "Термокружки", + "type_id": 97114, + "type_name": "Термокружка", + "disabled": false, + "children": [] + }, + { + "description_category_id": 17028922, + "category_name": "Посуда", + "type_id": 0, + "type_name": "", + "disabled": true, + "children": [ + { + "description_category_id": 17033876, + "category_name": "Термокружки", + "type_id": 97114, + "type_name": "Термокружка", + "disabled": false, + "children": [] + } + ] + } + ] +} +``` + +### 字段说明 + +| 字段 | 类型 | 说明 | +|---|---|---| +| description_category_id | integer | **类目 ID**(发布商品时必填) | +| category_name | string | 类目名称 | +| type_id | integer | **商品类型 ID**(发布商品时必填,与 category_id 配对) | +| type_name | string | 商品类型名称 | +| disabled | boolean | **是否禁用**。`true` = 不可建品(父类目),`false` = 可建品(末级类目) | +| children | array | 子类目(递归结构) | + +--- + +## 关键约束 + +1. **只有末级类目可建品**:`disabled=false` 的类目才能用于发布商品 +2. **必须配对使用**:发布时需同时提供 `description_category_id` + `type_id` +3. **层级结构**:类目可能有多层嵌套(最多 5-6 层),需递归遍历找到末级 + +--- + +## 使用场景 + +### 场景 1:前端类目选择器 + +``` +① 请求类目树(language=RU,给俄文用户看) +② 递归展开树形结构 +③ 用户选择类目后,校验 disabled=false(若 true 则禁止选择或自动展开子级) +④ 选中后保存 description_category_id + type_id +``` + +### 场景 2:服务端缓存 + +``` +① 启动时拉取类目树(language=DEFAULT,英文字段名便于代码处理) +② 存入 category_tree 表(见 docs/v2/database.md §2.7) +③ TTL 24h,过期重拉 +④ 用户选类目时直接查库,不频繁调 API +``` + +--- + +## 示例代码 + +### Python(服务端缓存) + +```python +import httpx +from typing import List, Dict, Any + +async def fetch_category_tree( + client_id: str, + api_key: str, + language: str = "DEFAULT" +) -> List[Dict[str, Any]]: + """拉取类目树并返回扁平化列表""" + headers = { + "Client-Id": client_id, + "Api-Key": api_key, + "Content-Type": "application/json" + } + + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.post( + "https://api-seller.ozon.ru/v1/description-category/tree", + headers=headers, + json={"language": language} + ) + resp.raise_for_status() + data = resp.json() + + # 递归扁平化 + def flatten(nodes: List[Dict], level: int = 0, parent_id: int = 0): + flat = [] + for node in nodes: + flat.append({ + "description_category_id": node["description_category_id"], + "parent_id": parent_id, + "category_name": node["category_name"], + "type_id": node["type_id"], + "type_name": node["type_name"], + "disabled": node["disabled"], + "level": level, + "lang": language + }) + if node.get("children"): + flat.extend(flatten( + node["children"], + level + 1, + node["description_category_id"] + )) + return flat + + return flatten(data.get("result", [])) +``` + +### TypeScript(前端选择器) + +```typescript +interface CategoryNode { + description_category_id: number; + category_name: string; + type_id: number; + type_name: string; + disabled: boolean; + children: CategoryNode[]; +} + +async function fetchCategoryTree(language = 'RU'): Promise { + const resp = await fetch('/api/categories/tree?lang=' + language); + const data = await resp.json(); + return data.result; +} + +// 转为 antd Tree 数据结构 +function toTreeData(nodes: CategoryNode[]): any[] { + return nodes.map(node => ({ + key: `${node.description_category_id}-${node.type_id}`, + title: node.category_name, + disabled: node.disabled, // 父类目禁止选择 + children: node.children.length > 0 ? toTreeData(node.children) : undefined, + // 保存原始数据,选中时取用 + data: { + description_category_id: node.description_category_id, + type_id: node.type_id + } + })); +} +``` + +--- + +## 缓存策略 + +### 全局缓存(推荐) + +```python +# 类目树与店铺无关,所有店铺共用一份 +# 启动时拉取,存内存 + 数据库 +# TTL 24h(类目变化不频繁) + +from functools import lru_cache +from datetime import datetime, timedelta + +_category_tree_cache = None +_cache_time = None + +@lru_cache(maxsize=1) +async def get_category_tree_cached(language: str = "DEFAULT"): + global _category_tree_cache, _cache_time + + now = datetime.utcnow() + if _category_tree_cache and _cache_time and (now - _cache_time) < timedelta(hours=24): + return _category_tree_cache + + # 从任意店铺拉(类目树全局一致) + tree = await fetch_category_tree(any_client_id, any_api_key, language) + _category_tree_cache = tree + _cache_time = now + + # 同时写数据库 + await save_to_db(tree) + + return tree +``` + +### 按需更新 + +```python +# 用户反馈「找不到某类目」时手动刷新 +async def refresh_category_tree(): + global _category_tree_cache, _cache_time + _category_tree_cache = None + _cache_time = None + get_category_tree_cached.cache_clear() + return await get_category_tree_cached() +``` + +--- + +## 常见问题 + +### Q1: 类目树很大吗? + +**A**: 约 **1-2 万个类目节点**,JSON 约 3-5MB。首次拉取需几秒,后续从缓存读取。 + +### Q2: 多久更新一次? + +**A**: Ozon 不定期新增类目(月级别),建议 **24h TTL + 手动刷新入口**。 + +### Q3: 不同语言的类目树结构一样吗? + +**A**: 结构一致(`description_category_id` / `type_id` 相同),仅 `category_name` / `type_name` 翻译不同。建议: +- 服务端缓存 `DEFAULT`(英文,便于代码处理) +- 前端按用户语言拉取 `RU`(俄文,展示用) + +### Q4: `type_id=0` 是什么意思? + +**A**: 父类目(`disabled=true`)的 `type_id` 为 0,表示该节点不是商品类型,只是分类层级。只有末级类目的 `type_id > 0`。 + +--- + +## V2 项目集成 + +### API 层(已实现) + +```python +# server/api/categories.py + +@router.get("/categories/tree") +async def get_tree( + lang: str = Query("RU"), + shop_id: str = Query(...), # 需要店铺凭证 + db: AsyncSession = Depends(get_db) +): + shop = await get_shop(db, shop_id) + tree = await ozon_client.get_category_tree( + shop.client_id_dec, + shop.api_key_dec, + lang + ) + return {"result": tree} +``` + +### 前端(待实现) + +```tsx +// studio/src/pages/product/components/CategoryPicker.tsx + +import { Tree } from 'antd'; +import { useEffect, useState } from 'react'; + +export function CategoryPicker({ shopId, onChange }) { + const [treeData, setTreeData] = useState([]); + + useEffect(() => { + fetch(`/api/categories/tree?shop_id=${shopId}&lang=RU`) + .then(r => r.json()) + .then(data => setTreeData(toTreeData(data.result))); + }, [shopId]); + + return ( + { + if (!node.disabled) { + onChange(node.data); // { description_category_id, type_id } + } + }} + /> + ); +} +``` + +--- + +## 相关文档 + +- [03-category-attributes.md](./03-category-attributes.md) —— 获取类目属性 +- [docs/v2/database.md](../v2/database.md) §2.7 —— `category_tree` 表结构 +- [docs/v2/ozon-publish.md](../v2/ozon-publish.md) §3 —— 类目字典缓存策略 diff --git a/docs/ozon-seller-api/03-category-attributes.md b/docs/ozon-seller-api/03-category-attributes.md new file mode 100644 index 0000000..b487fe2 --- /dev/null +++ b/docs/ozon-seller-api/03-category-attributes.md @@ -0,0 +1,660 @@ +# 类目属性与字典值 API + +> 官方文档:https://docs.ozon.ru/api/seller/zh/#operation/DescriptionCategoryAPI_GetAttributes + +--- + +## 1. 获取类目属性 + +### 接口信息 + +| 项 | 值 | +|---|---| +| 方法 | POST | +| 路径 | `/v1/description-category/attribute` | +| 鉴权 | 需要 `Client-Id` + `Api-Key` | +| 用途 | 获取指定类目的所有属性(发布商品时需填写) | + +--- + +### 请求 + +```json +{ + "description_category_id": 17033876, + "type_id": 97114, + "language": "RU" +} +``` + +#### 参数说明 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| description_category_id | integer | ✅ 是 | 类目 ID(从类目树获取) | +| type_id | integer | ✅ 是 | 商品类型 ID(从类目树获取,与 category_id 配对) | +| language | string | 可选 | 语言代码:`DEFAULT` / `RU` / `EN` / `ZH_HANS`。默认 `DEFAULT` | + +--- + +### 响应 + +```json +{ + "result": [ + { + "id": 85, + "name": "Бренд", + "description": "Укажите бренд товара", + "type": "String", + "is_collection": false, + "is_required": true, + "is_aspect": false, + "max_value_count": 1, + "dictionary_id": 28732, + "category_dependent": false, + "group_id": 0, + "group_name": "" + }, + { + "id": 8229, + "name": "Цвет товара", + "description": "", + "type": "String", + "is_collection": false, + "is_required": false, + "is_aspect": true, + "max_value_count": 1, + "dictionary_id": 61405, + "category_dependent": false, + "group_id": 1, + "group_name": "Варианты" + }, + { + "id": 9048, + "name": "Объем", + "description": "Укажите объем в миллилитрах", + "type": "Integer", + "is_collection": false, + "is_required": true, + "is_aspect": false, + "max_value_count": 1, + "dictionary_id": 0, + "category_dependent": false, + "group_id": 2, + "group_name": "Основные" + } + ] +} +``` + +--- + +### 字段说明 + +| 字段 | 类型 | 说明 | +|---|---|---| +| **id** | integer | **属性 ID**(发布时填 `attributes[].id`) | +| **name** | string | 属性名称(如"品牌"、"颜色") | +| description | string | 属性说明(填写提示) | +| **type** | string | 值类型:`String` / `Integer` / `Decimal` / `Boolean` / `URL` | +| **is_required** | boolean | **是否必填**。`true` = 必须填写,否则发布失败 | +| **is_aspect** | boolean | **是否变体属性**(如颜色/尺码)。`true` = 该属性用于区分 SKU 变体 | +| **is_collection** | boolean | 是否多值。`true` = 可填多个值(如"适用场景:家用,办公") | +| max_value_count | integer | 最多值数量(`is_collection=true` 时有效) | +| **dictionary_id** | integer | **字典 ID**。`> 0` = 有预设值字典(需调字典值接口),`0` = 自由输入 | +| category_dependent | boolean | 字典值是否依赖类目(`true` = 不同类目的字典值不同) | +| group_id | integer | 属性分组 ID | +| group_name | string | 属性分组名(如"基本信息"、"变体") | + +--- + +### 关键字段组合 + +| 组合 | 含义 | 示例 | 填写方式 | +|---|---|---|---| +| `is_required=true` | **必填** | 品牌、尺寸、重量 | 必须有值,否则发布失败 | +| `dictionary_id > 0` | **有字典** | 品牌、颜色、材质 | 值必须从字典选(dictionary_value_id) | +| `dictionary_id = 0` | **自由输入** | 商品名、描述、数值 | 直接填文本/数字 | +| `is_aspect=true` | **变体属性** | 颜色、尺码 | 用于区分 SKU(不同颜色 = 不同 SKU) | +| `is_collection=true` | **多值** | 适用场景、材质组成 | 可传数组 `["值1", "值2"]` | + +--- + +## 2. 获取属性值字典 + +### 接口信息 + +| 项 | 值 | +|---|---| +| 方法 | POST | +| 路径 | `/v1/description-category/attribute/values` | +| 鉴权 | 需要 `Client-Id` + `Api-Key` | +| 用途 | 获取属性的预设值字典(`dictionary_id > 0` 的属性) | + +--- + +### 请求 + +```json +{ + "attribute_id": 85, + "description_category_id": 17033876, + "type_id": 97114, + "language": "RU", + "limit": 1000, + "last_value_id": 0 +} +``` + +#### 参数说明 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| attribute_id | integer | ✅ 是 | 属性 ID | +| description_category_id | integer | ✅ 是 | 类目 ID | +| type_id | integer | ✅ 是 | 商品类型 ID | +| language | string | 可选 | 语言代码 | +| limit | integer | 可选 | 每页数量,最大 **5000**,默认 1000 | +| last_value_id | integer | 可选 | 分页游标(上一页最后一个值的 `id`),首页传 0 | + +--- + +### 响应 + +```json +{ + "result": [ + { + "id": 971082156, + "value": "Thermos", + "info": "", + "picture": "" + }, + { + "id": 971317107, + "value": "Stanley", + "info": "", + "picture": "" + } + ], + "has_next": true +} +``` + +#### 字段说明 + +| 字段 | 类型 | 说明 | +|---|---|---| +| **id** | integer | **字典值 ID**(发布时填 `attributes[].values[].dictionary_value_id`) | +| **value** | string | 字典值文本(如品牌名"Thermos") | +| info | string | 补充说明 | +| picture | string | 值配图 URL(部分属性有,如颜色) | +| **has_next** | boolean | 是否有下一页(`true` = 用最后一个 `id` 继续分页) | + +--- + +### 分页示例 + +```python +async def fetch_all_values(attribute_id, category_id, type_id): + all_values = [] + last_id = 0 + + while True: + resp = await client.post( + "https://api-seller.ozon.ru/v1/description-category/attribute/values", + json={ + "attribute_id": attribute_id, + "description_category_id": category_id, + "type_id": type_id, + "language": "RU", + "limit": 5000, + "last_value_id": last_id + } + ) + data = resp.json() + values = data.get("result", []) + all_values.extend(values) + + if not data.get("has_next") or not values: + break + + last_id = values[-1]["id"] + + return all_values +``` + +--- + +## 3. 按关键词搜索属性值 + +### 接口信息 + +| 项 | 值 | +|---|---| +| 方法 | POST | +| 路径 | `/v1/description-category/attribute/values/search` | +| 鉴权 | 需要 `Client-Id` + `Api-Key` | +| 用途 | 模糊搜索字典值(避免拉全量字典) | + +--- + +### 请求 + +```json +{ + "attribute_id": 85, + "description_category_id": 17033876, + "type_id": 97114, + "language": "RU", + "value": "Ther", + "limit": 100 +} +``` + +#### 参数说明 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| attribute_id | integer | ✅ 是 | 属性 ID | +| description_category_id | integer | ✅ 是 | 类目 ID | +| type_id | integer | ✅ 是 | 商品类型 ID | +| language | string | 可选 | 语言代码 | +| **value** | string | ✅ 是 | 搜索关键词(≥2 个字符) | +| limit | integer | 可选 | 返回数量,最大 **100**,默认 50 | + +--- + +### 响应 + +```json +{ + "result": [ + { + "id": 971082156, + "value": "Thermos", + "info": "", + "picture": "" + }, + { + "id": 971982345, + "value": "Thermocafe", + "info": "", + "picture": "" + } + ] +} +``` + +--- + +## 4. 属性映射工作流 + +### 场景:采集来的参数 → Ozon 属性 + +``` +采集原文(raw.params): +[ + { "key": "Материал", "value": "Нержавеющая сталь" }, + { "key": "Объем", "value": "500 мл" }, + { "key": "Бренд", "value": "Thermos" } +] + +↓ ① 自动匹配属性名 + +attributes = [ + { id: 8505, name: "Материал" }, // 材质 + { id: 9048, name: "Объем" }, // 容量 + { id: 85, name: "Бренд" } // 品牌 +] + +↓ ② 对有字典的属性(dictionary_id > 0),搜索字典值 + +POST /attribute/values/search { + attribute_id: 85, // 品牌 + value: "Thermos" +} +→ { id: 971082156, value: "Thermos" } + +↓ ③ 组装最终 attributes + +products.attributes = [ + { + "complex_id": 0, + "id": 8505, + "values": [{ "value": "Нержавеющая сталь" }] // 材质无字典,直接填 + }, + { + "complex_id": 0, + "id": 9048, + "values": [{ "value": "500" }] // 容量是数值,提取数字 + }, + { + "complex_id": 0, + "id": 85, + "values": [{ "dictionary_value_id": 971082156, "value": "Thermos" }] // 品牌有字典 + } +] +``` + +--- + +## 5. 自动匹配策略 + +### 策略 A:归一化 + 模糊匹配 + +```python +import re +from difflib import SequenceMatcher + +def normalize(text: str) -> str: + """归一化:小写 + 去标点 + 词干""" + text = text.lower().strip() + text = re.sub(r'[^\w\s]', '', text) + # 俄文词干化(需 pymorphy2 库) + # text = morph.parse(text)[0].normal_form + return text + +def fuzzy_match(采集key: str, 属性列表: list, threshold=0.8): + """模糊匹配:相似度 > 0.8 即认为匹配""" + norm_key = normalize(采集key) + best = None + best_score = 0 + + for attr in 属性列表: + norm_name = normalize(attr["name"]) + score = SequenceMatcher(None, norm_key, norm_name).ratio() + if score > best_score: + best = attr + best_score = score + + return best if best_score >= threshold else None +``` + +### 策略 B:关键词映射表 + +```python +# 预定义常见映射(中文采集 key → Ozon 属性 name) +KEYWORD_MAP = { + "品牌": ["Бренд", "Brand"], + "材质": ["Материал", "Material"], + "重量": ["Вес", "Weight"], + "尺寸": ["Размер", "Size"], + "颜色": ["Цвет", "Color"], + # ... 补充更多 +} + +def keyword_match(采集key: str, 属性列表: list): + for cn_key, ru_keys in KEYWORD_MAP.items(): + if cn_key in 采集key: + for attr in 属性列表: + if any(rk in attr["name"] for rk in ru_keys): + return attr + return None +``` + +--- + +## 6. 必填项校验 + +### 发布前校验 + +```python +async def validate_required_attributes( + category_id: int, + type_id: int, + attributes: list +) -> list[str]: + """返回缺失的必填属性名列表""" + # 获取该类目的所有属性 + attrs = await fetch_category_attributes(category_id, type_id) + + # 提取必填属性 + required = [a for a in attrs if a["is_required"]] + + # 已填写的属性 ID + filled_ids = {a["id"] for a in attributes} + + # 找出缺失的 + missing = [a["name"] for a in required if a["id"] not in filled_ids] + + return missing + +# 使用 +missing = await validate_required_attributes(17033876, 97114, product.attributes) +if missing: + raise ValueError(f"缺少必填属性:{', '.join(missing)}") +``` + +--- + +## 7. 缓存策略 + +### 属性列表缓存 + +```python +# 按 (category_id, type_id) 缓存 +# TTL 7 天(属性变化极少) + +from functools import lru_cache + +@lru_cache(maxsize=500) +async def get_attributes_cached(category_id: int, type_id: int): + # 先查数据库 + cached = await db.query(CategoryAttribute).filter_by( + description_category_id=category_id, + type_id=type_id + ).all() + + if cached: + return cached + + # 未缓存,调 API + attrs = await fetch_category_attributes(category_id, type_id) + + # 写入数据库 + await save_attributes_to_db(attrs) + + return attrs +``` + +### 字典值缓存(按需) + +```python +# 字典值可能很大(数万条),不全量缓存 +# 策略:用户映射到某属性时,才拉该属性的字典(且优先用 /search) + +async def get_attribute_values(attr_id, category_id, type_id, keyword=None): + if keyword: + # 有关键词 → 搜索接口(limit 100) + return await search_values(attr_id, category_id, type_id, keyword) + else: + # 无关键词 → 拉全量(分页,存数据库) + return await fetch_all_values(attr_id, category_id, type_id) +``` + +--- + +## 8. 前端交互设计 + +### 属性映射 UI(推荐) + +``` +┌─────────────────────────────────────────────────┐ +│ 类目属性映射 │ +├─────────────────────────────────────────────────┤ +│ 采集属性 → Ozon 属性 │ +├─────────────────────────────────────────────────┤ +│ ✅ Материал (材质) → [自动] Материал (8505) │ +│ 值:Нержавеющая сталь │ +├─────────────────────────────────────────────────┤ +│ ✅ Бренд (品牌) → [自动] Бренд (85) ⚠️必填 │ +│ 值:Thermos → 字典值:[选择 ▼] │ +│ ├ Thermos ✅ │ +│ ├ Stanley │ +│ └ ... │ +├─────────────────────────────────────────────────┤ +│ ⚠️ 未匹配:包装重量 │ +│ → [手动选择属性 ▼] │ +├─────────────────────────────────────────────────┤ +│ ❌ 缺少必填属性: │ +│ - Объем (容量) [+添加] │ +│ - Цвет (颜色) [+添加] │ +└─────────────────────────────────────────────────┘ +``` + +--- + +## 9. 常见问题 + +### Q1: 属性太多怎么办? + +**A**: 一个类目可能有 **50-100+ 属性**,但常用的只有 10-20 个。策略: +- 必填属性置顶 + 高亮 +- 已匹配属性展开,未匹配折叠 +- 提供搜索/筛选 + +### Q2: 字典值有多大? + +**A**: +- 小字典(颜色/材质):几十到几百条 +- 大字典(品牌):**数万条**(如品牌字典 > 50,000) +- 策略:**优先用 `/values/search`**,避免拉全量 + +### Q3: 自由输入的属性怎么填? + +**A**: `dictionary_id=0` 的属性直接填 `value`,无需 `dictionary_value_id`: + +```json +{ + "id": 9048, + "values": [{ "value": "500" }] // 容量,数值类型 +} +``` + +### Q4: 如何处理多值属性? + +**A**: `is_collection=true` 时传数组: + +```json +{ + "id": 10096, + "values": [ + { "value": "家用" }, + { "value": "办公" } + ] +} +``` + +--- + +## 10. V2 项目集成 + +### API 层(已实现) + +```python +# server/api/categories.py + +@router.get("/categories/{category_id}/attributes") +async def get_attributes( + category_id: int, + type_id: int = Query(...), + shop_id: str = Query(...), + db: AsyncSession = Depends(get_db) +): + shop = await get_shop(db, shop_id) + attrs = await ozon_client.get_category_attributes( + shop.client_id_dec, + shop.api_key_dec, + category_id, + type_id + ) + return {"result": attrs} + +@router.get("/categories/attribute/{attribute_id}/values") +async def get_attribute_values( + attribute_id: int, + category_id: int = Query(...), + type_id: int = Query(...), + q: str = Query(None), # 搜索关键词 + shop_id: str = Query(...), + db: AsyncSession = Depends(get_db) +): + shop = await get_shop(db, shop_id) + + if q and len(q) >= 2: + # 搜索接口 + values = await ozon_client.search_attribute_values( + shop.client_id_dec, shop.api_key_dec, + attribute_id, category_id, type_id, q + ) + else: + # 全量拉取(分页) + values = await ozon_client.get_attribute_values( + shop.client_id_dec, shop.api_key_dec, + attribute_id, category_id, type_id + ) + + return {"result": values} +``` + +### 前端(待实现) + +```tsx +// studio/src/pages/product/components/AttributeMapper.tsx + +import { Form, Select, Input, Tag } from 'antd'; + +export function AttributeMapper({ categoryId, typeId, rawParams, onChange }) { + const [attributes, setAttributes] = useState([]); + + useEffect(() => { + fetch(`/api/categories/${categoryId}/attributes?type_id=${typeId}`) + .then(r => r.json()) + .then(data => setAttributes(data.result)); + }, [categoryId, typeId]); + + // 自动匹配 + const autoMatch = () => { + const matched = rawParams.map(p => { + const attr = attributes.find(a => + normalize(a.name) === normalize(p.key) + ); + return attr ? { ...p, attrId: attr.id, attr } : p; + }); + return matched; + }; + + return ( +
+ {autoMatch().map((item, i) => ( + + {item.attr?.dictionary_id > 0 ? ( + + )} + + ))} +
+ ); +} +``` + +--- + +## 相关文档 + +- [02-category-tree.md](./02-category-tree.md) —— 获取类目树 +- [04-product-import.md](./04-product-import.md) —— 发布商品(使用属性) +- [docs/v2/database.md](../v2/database.md) §2.7 —— 属性缓存表结构 +- [docs/v2/ozon-publish.md](../v2/ozon-publish.md) §3 —— 属性映射策略 diff --git a/docs/ozon-seller-api/04-product-import.md b/docs/ozon-seller-api/04-product-import.md new file mode 100644 index 0000000..080c923 --- /dev/null +++ b/docs/ozon-seller-api/04-product-import.md @@ -0,0 +1,616 @@ +# 商品导入(创建/更新)API + +> 官方文档:https://docs.ozon.ru/api/seller/zh/#operation/ProductAPI_ImportProductsV3 + +--- + +## 接口信息 + +| 项 | 值 | +|---|---| +| 方法 | POST | +| 路径 | `/v3/product/import` | +| 鉴权 | 需要 `Client-Id` + `Api-Key` | +| 用途 | **创建或更新商品**(Ozon 核心接口) | +| 异步 | ✅ 返回 `task_id`,需轮询 `/v1/product/import/info` 获取最终状态 | + +--- + +## 1. 请求体结构 + +### 完整示例 + +```json +{ + "items": [ + { + "offer_id": "MY-THERMOS-001", + "name": "Термокружка Thermos из нержавеющей стали 500 мл", + "description": "Термокружка из высококачественной нержавеющей стали. Сохраняет температуру до 6 часов.", + "description_category_id": 17033876, + "type_id": 97114, + "price": "2990", + "old_price": "3490", + "currency_code": "RUB", + "vat": "0", + "depth": 80, + "width": 80, + "height": 200, + "dimension_unit": "mm", + "weight": 320, + "weight_unit": "g", + "images": [ + "https://cdn.example.com/thermos-main-1.jpg", + "https://cdn.example.com/thermos-main-2.jpg" + ], + "primary_image": "", + "images360": [], + "color_image": "", + "barcode": "", + "attributes": [ + { + "complex_id": 0, + "id": 85, + "values": [ + { + "dictionary_value_id": 971082156, + "value": "Thermos" + } + ] + }, + { + "complex_id": 0, + "id": 8505, + "values": [ + { + "value": "Нержавеющая сталь" + } + ] + } + ], + "complex_attributes": [] + } + ] +} +``` + +--- + +## 2. 字段说明 + +### 基本信息 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| **offer_id** | string | ✅ 是 | **自己的货号**(唯一标识,用于更新)。最长 255 字符 | +| **name** | string | ✅ 是 | 商品名称。最长 500 字符 | +| **description** | string | ✅ 是 | 商品描述。最长 5000 字符,支持 HTML 标签 | +| **description_category_id** | integer | ✅ 是 | 类目 ID(从类目树获取) | +| **type_id** | integer | ✅ 是 | 商品类型 ID(从类目树获取) | + +### 价格 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| **price** | string | ✅ 是 | 销售价(字符串格式,如 `"2990"` = 2990 卢布) | +| old_price | string | 可选 | 划线价(原价),用于展示折扣 | +| **currency_code** | string | ✅ 是 | 币种,通常 `"RUB"`(卢布)。也可 `"CNY"` 等 | +| **vat** | string | ✅ 是 | 增值税率:`"0"` / `"0.1"` / `"0.2"`。俄罗斯默认 `"0"` | + +### 尺寸与重量 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| **depth** | number | ✅ 是 | 长度(包装尺寸)。**不能为 0** | +| **width** | number | ✅ 是 | 宽度(包装尺寸)。**不能为 0** | +| **height** | number | ✅ 是 | 高度(包装尺寸)。**不能为 0** | +| **dimension_unit** | string | ✅ 是 | 尺寸单位:`"mm"` / `"cm"` | +| **weight** | number | ✅ 是 | 重量(包装重量)。**不能为 0** | +| **weight_unit** | string | ✅ 是 | 重量单位:`"g"` / `"kg"` | + +⚠️ **硬约束**:尺寸和重量必须 **> 0**,否则 API 返回 400 错误。 + +### 图片 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| **images** | array | ✅ 是 | **主图数组**(最多 15 张)。顺序即展示顺序,第一张为主图 | +| primary_image | string | 可选 | 主图(单独指定)。若使用则 `images` 最多 14 张 | +| images360 | array | 可选 | 360° 图片数组 | +| color_image | string | 可选 | 营销色图(部分类目支持) | + +⚠️ **硬约束**: +- 图片 URL 必须是 **https 公网直链**(http 会被拒绝) +- 图片需可访问(Ozon 服务器会主动拉取) +- 建议尺寸:≥ 700×700 px,白底,主体占画面 80%+ + +### 属性 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| **attributes** | array | ✅ 是 | 商品属性数组(从类目属性获取) | +| attributes[].complex_id | integer | ✅ 是 | 复杂属性 ID,通常填 `0` | +| attributes[].id | integer | ✅ 是 | 属性 ID | +| attributes[].values | array | ✅ 是 | 属性值数组 | +| values[].dictionary_value_id | integer | 条件 | 字典值 ID(属性有字典时必填) | +| values[].value | string | ✅ 是 | 属性值文本 | + +### 复杂属性 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| complex_attributes | array | 可选 | 复杂属性(视频、尺码表等) | + +### 其他 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| barcode | string | 可选 | 条形码 | +| pdf_list | array | 可选 | PDF 文件 URL 列表 | + +--- + +## 3. 响应 + +### 成功响应(200) + +```json +{ + "result": { + "task_id": 123456789 + } +} +``` + +| 字段 | 类型 | 说明 | +|---|---|---| +| task_id | integer | **任务 ID**(用于轮询状态,见下节) | + +⚠️ **此时商品尚未创建**,需轮询 `/v1/product/import/info` 获取最终结果。 + +--- + +## 4. 轮询任务状态 + +### 接口信息 + +| 项 | 值 | +|---|---| +| 方法 | POST | +| 路径 | `/v1/product/import/info` | +| 鉴权 | 需要 `Client-Id` + `Api-Key` | + +### 请求 + +```json +{ + "task_id": 123456789 +} +``` + +### 响应 + +```json +{ + "result": { + "items": [ + { + "offer_id": "MY-THERMOS-001", + "product_id": 987654321, + "status": "imported", + "errors": [] + } + ] + } +} +``` + +### 状态值 + +| status | 含义 | 处理 | +|---|---|---| +| **imported** | ✅ 导入成功 | 保存 `product_id`,标记商品为 `published` | +| **pending** | ⏳ 排队中 | 继续轮询(间隔 5s) | +| **processing** | ⏳ 处理中 | 继续轮询(间隔 5s) | +| **moderation** | ⏳ 审核中 | 继续轮询(间隔 30s,审核可能需几小时) | +| **failed** | ❌ 失败 | 读取 `errors` 数组,展示错误给用户 | + +### 错误结构 + +```json +{ + "offer_id": "MY-THERMOS-001", + "product_id": 0, + "status": "failed", + "errors": [ + { + "code": "INVALID_ATTRIBUTE", + "message": "Attribute 'Бренд' is required", + "field": "attributes" + } + ] +} +``` + +--- + +## 5. 轮询策略 + +### 推荐策略 + +```python +import asyncio + +async def wait_for_import(task_id: int, timeout=300): + """轮询导入状态,最多等待 5 分钟""" + start = time.time() + interval = 5 # 初始间隔 5s + + while time.time() - start < timeout: + resp = await ozon_client.get_import_info(task_id) + item = resp["result"]["items"][0] + status = item["status"] + + if status == "imported": + return {"success": True, "product_id": item["product_id"]} + + if status == "failed": + return {"success": False, "errors": item["errors"]} + + if status == "moderation": + interval = 30 # 审核阶段降低频率 + + await asyncio.sleep(interval) + + # 超时:不算失败,标记为"审核中"继续后台轮询 + return {"success": None, "status": "timeout"} +``` + +### 后台轮询(推荐) + +```python +# 用户提交发布后立即返回,后台协程轮询 +# 状态变化时通知前端(WebSocket / 长轮询 / 前端定时刷新) + +async def background_poll_task(task_id: int, product_id: str): + """后台协程,轮询直到完成或失败""" + result = await wait_for_import(task_id, timeout=3600) # 最多 1 小时 + + # 更新数据库 + await db.execute( + update(Product) + .where(Product.id == product_id) + .values( + stage="published" if result["success"] else "failed", + ozon_product_id=result.get("product_id"), + published_at=datetime.utcnow() if result["success"] else None + ) + ) + + # 记录任务结果 + await db.execute( + update(PublishTask) + .where(PublishTask.ozon_task_id == task_id) + .values( + status=result.get("status"), + errors=result.get("errors"), + completed_at=datetime.utcnow() + ) + ) +``` + +--- + +## 6. 创建 vs 更新 + +### 创建新商品 + +```json +{ + "offer_id": "NEW-PRODUCT-001", // 全新 offer_id + // ... 其他字段 +} +``` + +- 如果 `offer_id` 不存在 → 创建新商品 +- 如果 `offer_id` 已存在 → 返回 409 冲突 + +### 更新已有商品 + +```json +{ + "offer_id": "EXISTING-001", // 已存在的 offer_id + // ... 要更新的字段(可部分更新) +} +``` + +或使用 `product_id`: + +```json +{ + "product_id": 987654321, // Ozon 商品 ID + // ... 要更新的字段 +} +``` + +⚠️ **注意**: +- 更新时,未传的字段**保持原值**(非清空) +- 图片数组传空 `[]` 会清空图片(需小心) +- 建议更新前先读取当前值(`/v3/product/info/list`) + +--- + +## 7. 批量导入 + +单次请求最多 **100 个 item**: + +```json +{ + "items": [ + { "offer_id": "PROD-001", /* ... */ }, + { "offer_id": "PROD-002", /* ... */ }, + // ... 最多 100 个 + ] +} +``` + +响应包含每个 item 的状态: + +```json +{ + "result": { + "items": [ + { "offer_id": "PROD-001", "status": "imported", "product_id": 111 }, + { "offer_id": "PROD-002", "status": "failed", "errors": [...] } + ] + } +} +``` + +--- + +## 8. 常见错误 + +### 错误码速查 + +| code | message | 原因 | 解决 | +|---|---|---|---| +| `INVALID_PARAMETER` | 参数错误 | 必填字段缺失 / 格式错误 | 检查字段完整性 | +| `INVALID_ATTRIBUTE` | 属性错误 | 缺少必填属性 / 字典值不匹配 | 补全必填属性,校验字典值 | +| `INVALID_CATEGORY` | 类目错误 | `description_category_id` 不存在或已禁用 | 重新选择类目 | +| `INVALID_IMAGE` | 图片错误 | URL 不可访问 / 非 https / 格式不支持 | 检查图片 URL 有效性 | +| `OFFER_ID_DUPLICATE` | offer_id 重复 | 该 offer_id 已存在 | 换一个唯一 offer_id 或走更新 | +| `DIMENSION_REQUIRED` | 尺寸必填 | 尺寸/重量为 0 或缺失 | 填写正确尺寸重量 | +| `PRICE_INVALID` | 价格错误 | 价格 ≤ 0 或格式错误 | 检查价格字段 | + +### 典型错误示例 + +#### 错误 1:尺寸为 0 + +```json +{ + "errors": [ + { + "code": "DIMENSION_REQUIRED", + "message": "Dimensions must be greater than 0", + "field": "weight" + } + ] +} +``` + +**解决**:确保 `depth/width/height/weight` 都 > 0。 + +#### 错误 2:缺少必填属性 + +```json +{ + "errors": [ + { + "code": "INVALID_ATTRIBUTE", + "message": "Required attribute 'Бренд' (id=85) is missing", + "field": "attributes" + } + ] +} +``` + +**解决**:补充缺失的必填属性。 + +#### 错误 3:图片 URL 不可访问 + +```json +{ + "errors": [ + { + "code": "INVALID_IMAGE", + "message": "Image URL is not accessible: https://...", + "field": "images[0]" + } + ] +} +``` + +**解决**: +1. 检查 URL 是 https(非 http) +2. 检查 URL 公网可访问(Ozon 服务器需能拉取) +3. 检查图片格式(支持 jpg/png/webp) + +--- + +## 9. 发布后操作 + +### 设置库存(必须) + +商品导入成功后**不会自动上架**,需设置库存才能开售: + +```http +POST /v2/products/stocks +``` + +```json +{ + "stocks": [ + { + "product_id": 987654321, + "offer_id": "MY-THERMOS-001", + "stock": 100, + "warehouse_id": 12345678 + } + ] +} +``` + +⚠️ 不设置库存 → 商品在后台但不可购买。 + +### 查询商品详情 + +```http +POST /v3/product/info/list +``` + +```json +{ + "offer_id": ["MY-THERMOS-001"] +} +``` + +返回商品完整信息(含审核状态、图片、属性)。 + +--- + +## 10. V2 项目集成 + +### API 层(已实现) + +```python +# server/api/publish.py + +@router.post("/products/{product_id}/publish") +async def publish_product( + product_id: str, + shop_id: str = Body(...), + db: AsyncSession = Depends(get_db) +): + # 1. 取商品数据 + product = await get_product(db, product_id) + + # 2. 校验必填项 + validate_required_fields(product) + + # 3. 组装 ImportProductsV3 请求体 + item = build_import_item(product) + + # 4. 调用 Ozon API + shop = await get_shop(db, shop_id) + resp = await ozon_client.import_products( + shop.client_id_dec, + shop.api_key_dec, + [item] + ) + + task_id = resp["result"]["task_id"] + + # 5. 记录发布任务 + task = PublishTask( + product_id=product_id, + shop_id=shop_id, + ozon_task_id=task_id, + status="pending", + request_payload=item + ) + db.add(task) + await db.commit() + + # 6. 启动后台轮询 + asyncio.create_task(background_poll_task(task_id, product_id)) + + return {"task_id": task_id} + +def build_import_item(product: Product) -> dict: + """组装 ImportProductsV3 items[0]""" + return { + "offer_id": product.offer_id, + "name": product.name, + "description": product.description, + "description_category_id": product.description_category_id, + "type_id": product.type_id, + "price": str(product.price), + "old_price": str(product.old_price) if product.old_price else "", + "currency_code": product.currency_code, + "vat": product.vat, + "depth": product.depth, + "width": product.width, + "height": product.height, + "dimension_unit": product.dimension_unit, + "weight": product.weight, + "weight_unit": product.weight_unit, + "images": product.images, # 七牛 URL 数组 + "primary_image": product.primary_image or "", + "images360": product.images360 or [], + "color_image": product.color_image or "", + "barcode": product.barcode or "", + "attributes": product.attributes or [], + "complex_attributes": product.complex_attributes or [] + } +``` + +### 前端(待实现) + +```tsx +// studio/src/pages/product/components/PublishPanel.tsx + +import { Button, Select, message } from 'antd'; + +export function PublishPanel({ productId }) { + const [shops, setShops] = useState([]); + const [publishing, setPublishing] = useState(false); + + const handlePublish = async (shopId) => { + setPublishing(true); + try { + const resp = await fetch(`/api/products/${productId}/publish`, { + method: 'POST', + body: JSON.stringify({ shop_id: shopId }) + }); + const data = await resp.json(); + + message.success('发布任务已提交,轮询中...'); + + // 轮询状态(或 WebSocket 推送) + pollPublishStatus(data.task_id); + } catch (err) { + message.error(`发布失败: ${err.message}`); + } finally { + setPublishing(false); + } + }; + + return ( +
+ setBaseUrl(e.target.value)} + placeholder="http://127.0.0.1:8800" + /> +
+
+ 访问 Token(可选,留空即可) + setAppToken(e.target.value)} + placeholder="后续加账户体系时再填" + /> +
+ + + ), + }, + ]} + /> + + {error && } + + {result && ( + <> + +
+ + {result.platform.toUpperCase()} + {result.itemId && {result.itemId}} + 来源:{result.source} + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* 参数表(左侧参数名只读,右侧参数值可编辑) */} + {params.length > 0 && ( + 参数表({params.length} 项), + children: ( +
+ {params.map((p, i) => ( + + + + {p.key || '—'} + + + + { + const next = [...params]; + next[i] = { ...next[i], value: e.target.value }; + setParams(next); + }} + /> + + + ))} +
+ ), + }, + ]} + /> + )} + +
+ + {/* 图片分组 */} + + {groups.map((g) => { + const allOn = g.items.every((i) => selected.has(i.key)); + return ( +
+ +
+ {g.items.map((img) => { + const on = selected.has(img.key); + return ( +
toggleOne(img.key)} + style={{ + position: 'relative', + width: 64, + height: 64, + border: on ? `2px solid ${token.colorPrimary}` : '1px solid #e0e0e0', + borderRadius: 8, + overflow: 'hidden', + cursor: 'pointer', + background: '#f5f5f5', + }} + > + {img.type === 'video' ? ( +
+ {img.thumbUrl && !/\.(mp4|webm|m3u8|mov|avi)(\?|$)/i.test(img.thumbUrl) ? ( + 视频封面 + ) : null} +
+ +
+
+ ) : ( + {img.variantName + )} + {on && ( +
+ +
+ )} + {img.variantName && ( +
+ {img.variantName} +
+ )} +
+ ); + })} +
+
+ ); + })} +
+ + {/* 警告 */} + {result.warnings.length > 0 && ( +
+ {result.warnings.map((w, i) => ( + + ))} +
+ )} + +
+ {/* 本地文件夹名 + 导出/上传(固定在底部) */} +
+ 本地文件夹名 + setFolderName(e.target.value)} + placeholder="留空用商品标题" + size="small" + /> +
+
+
+ {rootLabel ? `保存到:${rootLabel}` : '未选择保存目录'} +
+ +
+ + + + + + + + + + + {exportResult && ( + + )} + {uploadResult && ( + + )} +
+ + )} + + {!result && ( +
+
💡 使用说明:
+
    +
  1. 打开 Ozon 商品详情页(ru/kz/by)
  2. +
  3. 滚动到页面底部(加载详情图)
  4. +
  5. 点「开始采集」→ 核对/修改信息 → 导出或上传
  6. +
+
+ )} + + + ); +} + +function Root() { + return ( + + + + + + ); +} + +const root = createRoot(document.getElementById('root')!); +root.render(); + +export default Root; diff --git a/extension-v2/entrypoints/sidepanel/index.html b/extension-v2/entrypoints/sidepanel/index.html new file mode 100644 index 0000000..f454ee1 --- /dev/null +++ b/extension-v2/entrypoints/sidepanel/index.html @@ -0,0 +1,30 @@ + + + + + + 套娃采集助手 + + + +
+ + + diff --git a/extension-v2/package.json b/extension-v2/package.json new file mode 100644 index 0000000..d2d3a0c --- /dev/null +++ b/extension-v2/package.json @@ -0,0 +1,26 @@ +{ + "name": "taowa-collector", + "version": "0.2.0", + "type": "module", + "private": true, + "scripts": { + "dev": "wxt", + "build": "wxt build", + "zip": "wxt zip" + }, + "dependencies": { + "@ant-design/icons": "^6.3.2", + "antd": "^6.6.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/chrome": "^0.0.268", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@types/wicg-file-system-access": "^2023.10.7", + "typescript": "^5.5.3", + "wxt": "^0.19.0" + }, + "packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be" +} diff --git a/extension-v2/pnpm-lock.yaml b/extension-v2/pnpm-lock.yaml new file mode 100644 index 0000000..34f5aea --- /dev/null +++ b/extension-v2/pnpm-lock.yaml @@ -0,0 +1,4419 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@ant-design/icons': + specifier: ^6.3.2 + version: 6.3.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + antd: + specifier: ^6.6.0 + version: 6.6.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) + devDependencies: + '@types/chrome': + specifier: ^0.0.268 + version: 0.0.268 + '@types/react': + specifier: ^18.3.3 + version: 18.3.31 + '@types/react-dom': + specifier: ^18.3.0 + version: 18.3.7(@types/react@18.3.31) + '@types/wicg-file-system-access': + specifier: ^2023.10.7 + version: 2023.10.7 + typescript: + specifier: ^5.5.3 + version: 5.9.3 + wxt: + specifier: ^0.19.0 + version: 0.19.29(@types/node@26.2.0)(rollup@4.62.4) + +packages: + + '@1natsu/wait-element@4.2.0': + resolution: {integrity: sha512-Om0Q+WE9mNrpY4AwMTvkFiYHv8VM7TML3PvOqXy+w6kAjLjKhGYHYX+305+a6J8RVpds9s7IF2Z5aOPYwULFNw==} + + '@aklinker1/rollup-plugin-visualizer@5.12.0': + resolution: {integrity: sha512-X24LvEGw6UFmy0lpGJDmXsMyBD58XmX1bbwsaMLhNoM+UMQfQ3b2RtC+nz4b/NoRK5r6QJSKJHBNVeUdwqybaQ==} + engines: {node: '>=14'} + hasBin: true + peerDependencies: + rollup: 2.x || 3.x || 4.x + peerDependenciesMeta: + rollup: + optional: true + + '@ant-design/colors@8.0.1': + resolution: {integrity: sha512-foPVl0+SWIslGUtD/xBr1p9U4AKzPhNYEseXYRRo5QSzGACYZrQbe11AYJbYfAWnWSpGBx6JjBmSeugUsD9vqQ==} + + '@ant-design/cssinjs-utils@2.1.2': + resolution: {integrity: sha512-5fTHQ158jJJ5dC/ECeyIdZUzKxE/mpEMRZxthyG1sw/AKRHKgJBg00Yi6ACVXgycdje7KahRNvNET/uBccwCnA==} + peerDependencies: + react: '>=18' + react-dom: '>=18' + + '@ant-design/cssinjs@2.1.2': + resolution: {integrity: sha512-2Hy8BnCEH31xPeSLbhhB2ctCPXE2ZnASdi+KbSeS79BNbUhL9hAEe20SkUk+BR8aKTmqb6+FKFruk7w8z0VoRQ==} + peerDependencies: + react: '>=16.0.0' + react-dom: '>=16.0.0' + + '@ant-design/fast-color@3.0.1': + resolution: {integrity: sha512-esKJegpW4nckh0o6kV3Tkb7NPIZYbPnnFxmQDUmL08ukXZAvV85TZBr70eGuke/CIArLaP6aw8lt9KILjnWuOw==} + engines: {node: '>=8.x'} + + '@ant-design/icons-svg@4.5.0': + resolution: {integrity: sha512-1BTUFyKPTBZ53MuTP8s0k5SFEXL7o3VHEOwLgzaoWKwnBeqIcqUtVshc4SKzhI6uACfqhJqBwBUE9FsWR3uULA==} + + '@ant-design/icons@6.3.2': + resolution: {integrity: sha512-B6O5a5XJ4wjtNOfZejXYwHW5zvKV5gYkjGf11dHGLEbKn0ABDGndo41+gfIiXyTFhvESj4XTotuud33mUFid0g==} + engines: {node: '>=8'} + peerDependencies: + react: '>=16.0.0' + react-dom: '>=16.0.0' + + '@ant-design/react-slick@2.0.0': + resolution: {integrity: sha512-HMS9sRoEmZey8LsE/Yo6+klhlzU12PisjrVcydW3So7RdklyEd2qehyU6a7Yp+OYN72mgsYs3NFCyP2lCPFVqg==} + peerDependencies: + react: ^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/runtime@7.28.2': + resolution: {integrity: sha512-KHp2IflsnGywDjBWDkR9iEqiWSpc8GIi0lgTT3mOElT0PP1tG26P4tmFI2YvAdzgq9RGyoHZQEIEdZy6Ec5xCA==} + engines: {node: '>=6.9.0'} + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@babel/runtime@8.0.0': + resolution: {integrity: sha512-sL6cvO2IfkSu/iU+zs2S/w01B7A8V7suXSIKEN4hPFFdZoiPGxrj5pAG0lCaqLWiEIrjKzdznIWuaLcxPR53qw==} + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@devicefarmer/adbkit-logcat@2.1.3': + resolution: {integrity: sha512-yeaGFjNBc/6+svbDeul1tNHtNChw6h8pSHAt5D+JsedUrMTN7tla7B15WLDyekxsuS2XlZHRxpuC6m92wiwCNw==} + engines: {node: '>= 4'} + + '@devicefarmer/adbkit-monkey@1.2.1': + resolution: {integrity: sha512-ZzZY/b66W2Jd6NHbAhLyDWOEIBWC11VizGFk7Wx7M61JZRz7HR9Cq5P+65RKWUU7u6wgsE8Lmh9nE4Mz+U2eTg==} + engines: {node: '>= 0.10.4'} + + '@devicefarmer/adbkit@3.3.8': + resolution: {integrity: sha512-7rBLLzWQnBwutH2WZ0EWUkQdihqrnLYCUMaB44hSol9e0/cdIhuNFcqZO0xNheAU6qqHVA8sMiLofkYTgb+lmw==} + engines: {node: '>= 0.10.4'} + hasBin: true + + '@emotion/hash@0.8.0': + resolution: {integrity: sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==} + + '@emotion/unitless@0.7.5': + resolution: {integrity: sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==} + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@pnpm/config.env-replace@1.1.0': + resolution: {integrity: sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==} + engines: {node: '>=12.22.0'} + + '@pnpm/network.ca-file@1.0.2': + resolution: {integrity: sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA==} + engines: {node: '>=12.22.0'} + + '@pnpm/npm-conf@3.0.3': + resolution: {integrity: sha512-//0sR/cow/s4ICQaYoAobOl4aU8cjU6x/V24V7XkKotb9+O+3zySIYp146vpaobYHnxa4pZX8NkV54Z5AwbDKA==} + engines: {node: '>=12'} + + '@rc-component/async-validator@6.0.0': + resolution: {integrity: sha512-D3AGQwdyE58gmvx6waVSXJ80JGO+IY5L2O8HDnSOex7JNlzB3GuN/4hyHNTdhy2qtOhkpbIjmeAN3tL993wKbA==} + engines: {node: '>=14.x'} + + '@rc-component/cascader@1.22.0': + resolution: {integrity: sha512-SffrA57aS9oub3VuI7ajPhJTPtaNxngSvtRhD40Rd8dwJ5vfWPSrVanWgeepdWFGBt7EHftIK5RUU0u3rCTwWw==} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + + '@rc-component/checkbox@2.0.0': + resolution: {integrity: sha512-3CXGPpAR9gsPKeO2N78HAPOzU30UdemD6HGJoWVJOpa6WleaGB5kzZj3v6bdTZab31YuWgY/RxV3VKPctn0DwQ==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/collapse@1.2.0': + resolution: {integrity: sha512-ZRYSKSS39qsFx93p26bde7JUZJshsUBEQRlRXPuJYlAiNX0vyYlF5TsAm8JZN3LcF8XvKikdzPbgAtXSbkLUkw==} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + + '@rc-component/color-picker@3.1.1': + resolution: {integrity: sha512-OHaCHLHszCegdXmIq2ZRIZBN/EtpT6Wm8SG/gpzLATHbVKc/avvuKi+zlOuk05FTWvgaMmpxAko44uRJ3M+2pg==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/context@2.0.2': + resolution: {integrity: sha512-uiGpAlblCNlziHPwj4S4Iy/oemeuz/hR03mbiEjTCXwsqOIN3BOzsRMyDwpyO5Fm0vIEEJRUf9ZtbRLbhksuTA==} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + + '@rc-component/dialog@1.10.0': + resolution: {integrity: sha512-eDukNlz9vNszAGv7i3zKXdxEd3wgVmNxuJijYt8zvTh17QwTu8KK/bdURRd/lU4qaMzhO1HKKmMrwOnkaw0BvQ==} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + + '@rc-component/drawer@1.4.2': + resolution: {integrity: sha512-1ib+fZEp6FBu+YvcIktm+nCQ+Q+qIpwpoaJH6opGr4ofh2QMq+qdr5DLC4oCf5qf3pcWX9lUWPYX652k4ini8Q==} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + + '@rc-component/dropdown@1.0.3': + resolution: {integrity: sha512-YTST/N6kpqpDz3IMuM/PSSZnrDpSOA6dgHv12gPA90ZTSLv2CoqkZ0+9NtwTY6BeO7dstPblSic2QJg7dSFy/g==} + peerDependencies: + react: '>=16.11.0' + react-dom: '>=16.11.0' + + '@rc-component/form@1.8.6': + resolution: {integrity: sha512-1EXVsSKPZC6kGrIqxVck7kAWM6T65b6et/n5wIcyiaIa41VrDshD0nVLHoBDEIZr2ATQsOP6icb4XQkNFMLBAw==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/image@1.10.0': + resolution: {integrity: sha512-BjeZCRQ+hw+4WAhvrw8rJvy5fckA2xpf/X2XQEOABUHvLTNB9inB98X3Mp54jYQ7g10DfWERQWHXeC4ylxp1Uw==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/input-number@1.6.2': + resolution: {integrity: sha512-Gjcq7meZlCOiWN1t1xCC+7/s85humHVokTBI7PJgTfoyw5OWF74y3e6P8PHX104g9+b54jsodFIzyaj6p8LI9w==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/input@1.3.1': + resolution: {integrity: sha512-iFvTUT9W+JC/MSin2aGAk8NqsVlTzcExNC9DZariON1IWirju9NoNeEk47an4Q8iHazkoVI/y1LnDi88+CPcig==} + peerDependencies: + react: '>=16.0.0' + react-dom: '>=16.0.0' + + '@rc-component/listy@1.2.3': + resolution: {integrity: sha512-IXiMjV5s0rczLBlfh7G5nB4M3365mrEeedjwKtf5I+Ns3PqRUsebR2h5u8CeFarsVfLUPC2I5p0h09TNoOWyvQ==} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + + '@rc-component/mentions@1.11.0': + resolution: {integrity: sha512-IC2qXuEBMFHxPIXEFfYWj6Sr7UiDZnOqJHCYQBbwPzopBJOPZIR6mV9U4QH1bYQRlKYlYnIsajWDMgVGgWQyWQ==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/menu@1.4.1': + resolution: {integrity: sha512-3GsVRoQ4cnF/AoIQ4P+Z1haBfgfBPQfLT1RJY3Nu4DzOnheTslfCiGSPj7bv/cLj5sW5pHqN25dDXGP3JELAlQ==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/mini-decimal@1.1.4': + resolution: {integrity: sha512-xiuXcaCwyOWpD8a8scdExFl+bntNphAW8XeenL1ig2en0AAZY0Pcp4pC0dI22qJ+NvxKn9RoNIoRdqYU3BLH4w==} + engines: {node: '>=8.x'} + + '@rc-component/motion@1.3.3': + resolution: {integrity: sha512-Xh3IszxvlSv3/PLYFyC2UZi9LNB83yOnkB/LNmRzaypZLvkhqUIPS7MQpGZcCMWrNsXV2p6YTSWbSGvFpEle9A==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/mutate-observer@2.0.1': + resolution: {integrity: sha512-AyarjoLU5YlxuValRi+w8JRH2Z84TBbFO2RoGWz9d8bSu0FqT8DtugH3xC3BV7mUwlmROFauyWuXFuq4IFbH+w==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/notification@2.0.7': + resolution: {integrity: sha512-nqZzpf6BPdaj+3ILx7si79LLmqPKyUmQoXa+/9gg0SkH0v1DbD66oJgRMSBEVnd/zUT3D4gwxWIHUKebYf2ZXQ==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + + '@rc-component/overflow@1.0.1': + resolution: {integrity: sha512-syfmgAABaHCnCDzPwHZ/2tuvIcpOO3jefYZMmfkN+pmo8HKTzsfhS57vxo4ksPdN0By+uWVJhJWNFozNBxi2eA==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/pagination@1.4.0': + resolution: {integrity: sha512-CW1g7P9V8u+e8JQdUsl2RWg+GCsoee0mtJjZUCCxn/vb3jzOwDKm6hAdwddHCVBfWJ58eGUBZz3IvnU8rRktjw==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/picker@1.12.0': + resolution: {integrity: sha512-0FZGgDiDZFMm2hcfGv4jyjm7yWIj2MiwfeTzLL0vWkO+8cCSKdiM9XhWpkn1aAsoI2EwUy48Oz2cGfvL8ZKrfw==} + engines: {node: '>=12.x'} + peerDependencies: + date-fns: '>= 2.x' + dayjs: '>= 1.x' + luxon: '>= 3.x' + moment: '>= 2.x' + react: '>=16.9.0' + react-dom: '>=16.9.0' + peerDependenciesMeta: + date-fns: + optional: true + dayjs: + optional: true + luxon: + optional: true + moment: + optional: true + + '@rc-component/portal@2.2.1': + resolution: {integrity: sha512-ck+r1kW/JSv0wxPji3KN2ss9K6Z0qqwusw/mf/0JobXhZ8hC2ejZwCJObW/SvDi0uhA0VzmCnx0CaCci95tcmA==} + engines: {node: '>=12.x'} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + + '@rc-component/progress@1.0.2': + resolution: {integrity: sha512-WZUnH9eGxH1+xodZKqdrHke59uyGZSWgj5HBM5Kwk5BrTMuAORO7VJ2IP5Qbm9aH3n9x3IcesqHHR0NWPBC7fQ==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/qrcode@2.0.0': + resolution: {integrity: sha512-aAv3QhPP1xyafuTZOxub6a54pCeBnN3IwQkpETrBtthq4BL5IgxnCbuoBWPDpdLw1y1j6BgBUCAKV92+yX06Dw==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/rate@1.0.1': + resolution: {integrity: sha512-bkXxeBqDpl5IOC7yL7GcSYjQx9G8H+6kLYQnNZWeBYq2OYIv1MONd6mqKTjnnJYpV0cQIU2z3atdW0j1kttpTw==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/resize-observer@1.1.2': + resolution: {integrity: sha512-t/Bb0W8uvL4PYKAB3YcChC+DlHh0Wt5kM7q/J+0qpVEUMLe7Hk5zuvc9km0hMnTFPSx5Z7Wu/fzCLN6erVLE8Q==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/segmented@1.3.0': + resolution: {integrity: sha512-5J/bJ01mbDnoA6P/FW8SxUvKn+OgUSTZJPzCNnTBntG50tzoP7DydGhqxp7ggZXZls7me3mc2EQDXakU3iTVFg==} + peerDependencies: + react: '>=16.0.0' + react-dom: '>=16.0.0' + + '@rc-component/select@1.10.1': + resolution: {integrity: sha512-H+yQsl+qED9NilQ3g6zdpsMwUgwVjrcMTkNHAWRVU/MoNCYgTbDgU+MIMgZDK+rVdd2JUfI/MkysMcZZ0cyQKw==} + engines: {node: '>=8.x'} + peerDependencies: + react: '*' + react-dom: '*' + + '@rc-component/slider@1.1.1': + resolution: {integrity: sha512-LSzgWGYDgeCDgR4r1XlU29gbYws6HpLnvJd/uMhLeW/vQgxldeR+Wb4uzHDCHiYEbr1bnEHWdjkPxjJRHxuiig==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/steps@1.2.2': + resolution: {integrity: sha512-/yVIZ00gDYYPHSY0JP+M+s3ZvuXLu2f9rEjQqiUDs7EcYsUYrpJ/1bLj9aI9R7MBR3fu/NGh6RM9u2qGfqp+Nw==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/switch@1.0.3': + resolution: {integrity: sha512-Jgi+EbOBquje/XNdofr7xbJQZPYJP+BlPfR0h+WN4zFkdtB2EWqEfvkXJWeipflwjWip0/17rNbxEAqs8hVHfw==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/table@1.11.1': + resolution: {integrity: sha512-OWdS6DMmeWb7bJBGqPxYZpQbzBlBiXZUu2sqo6Ii7Sjs9GeK1IsrXrWk26SL2c6KEseabswdxrRj7WUm9LdECw==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + + '@rc-component/tabs@1.12.0': + resolution: {integrity: sha512-XL7Kqy5fnUE2WTlO1/fCGrrfNlGFebdr7JseGkEIjzcVMAtIFQJ8sqCSOmxcXstjU6fonD/4rnhZHxj7sDTajQ==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/tooltip@1.5.0': + resolution: {integrity: sha512-agQ/+mBqrEQfTX4D3KhQ7j+ZbX4/VHjoJ7Noa2wIdZ1/FbQTOd7Sn92rp+jtCoqAVTLUgSOydePIgZ204gi2EQ==} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + + '@rc-component/tour@2.4.0': + resolution: {integrity: sha512-aui4r4TqmTzwaBgcQxHYep8kM8PTjZFufjokObpy35KfFeZ0k9ArquWFZqegQlH24P14t+F0qO0mGTgzlav1yg==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/tree-select@1.16.1': + resolution: {integrity: sha512-a1Oi6EJhqAhdOxxupdJi6fP0RPHMKn5TcfkX2+llaQ4lF4nwfH7b6SCHcnsybaa2s+pk1yZYwVyeOYkDnEBRdg==} + peerDependencies: + react: '*' + react-dom: '*' + + '@rc-component/tree@1.4.0': + resolution: {integrity: sha512-dGsJGDJQedA0BqqVgj3F8BvHXTSZijyhTXdbAdkcx8lynzZkty/CV3Z3LOm/fxz+BCfl3dfGiAQpb7Q5XNvl0Q==} + engines: {node: '>=10.x'} + peerDependencies: + react: '*' + react-dom: '*' + + '@rc-component/trigger@3.10.1': + resolution: {integrity: sha512-mXlDN0IXdtV8Yqqm8195ECCyrbmfvvfKvwVvSlH0+qvKD6BUF8gRhEjSy0FOcD1+CcDRHgTiX99LoxfQrmh3Cw==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + + '@rc-component/upload@1.1.1': + resolution: {integrity: sha512-GvYWSKeaJTOxxC5p6+nOSadzfvXA1h8C/iHFPFZX+szH3JUXrvs+DLiW8YUTBgvMh8m63mJeHrlYlJzAlg+pDA==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/util@1.12.0': + resolution: {integrity: sha512-AEjPL8JVdohIITaiXokyjL9WQ6tKWWjAYK9QU16tGNE9JaQABBQy+hA4H2Lup5MgXy9yY3iLrbZJheuU13hTdQ==} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + + '@rc-component/virtual-list@1.5.1': + resolution: {integrity: sha512-boqHxdtyWC88u8quYgEO49bcBy5fzRiOcnBge+N4nLzs2k8hUQ/yw7JE9dM6yCBE4jSm5YSHVCVMS+suBuJGKA==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + + '@rollup/pluginutils@5.4.0': + resolution: {integrity: sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/rollup-android-arm-eabi@4.62.4': + resolution: {integrity: sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.4': + resolution: {integrity: sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.4': + resolution: {integrity: sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.4': + resolution: {integrity: sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.4': + resolution: {integrity: sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.4': + resolution: {integrity: sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.4': + resolution: {integrity: sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.62.4': + resolution: {integrity: sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.62.4': + resolution: {integrity: sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.62.4': + resolution: {integrity: sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.62.4': + resolution: {integrity: sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.62.4': + resolution: {integrity: sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.62.4': + resolution: {integrity: sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.62.4': + resolution: {integrity: sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.62.4': + resolution: {integrity: sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.62.4': + resolution: {integrity: sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.62.4': + resolution: {integrity: sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.62.4': + resolution: {integrity: sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.62.4': + resolution: {integrity: sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.62.4': + resolution: {integrity: sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.4': + resolution: {integrity: sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.4': + resolution: {integrity: sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.4': + resolution: {integrity: sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.4': + resolution: {integrity: sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.4': + resolution: {integrity: sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==} + cpu: [x64] + os: [win32] + + '@types/chrome@0.0.268': + resolution: {integrity: sha512-7N1QH9buudSJ7sI8Pe4mBHJr5oZ48s0hcanI9w3wgijAlv1OZNUZve9JR4x42dn5lJ5Sm87V1JNfnoh10EnQlA==} + + '@types/chrome@0.0.280': + resolution: {integrity: sha512-AotSmZrL9bcZDDmSI1D9dE7PGbhOur5L0cKxXd7IqbVizQWCY4gcvupPUVsQ4FfDj3V2tt/iOpomT9EY0s+w1g==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/filesystem@0.0.36': + resolution: {integrity: sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==} + + '@types/filewriter@0.0.33': + resolution: {integrity: sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==} + + '@types/har-format@1.2.16': + resolution: {integrity: sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==} + + '@types/minimatch@3.0.5': + resolution: {integrity: sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==} + + '@types/node@26.2.0': + resolution: {integrity: sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==} + + '@types/prop-types@15.7.15': + resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + + '@types/react-dom@18.3.7': + resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} + peerDependencies: + '@types/react': ^18.0.0 + + '@types/react@18.3.31': + resolution: {integrity: sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==} + + '@types/webextension-polyfill@0.12.5': + resolution: {integrity: sha512-uKSAv6LgcVdINmxXMKBuVIcg/2m5JZugoZO8x20g7j2bXJkPIl/lVGQcDlbV+aXAiTyXT2RA5U5mI4IGCDMQeg==} + + '@types/wicg-file-system-access@2023.10.7': + resolution: {integrity: sha512-g49ijasEJvCd7ifmAY2D0wdEtt1xRjBbA33PJTiv8mKBr7DoMsPeISoJ8oQOTopSRi+FBWPpPW5ouDj2QPKtGA==} + + '@webext-core/fake-browser@1.5.2': + resolution: {integrity: sha512-nkDQwOJ23X5Q7cEtN6LRuBtVFf1KVOFi5GoQAro0lzqdh59F5E+K350j1isbnqYbzsXRh1NJtboudIcHfZtvOQ==} + + '@webext-core/isolated-element@1.1.5': + resolution: {integrity: sha512-4m6oP8Vzm/68YO1QmkUOZqqUcmyBtA53tji2g00/nYXE3E3IceYgeub7eIqvXDV2Z7xU6cm6qO1IMt4XFVwtvQ==} + + '@webext-core/match-patterns@1.1.0': + resolution: {integrity: sha512-vebVVbcOyva4jyvljIzRJwjFi/OKMLr96LIIxiPeXfA38gE4Z3+H6Y9DwRmn7pWErJGNNHU6XOhOb5ZwicGs7Q==} + + '@wxt-dev/browser@0.2.6': + resolution: {integrity: sha512-rQMn4gr36988e3RYVxsr0Qvy6A/9bIcA0meQvGtfQcEPmRJ73SU3xkHh6eX2yjv/j1QkMLacdzES/jf9xqz5/A==} + + '@wxt-dev/storage@1.2.9': + resolution: {integrity: sha512-dh9TJwvLBM2x4wyy9nE7eYE3wA8pgz1TXSyz7RbdjkJxLfFfWkbeIqiU7VkGy7Vx8sJBmQjoSpk9zSUb3Bjy0Q==} + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + adm-zip@0.5.18: + resolution: {integrity: sha512-ufJnssQGbxzLNS1Ho9bCtX4rQKCCvoVuDLHoJyc3F9dOGDB4BkWs2Ci0kv53lqocAEQ/Cbi+I2XCsNYGqVYqng==} + engines: {node: '>=12.0'} + + ansi-align@3.0.1: + resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} + + ansi-escapes@7.3.0: + resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} + engines: {node: '>=18'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.3.0: + resolution: {integrity: sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + antd@6.6.0: + resolution: {integrity: sha512-UDwWIbpmrCHB9ZQ+bPh4vQfB6DTI2ulIyoQ0Tc9xxalFblttiNGHl3ySBD9SyV/8+gUjFzfSx1+iU1Fog2i46w==} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + + array-differ@4.0.0: + resolution: {integrity: sha512-Q6VPTLMsmXZ47ENG3V+wQyZS1ZxXMxFyYzA+Z/GMrJ6yIutAIEf9wTyroTzmGjNfox9/h3GdGBCVh43GVFx4Uw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + array-union@3.0.1: + resolution: {integrity: sha512-1OvF9IbWwaeiM9VhzYXVQacMibxpXOMYVNIvMtKRyX9SImBXpKcFr8XvFDeEslCyuH/t6KRt7HEO94AlP8Iatw==} + engines: {node: '>=12'} + + async-mutex@0.5.0: + resolution: {integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==} + + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + atomically@2.1.1: + resolution: {integrity: sha512-P4w9o2dqARji6P7MHprklbfiArZAWvo07yW7qs3pdljb3BWr12FIB7W+p0zJiuiVsUpRO0iZn1kFFcpPegg0tQ==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + bluebird@3.7.2: + resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} + + boolbase@2.0.0: + resolution: {integrity: sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==} + engines: {node: '>=20.19.0'} + + boxen@8.0.1: + resolution: {integrity: sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==} + engines: {node: '>=18'} + + brace-expansion@1.1.18: + resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} + + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + + c12@3.3.4: + resolution: {integrity: sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==} + peerDependencies: + magicast: '*' + peerDependenciesMeta: + magicast: + optional: true + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + camelcase@8.0.0: + resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==} + engines: {node: '>=16'} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + chownr@2.0.0: + resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} + engines: {node: '>=10'} + + chrome-launcher@1.2.0: + resolution: {integrity: sha512-JbuGuBNss258bvGil7FT4HKdC3SC2K7UAEUqiPy3ACS3Yxo3hAW6bvFpCu2HsIJLgTqxgEX6BkujvzZfLpUD0Q==} + engines: {node: '>=12.13.0'} + hasBin: true + + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + engines: {node: '>=8'} + + citty@0.1.6: + resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} + + cli-boxes@3.0.0: + resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} + engines: {node: '>=10'} + + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + + cli-truncate@4.0.0: + resolution: {integrity: sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==} + engines: {node: '>=18'} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + + commander@2.9.0: + resolution: {integrity: sha512-bmkUukX8wAOjHdN26xj5c4ctEV22TQ7dQYhSmuckKhToXrkUn0iIaolHdIxYYqD55nhpSPA9zPQ1yP57GdXP2A==} + engines: {node: '>= 0.6.x'} + + commander@9.5.0: + resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} + engines: {node: ^12.20.0 || >=14} + + compute-scroll-into-view@3.1.1: + resolution: {integrity: sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + concat-stream@1.6.2: + resolution: {integrity: sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==} + engines: {'0': node >= 0.8} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + + config-chain@1.1.13: + resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} + + configstore@7.1.0: + resolution: {integrity: sha512-N4oog6YJWbR9kGyXvS7jEykLDXIE2C0ILYqNBZBp9iwiJpoCBWYsuAdW6PPFn6w06jjnC+3JstVvWHO4cZqvRg==} + engines: {node: '>=18'} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + css-select@7.0.0: + resolution: {integrity: sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==} + engines: {node: '>=20.19.0'} + + css-what@8.0.0: + resolution: {integrity: sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==} + engines: {node: '>=20.19.0'} + + cssom@0.5.0: + resolution: {integrity: sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + dayjs@1.11.21: + resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==} + + debounce@1.2.1: + resolution: {integrity: sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==} + + debug@4.3.7: + resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + + default-browser@5.5.0: + resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} + engines: {node: '>=18'} + + define-lazy-prop@2.0.0: + resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==} + engines: {node: '>=8'} + + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + dom-serializer@3.1.1: + resolution: {integrity: sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==} + engines: {node: '>=20.19.0'} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domelementtype@3.0.0: + resolution: {integrity: sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==} + engines: {node: '>=20.19.0'} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domhandler@6.0.1: + resolution: {integrity: sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==} + engines: {node: '>=20.19.0'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + domutils@4.0.2: + resolution: {integrity: sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==} + engines: {node: '>=20.19.0'} + + dot-prop@9.0.0: + resolution: {integrity: sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ==} + engines: {node: '>=18'} + + dotenv-expand@12.0.3: + resolution: {integrity: sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==} + engines: {node: '>=12'} + + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} + + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + es6-error@4.1.1: + resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-goat@4.0.0: + resolution: {integrity: sha512-2Sd4ShcWxbx6OY1IHyla/CVNwvg7XwZVoXZHcSu9w9SReNP1EzzD5T8NWKIR38fIqEns9kDWKUQTXXAmlDrdPg==} + engines: {node: '>=12'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + execa@8.0.1: + resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} + engines: {node: '>=16.17'} + + exsolve@1.1.1: + resolution: {integrity: sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-redact@3.5.0: + resolution: {integrity: sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==} + engines: {node: '>=6'} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + filesize@10.1.6: + resolution: {integrity: sha512-sJslQKU2uM33qH5nqewAwVB2QgR6w1aMNsYUp3aN5rMRyXEwJGmZvaWzeJFNTOXWlHQyBFCWrdj3fV/fsTOX8w==} + engines: {node: '>= 10.4.0'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + firefox-profile@4.7.0: + resolution: {integrity: sha512-aGApEu5bfCNbA4PGUZiRJAIU6jKmghV2UVdklXAofnNtiDjqYw0czLS46W7IfFqVKgKhFB8Ao2YoNGHY4BoIMQ==} + engines: {node: '>=18'} + hasBin: true + + form-data-encoder@4.1.0: + resolution: {integrity: sha512-G6NsmEW15s0Uw9XnCg+33H3ViYRyiM0hMrMhhqQOR8NFc5GhYrI+6I3u7OTw7b91J2g8rtvMBZJDbcGb2YUniw==} + engines: {node: '>= 18'} + + formdata-node@6.0.3: + resolution: {integrity: sha512-8e1++BCiTzUno9v5IZ2J6bv4RU+3UKDmqWUQD0MIMVCd9AdhWkO1gw57oo1mNEX1dMq2EGI+FbWz4B92pscSQg==} + engines: {node: '>= 18'} + + fs-extra@11.4.0: + resolution: {integrity: sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==} + engines: {node: '>=14.14'} + + fs-minipass@2.1.0: + resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} + engines: {node: '>= 8'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fx-runner@1.4.0: + resolution: {integrity: sha512-rci1g6U0rdTg6bAaBboP7XdRu01dzTAaKXxFf+PUqGuCv6Xu7o8NZdY1D5MvKGIjb6EdS1g3VlXOgksir1uGkg==} + hasBin: true + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + engines: {node: '>=18'} + + get-port-please@3.2.0: + resolution: {integrity: sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==} + + get-stream@8.0.1: + resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} + engines: {node: '>=16'} + + giget@1.2.5: + resolution: {integrity: sha512-r1ekGw/Bgpi3HLV3h1MRBIlSAdHoIMklpaQ3OQLFcRw9PwAj2rqigvIbg+dBUI51OxVI2jsEtDywDBjSiuf7Ug==} + hasBin: true + + giget@3.3.1: + resolution: {integrity: sha512-r+mvuDjrjMpsdw46Kmeydb8bdHm7wOKw8wNBtTndkjbPjgAp5oUJUxRE76wZFknxIPokfWvep2qSXK37aXE6zg==} + hasBin: true + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-to-regexp@0.4.1: + resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + + global-directory@4.0.1: + resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==} + engines: {node: '>=18'} + + graceful-fs@4.2.10: + resolution: {integrity: sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + graceful-readlink@1.0.1: + resolution: {integrity: sha512-8tLu60LgxF6XpdbK8OW3FA+IfTNBn1ZHGHKF4KQbEeSkajYw5PlYJcKluntgegDPTg8UkHjpet1T82vk6TQ68w==} + + growly@1.3.0: + resolution: {integrity: sha512-+xGQY0YyAWCnqy7Cd++hc2JqMYzlm0dG30Jd0beaA64sROr8C4nt8Yc9V5Ro3avlSUDTN0ulqP/VBKi1/lLygw==} + + hookable@5.5.3: + resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + + html-escaper@3.0.3: + resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} + + htmlparser2@10.1.0: + resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} + + human-signals@5.0.0: + resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} + engines: {node: '>=16.17.0'} + + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + ini@4.1.1: + resolution: {integrity: sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + ini@4.1.3: + resolution: {integrity: sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + is-absolute@0.1.7: + resolution: {integrity: sha512-Xi9/ZSn4NFapG8RP98iNPMOeaV3mXPisxKxzKtHVqr3g56j/fBn+yZmnxSVAA8lmZbl2J9b/a4kJvfU3hqQYgA==} + engines: {node: '>=0.10.0'} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-docker@2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-fullwidth-code-point@4.0.0: + resolution: {integrity: sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==} + engines: {node: '>=12'} + + is-fullwidth-code-point@5.1.0: + resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} + engines: {node: '>=18'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-in-ci@1.0.0: + resolution: {integrity: sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg==} + engines: {node: '>=18'} + hasBin: true + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + + is-installed-globally@1.0.0: + resolution: {integrity: sha512-K55T22lfpQ63N4KEN57jZUAaAYqYHEe8veb/TycJRk9DdSCLLcovXz/mL6mOnhQaZsQGwPhuFopdQIlqGSEjiQ==} + engines: {node: '>=18'} + + is-interactive@2.0.0: + resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} + engines: {node: '>=12'} + + is-mobile@5.0.0: + resolution: {integrity: sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ==} + + is-npm@6.1.0: + resolution: {integrity: sha512-O2z4/kNgyjhQwVR1Wpkbfc19JIhggF97NZNCpWTnjH7kVcZMUrnut9XSN7txI7VdyIYk5ZatOq3zvSuWpU8hoA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-path-inside@4.0.0: + resolution: {integrity: sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==} + engines: {node: '>=12'} + + is-plain-object@2.0.4: + resolution: {integrity: sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==} + engines: {node: '>=0.10.0'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + is-primitive@3.0.1: + resolution: {integrity: sha512-GljRxhWvlCNRfZyORiH77FwdFwGcMO620o37EOYC0ORWdq+WYNVqW0w2Juzew4M+L81l6/QS3t5gkkihyRqv9w==} + engines: {node: '>=0.10.0'} + + is-relative@0.1.3: + resolution: {integrity: sha512-wBOr+rNM4gkAZqoLRJI4myw5WzzIdQosFAAbnvfXP5z1LyzgAI3ivOKehC5KfqlQJZoihVhirgtCBj378Eg8GA==} + engines: {node: '>=0.10.0'} + + is-stream@3.0.0: + resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + is-unicode-supported@1.3.0: + resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==} + engines: {node: '>=12'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + + is-wsl@2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} + + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + + isexe@1.1.2: + resolution: {integrity: sha512-d2eJzK691yZwPHcv1LbeAOa91yMJ9QmfTgSO1oXB65ezVhXQsxBac2vEB4bMVms9cGzaA99n6V2viHMq82VLDw==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isobject@3.0.1: + resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==} + engines: {node: '>=0.10.0'} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + json-parse-even-better-errors@3.0.2: + resolution: {integrity: sha512-fi0NG4bPjCHunUJffmLd0gxssIgkNmArMvis4iNah6Owg1MCJjWhEcDLmsK6iGkJq3tHwbDkTlce70/tmXN4cQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + json2mq@0.2.0: + resolution: {integrity: sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonfile@6.2.1: + resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + + kleur@3.0.3: + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} + + ky@1.14.3: + resolution: {integrity: sha512-9zy9lkjac+TR1c2tG+mkNSVlyOpInnWdSMiue4F+kq8TwJSgv6o8jhLRg8Ho6SnZ9wOYUq/yozts9qQCfk7bIw==} + engines: {node: '>=18'} + + latest-version@9.0.0: + resolution: {integrity: sha512-7W0vV3rqv5tokqkBAFV1LbR7HPOWzXQDpDgEuib/aJ1jsZZx6x3c2mBI+TJhJzOhkGeaLbCKEHXEXLfirtG2JA==} + engines: {node: '>=18'} + + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + + lighthouse-logger@2.0.2: + resolution: {integrity: sha512-vWl2+u5jgOQuZR55Z1WM0XDdrJT6mzMP8zHUct7xTlWhuQs+eV0g+QL0RQdFjT54zVmbhLCP8vIVpy1wGn/gCg==} + + lines-and-columns@2.0.4: + resolution: {integrity: sha512-wM1+Z03eypVAVUCE7QdSqpVIvelbOakn1M0bPDoA4SGWPx3sNDVUiMo3L6To6WWGClB7VyXnhQ4Sn7gxiJbE6A==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + linkedom@0.18.13: + resolution: {integrity: sha512-ES/o9qotMpzpN2MHs+Iq/JcVoOj8Fa5wiQYrTdFpvAnwXL0g66XHHUc9WUMk6nAlBtGsFQ24ne+SYnvnaQ2FSw==} + engines: {node: '>=16'} + peerDependencies: + canvas: '>= 2' + peerDependenciesMeta: + canvas: + optional: true + + listr2@8.3.3: + resolution: {integrity: sha512-LWzX2KsqcB1wqQ4AHgYb4RsDXauQiqhjLk+6hjbaeHG4zpjjVAB6wC/gz6X0l+Du1cN3pUB5ZlrvTbhGSNnUQQ==} + engines: {node: '>=18.0.0'} + + local-pkg@1.2.1: + resolution: {integrity: sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==} + engines: {node: '>=14'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + log-symbols@6.0.0: + resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} + engines: {node: '>=18'} + + log-update@6.1.0: + resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} + engines: {node: '>=18'} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magicast@0.3.5: + resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + + make-error@1.3.6: + resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + + many-keys-map@3.0.3: + resolution: {integrity: sha512-1DiZmDHPXMBgMRjeUtHy1q1VYmeJscHxhIAexX9z/zjRMP80+0ETuPfssi8z+kMY4DwUgsKuHqpjxgmeA9gBNA==} + engines: {node: '>=18'} + + marky@1.3.0: + resolution: {integrity: sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mimic-fn@4.0.0: + resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} + engines: {node: '>=12'} + + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@3.3.6: + resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} + engines: {node: '>=8'} + + minipass@5.0.0: + resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==} + engines: {node: '>=8'} + + minizlib@2.1.2: + resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} + engines: {node: '>= 8'} + + mkdirp@1.0.4: + resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} + engines: {node: '>=10'} + hasBin: true + + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + multimatch@6.0.0: + resolution: {integrity: sha512-I7tSVxHGPlmPN/enE3mS1aOSo6bWBfls+3HmuEeCUBCE7gWnm3cBXCBkpurzFjVRwC6Kld8lLaZ1Iv5vOcjvcQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + nano-spawn@0.2.1: + resolution: {integrity: sha512-/pULofvsF8mOVcl/nUeVXL/GYOEvc7eJWSIxa+K4OYUolvXa5zwSgevsn4eoHs1xvh/BO3vx/PZiD9+Ow2ZVuw==} + engines: {node: '>=18.19'} + + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-fetch-native@1.6.7: + resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + + node-forge@1.4.0: + resolution: {integrity: sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==} + engines: {node: '>= 6.13.0'} + + node-notifier@10.0.1: + resolution: {integrity: sha512-YX7TSyDukOZ0g+gmzjB6abKu+hTGvO8+8+gIFDsRCU2t8fLV/P2unmt+LGFaIa4y64aX98Qksa97rgz4vMNeLQ==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + npm-run-path@5.3.0: + resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + nth-check@3.0.1: + resolution: {integrity: sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==} + engines: {node: '>=20.19.0'} + + nypm@0.3.12: + resolution: {integrity: sha512-D3pzNDWIvgA+7IORhD/IuWzEk4uXv6GsgOxiid4UU3h9oq5IqV1KtPDi63n4sZJ/xcWlr88c0QM2RgN5VbOhFA==} + engines: {node: ^14.16.0 || >=16.10.0} + hasBin: true + + nypm@0.5.4: + resolution: {integrity: sha512-X0SNNrZiGU8/e/zAB7sCTtdxWTMSIO73q+xuKgglm2Yvzwlo8UoC5FNySQFCvl84uPaeADkqHUZUkWy4aH4xOA==} + engines: {node: ^14.16.0 || >=16.10.0} + hasBin: true + + ofetch@1.5.1: + resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} + + ohash@1.1.6: + resolution: {integrity: sha512-TBu7PtV8YkAZn0tSxobKY2n2aAQva936lhRrj6957aDaCf9IEtqsKbgMzXE/F/sjqYOwmrukeORHNLe5glk7Cg==} + + ohash@2.0.12: + resolution: {integrity: sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==} + + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + + onetime@6.0.0: + resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} + engines: {node: '>=12'} + + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + + open@10.2.0: + resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} + engines: {node: '>=18'} + + open@8.4.2: + resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} + engines: {node: '>=12'} + + ora@8.2.0: + resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} + engines: {node: '>=18'} + + os-shim@0.1.3: + resolution: {integrity: sha512-jd0cvB8qQ5uVt0lvCIexBaROw1KyKm5sbulg2fWOHjETisuCzWyt+eTZKEMs8v6HwzoGs8xik26jg7eCM6pS+A==} + engines: {node: '>= 0.4.0'} + + package-json@10.0.1: + resolution: {integrity: sha512-ua1L4OgXSBdsu1FPb7F3tYH0F48a6kxvod4pLUlGY9COeJAJQNX/sNH2IiEmsxw7lqYiAwrdHMjz1FctOsyDQg==} + engines: {node: '>=18'} + + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + + parse-json@7.1.1: + resolution: {integrity: sha512-SgOTCX/EZXtZxBE5eJ97P4yGM5n37BwRU+YMsH4vNzFqJV/oWFXXCmwFlgWUM4PrakybVOueJJ6pwHqSVhTFDw==} + engines: {node: '>=16'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + + perfect-debounce@2.1.0: + resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + pino-abstract-transport@2.0.0: + resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==} + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@9.7.0: + resolution: {integrity: sha512-vnMCM6xZTb1WDmLvtG2lE/2p+t9hDEIvTWJsu6FejkE62vB7gDhvzrpFR4Cw2to+9JNQxVnkAKVPA1KPB98vWg==} + hasBin: true + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + pkg-types@2.3.1: + resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} + + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + engines: {node: ^10 || ^12 || >=14} + + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + process-warning@5.1.0: + resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==} + + promise-toolbox@0.21.0: + resolution: {integrity: sha512-NV8aTmpwrZv+Iys54sSFOBx3tuVaOBvvrft5PNppnxy9xpU/akHbaWIril22AB22zaPgrgwKdD0KsrM0ptUtpg==} + engines: {node: '>=6'} + + prompts@2.4.2: + resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} + engines: {node: '>= 6'} + + proto-list@1.2.4: + resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} + + publish-browser-extension@3.0.3: + resolution: {integrity: sha512-cBINZCkLo7YQaGoUvEHthZ0sDzgJQht28IS+SFMT2omSNhGsPiVNRkWir3qLiTrhGhW9Ci2KVHpA1QAMoBdL2g==} + hasBin: true + + pupa@3.3.0: + resolution: {integrity: sha512-LjgDO2zPtoXP2wJpDjZrGdojii1uqO0cnwKoIoUzkfS98HDmbeiGmYiXo3lXeFlq2xvne1QFQhwYXSUCLKtEuA==} + engines: {node: '>=12.20'} + + quansync@0.2.11: + resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + + rc9@3.0.1: + resolution: {integrity: sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==} + + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + + react-dom@18.3.1: + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + peerDependencies: + react: ^18.3.1 + + react-is@19.2.8: + resolution: {integrity: sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==} + + react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + engines: {node: '>=0.10.0'} + + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + readdirp@5.1.1: + resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==} + engines: {node: '>= 20.19.0'} + + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + registry-auth-token@5.1.1: + resolution: {integrity: sha512-P7B4+jq8DeD2nMsAcdfaqHbssgHtZ7Z5+++a5ask90fvmJ8p5je4mOa+wzu+DB4vQ5tdJV/xywY+UnVFeQLV5Q==} + engines: {node: '>=14'} + + registry-url@6.0.1: + resolution: {integrity: sha512-+crtS5QjFRqFCoQmvGduwYWEBng99ZvmFvF+cUJkGYF1L1BfU8C6Zp9T7f5vPAwyLkUExpvK+ANVZmGU49qi4Q==} + engines: {node: '>=12'} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + rollup@4.62.4: + resolution: {integrity: sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + sax@1.6.1: + resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==} + engines: {node: '>=11.0.0'} + + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + + scroll-into-view-if-needed@3.1.0: + resolution: {integrity: sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==} + + scule@1.3.0: + resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + set-value@4.1.0: + resolution: {integrity: sha512-zTEg4HL0RwVrqcWs3ztF+x1vkxfm0lP+MQQFPiMJTKVceBwEV0A569Ou8l9IYQG8jOZdMVI1hGsc0tmeD2o/Lw==} + engines: {node: '>=11.0'} + + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + shell-quote@1.7.3: + resolution: {integrity: sha512-Vpfqwm4EnqGdlsBFNmHhxhElJYrdfcxPThu+ryKS5J8L/fhAwLazFZtq+S+TWZ9ANj2piSQLGj6NQg+lKPmxrw==} + + shellwords@0.1.1: + resolution: {integrity: sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + slice-ansi@5.0.0: + resolution: {integrity: sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==} + engines: {node: '>=12'} + + slice-ansi@7.1.2: + resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} + engines: {node: '>=18'} + + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + spawn-sync@1.0.15: + resolution: {integrity: sha512-9DWBgrgYZzNghseho0JOuh+5fg9u6QWhAWa51QC7+U5rCheZ/j1DrEZnyE0RBBRqZ9uEXGPgSSM0nky6burpVw==} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + split@1.0.1: + resolution: {integrity: sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg==} + + stdin-discarder@0.2.2: + resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} + engines: {node: '>=18'} + + string-convert@0.2.1: + resolution: {integrity: sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-bom@5.0.0: + resolution: {integrity: sha512-p+byADHF7SzEcVnLvc/r3uognM1hUhObuHXxJcgLCfD194XAkaLbjq3Wzb0N5G2tgIjH0dgT708Z51QxMeu60A==} + engines: {node: '>=12'} + + strip-final-newline@3.0.0: + resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} + engines: {node: '>=12'} + + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + + strip-json-comments@5.0.2: + resolution: {integrity: sha512-4X2FR3UwhNUE9G49aIsJW5hRRR3GXGTBTZRMfv568O60ojM8HcWjV/VxAxCDW3SUND33O6ZY66ZuRcdkj73q2g==} + engines: {node: '>=14.16'} + + strip-literal@2.1.1: + resolution: {integrity: sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==} + + stubborn-fs@2.0.0: + resolution: {integrity: sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA==} + + stubborn-utils@1.0.2: + resolution: {integrity: sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==} + + stylis@4.4.0: + resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==} + + superlock@1.3.5: + resolution: {integrity: sha512-XpWNthvezZnWp0u7/UL8rBbBOnq2Qx39fw+0RNMC/+eotOd81glzsmIWVH0ejarhTeDQihxOKSbjm2XXFo5a8w==} + engines: {node: '>= 14'} + + tar@6.2.1: + resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} + engines: {node: '>=10'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + thread-stream@3.2.0: + resolution: {integrity: sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw==} + + throttle-debounce@5.0.2: + resolution: {integrity: sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==} + engines: {node: '>=12.22'} + + through@2.3.8: + resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tmp@0.2.5: + resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==} + engines: {node: '>=14.14'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-fest@3.13.1: + resolution: {integrity: sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==} + engines: {node: '>=14.16'} + + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + + typedarray@0.0.6: + resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + uhyphen@0.2.0: + resolution: {integrity: sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==} + + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + + unimport@3.14.6: + resolution: {integrity: sha512-CYvbDaTT04Rh8bmD8jz3WPmHYZRG/NnvYVzwD6V1YAlvvKROlAeNDUBhkBGzNav2RKaeuXvlWYaa1V4Lfi/O0g==} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + unplugin@1.16.1: + resolution: {integrity: sha512-4/u/j4FrCKdi17jaxuJA0jClGxB1AvU2hw/IuayPc4ay1XGaJs/rbb4v5WKwAjNifjmXK9PIFyuPiaK8azyR9w==} + engines: {node: '>=14.0.0'} + + update-notifier@7.3.1: + resolution: {integrity: sha512-+dwUY4L35XFYEzE+OAL3sarJdUioVovq+8f7lcIJ7wnmnYQV5UD1Y/lcwaMSyaQ6Bj3JMj1XSTjZbNLHn/19yA==} + engines: {node: '>=18'} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@6.4.3: + resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + watchpack@2.4.4: + resolution: {integrity: sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==} + engines: {node: '>=10.13.0'} + + web-ext-run@0.2.4: + resolution: {integrity: sha512-rQicL7OwuqWdQWI33JkSXKcp7cuv1mJG8u3jRQwx/8aDsmhbTHs9ZRmNYOL+LX0wX8edIEQX8jj4bB60GoXtKA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + + webextension-polyfill@0.12.0: + resolution: {integrity: sha512-97TBmpoWJEE+3nFBQ4VocyCdLKfw54rFaJ6EVQYLBCXqCIpLSZkwGgASpv4oPt9gdKCJ80RJlcmNzNn008Ag6Q==} + + webpack-virtual-modules@0.6.2: + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + + when-exit@2.1.5: + resolution: {integrity: sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg==} + + when@3.7.7: + resolution: {integrity: sha512-9lFZp/KHoqH6bPKjbWqa+3Dg/K/r2v0X/3/G2x4DBGchVS2QX2VXL3cZV994WQVnTM1/PD71Az25nAzryEUugw==} + + which@1.2.4: + resolution: {integrity: sha512-zDRAqDSBudazdfM9zpiI30Fu9ve47htYXcGi3ln0wfKu2a7SmrT6F3VDoYONu//48V8Vz4TdCRNPjtvyRO3yBA==} + hasBin: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + widest-line@5.0.0: + resolution: {integrity: sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==} + engines: {node: '>=18'} + + winreg@0.0.12: + resolution: {integrity: sha512-typ/+JRmi7RqP1NanzFULK36vczznSNN8kWVA9vIqXyv8GhghUlwhGp1Xj3Nms1FsPcNnsQrJOR10N58/nQ9hQ==} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} + engines: {node: '>=18'} + + wsl-utils@0.1.0: + resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} + engines: {node: '>=18'} + + wxt@0.19.29: + resolution: {integrity: sha512-n6DRR34OAFczJfZOwJeY5dn+j+w2BTquW2nAX32vk3FMLWUhzpv5svMvSUTyNiFq3P0o3U7YxfxHdmKJnXZHBA==} + hasBin: true + + xdg-basedir@5.1.0: + resolution: {integrity: sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==} + engines: {node: '>=12'} + + xml2js@0.6.2: + resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==} + engines: {node: '>=4.0.0'} + + xmlbuilder@11.0.1: + resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} + engines: {node: '>=4.0'} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} + engines: {node: '>=12'} + + zip-dir@2.0.0: + resolution: {integrity: sha512-uhlsJZWz26FLYXOD6WVuq+fIcZ3aBPGo/cFdiLlv3KNwpa52IF3ISV8fLhQLiqVu5No3VhlqlgthN6gehil1Dg==} + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + +snapshots: + + '@1natsu/wait-element@4.2.0': + dependencies: + defu: 6.1.7 + many-keys-map: 3.0.3 + + '@aklinker1/rollup-plugin-visualizer@5.12.0(rollup@4.62.4)': + dependencies: + open: 8.4.2 + picomatch: 2.3.2 + source-map: 0.7.6 + yargs: 17.7.3 + optionalDependencies: + rollup: 4.62.4 + + '@ant-design/colors@8.0.1': + dependencies: + '@ant-design/fast-color': 3.0.1 + + '@ant-design/cssinjs-utils@2.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@ant-design/cssinjs': 2.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@babel/runtime': 7.29.7 + '@rc-component/util': 1.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@ant-design/cssinjs@2.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.7 + '@emotion/hash': 0.8.0 + '@emotion/unitless': 0.7.5 + '@rc-component/util': 1.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + csstype: 3.2.3 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + stylis: 4.4.0 + + '@ant-design/fast-color@3.0.1': {} + + '@ant-design/icons-svg@4.5.0': {} + + '@ant-design/icons@6.3.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@ant-design/colors': 8.0.1 + '@ant-design/icons-svg': 4.5.0 + '@rc-component/util': 1.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@ant-design/react-slick@2.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.7 + clsx: 2.1.1 + json2mq: 0.2.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + throttle-debounce: 5.0.2 + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/runtime@7.28.2': {} + + '@babel/runtime@7.29.7': {} + + '@babel/runtime@8.0.0': {} + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@devicefarmer/adbkit-logcat@2.1.3': {} + + '@devicefarmer/adbkit-monkey@1.2.1': {} + + '@devicefarmer/adbkit@3.3.8': + dependencies: + '@devicefarmer/adbkit-logcat': 2.1.3 + '@devicefarmer/adbkit-monkey': 1.2.1 + bluebird: 3.7.2 + commander: 9.5.0 + debug: 4.3.7 + node-forge: 1.4.0 + split: 1.0.1 + transitivePeerDependencies: + - supports-color + + '@emotion/hash@0.8.0': {} + + '@emotion/unitless@0.7.5': {} + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@pnpm/config.env-replace@1.1.0': {} + + '@pnpm/network.ca-file@1.0.2': + dependencies: + graceful-fs: 4.2.10 + + '@pnpm/npm-conf@3.0.3': + dependencies: + '@pnpm/config.env-replace': 1.1.0 + '@pnpm/network.ca-file': 1.0.2 + config-chain: 1.1.13 + + '@rc-component/async-validator@6.0.0': + dependencies: + '@babel/runtime': 7.29.7 + + '@rc-component/cascader@1.22.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/select': 1.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/tree': 1.4.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/util': 1.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/checkbox@2.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/util': 1.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/collapse@1.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.7 + '@rc-component/motion': 1.3.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/util': 1.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/color-picker@3.1.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@ant-design/fast-color': 3.0.1 + '@rc-component/util': 1.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/context@2.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/util': 1.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/dialog@1.10.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/motion': 1.3.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/portal': 2.2.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/util': 1.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/drawer@1.4.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/motion': 1.3.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/portal': 2.2.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/util': 1.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/dropdown@1.0.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/trigger': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/util': 1.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/form@1.8.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/async-validator': 6.0.0 + '@rc-component/util': 1.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/image@1.10.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/motion': 1.3.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/portal': 2.2.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/util': 1.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/input-number@1.6.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/mini-decimal': 1.1.4 + '@rc-component/util': 1.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/input@1.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/resize-observer': 1.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/util': 1.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/listy@1.2.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/motion': 1.3.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/portal': 2.2.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/resize-observer': 1.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/util': 1.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/virtual-list': 1.5.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/mentions@1.11.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/input': 1.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/menu': 1.4.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/trigger': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/util': 1.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/menu@1.4.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/motion': 1.3.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/overflow': 1.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/trigger': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/util': 1.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/mini-decimal@1.1.4': + dependencies: + '@babel/runtime': 7.29.7 + + '@rc-component/motion@1.3.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/util': 1.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/mutate-observer@2.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/util': 1.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/notification@2.0.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/motion': 1.3.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/util': 1.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/overflow@1.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.7 + '@rc-component/resize-observer': 1.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/util': 1.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/pagination@1.4.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/util': 1.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/picker@1.12.0(dayjs@1.11.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/overflow': 1.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/resize-observer': 1.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/trigger': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/util': 1.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + dayjs: 1.11.21 + + '@rc-component/portal@2.2.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/util': 1.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/progress@1.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/util': 1.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/qrcode@2.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.7 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/rate@1.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/util': 1.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/resize-observer@1.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/util': 1.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/segmented@1.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.7 + '@rc-component/motion': 1.3.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/util': 1.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/select@1.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/overflow': 1.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/trigger': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/util': 1.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/virtual-list': 1.5.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/slider@1.1.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/util': 1.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/steps@1.2.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/util': 1.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/switch@1.0.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/util': 1.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/table@1.11.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/context': 2.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/resize-observer': 1.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/util': 1.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/virtual-list': 1.5.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/tabs@1.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/dropdown': 1.0.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/menu': 1.4.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/motion': 1.3.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/resize-observer': 1.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/util': 1.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/tooltip@1.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/trigger': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/util': 1.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/tour@2.4.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/portal': 2.2.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/trigger': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/util': 1.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/tree-select@1.16.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/select': 1.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/tree': 1.4.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/util': 1.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/tree@1.4.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/motion': 1.3.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/util': 1.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/virtual-list': 1.5.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/trigger@3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/motion': 1.3.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/portal': 2.2.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/resize-observer': 1.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/util': 1.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/upload@1.1.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/util': 1.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/util@1.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + is-mobile: 5.0.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-is: 19.2.8 + + '@rc-component/virtual-list@1.5.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 8.0.0 + '@rc-component/resize-observer': 1.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/util': 1.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rollup/pluginutils@5.4.0(rollup@4.62.4)': + dependencies: + '@types/estree': 1.0.9 + estree-walker: 2.0.2 + picomatch: 4.0.5 + optionalDependencies: + rollup: 4.62.4 + + '@rollup/rollup-android-arm-eabi@4.62.4': + optional: true + + '@rollup/rollup-android-arm64@4.62.4': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.4': + optional: true + + '@rollup/rollup-darwin-x64@4.62.4': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.4': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.4': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.4': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.4': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.4': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.4': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.4': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.4': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.4': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.4': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.4': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.4': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.4': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.4': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.4': + optional: true + + '@types/chrome@0.0.268': + dependencies: + '@types/filesystem': 0.0.36 + '@types/har-format': 1.2.16 + + '@types/chrome@0.0.280': + dependencies: + '@types/filesystem': 0.0.36 + '@types/har-format': 1.2.16 + + '@types/estree@1.0.9': {} + + '@types/filesystem@0.0.36': + dependencies: + '@types/filewriter': 0.0.33 + + '@types/filewriter@0.0.33': {} + + '@types/har-format@1.2.16': {} + + '@types/minimatch@3.0.5': {} + + '@types/node@26.2.0': + dependencies: + undici-types: 8.3.0 + + '@types/prop-types@15.7.15': {} + + '@types/react-dom@18.3.7(@types/react@18.3.31)': + dependencies: + '@types/react': 18.3.31 + + '@types/react@18.3.31': + dependencies: + '@types/prop-types': 15.7.15 + csstype: 3.2.3 + + '@types/webextension-polyfill@0.12.5': {} + + '@types/wicg-file-system-access@2023.10.7': {} + + '@webext-core/fake-browser@1.5.2': + dependencies: + '@types/webextension-polyfill': 0.12.5 + lodash.merge: 4.6.2 + + '@webext-core/isolated-element@1.1.5': + dependencies: + is-potential-custom-element-name: 1.0.1 + + '@webext-core/match-patterns@1.1.0': {} + + '@wxt-dev/browser@0.2.6': + dependencies: + '@types/filesystem': 0.0.36 + '@types/har-format': 1.2.16 + + '@wxt-dev/storage@1.2.9': + dependencies: + '@wxt-dev/browser': 0.2.6 + superlock: 1.3.5 + + acorn@8.18.0: {} + + adm-zip@0.5.18: {} + + ansi-align@3.0.1: + dependencies: + string-width: 4.2.3 + + ansi-escapes@7.3.0: + dependencies: + environment: 1.1.0 + + ansi-regex@5.0.1: {} + + ansi-regex@6.3.0: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.3: {} + + antd@6.6.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@ant-design/colors': 8.0.1 + '@ant-design/cssinjs': 2.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@ant-design/cssinjs-utils': 2.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@ant-design/fast-color': 3.0.1 + '@ant-design/icons': 6.3.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@ant-design/react-slick': 2.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@babel/runtime': 7.29.7 + '@rc-component/cascader': 1.22.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/checkbox': 2.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/collapse': 1.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/color-picker': 3.1.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/dialog': 1.10.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/drawer': 1.4.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/dropdown': 1.0.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/form': 1.8.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/image': 1.10.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/input': 1.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/input-number': 1.6.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/listy': 1.2.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/mentions': 1.11.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/menu': 1.4.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/motion': 1.3.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/mutate-observer': 2.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/notification': 2.0.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/pagination': 1.4.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/picker': 1.12.0(dayjs@1.11.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/progress': 1.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/qrcode': 2.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/rate': 1.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/resize-observer': 1.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/segmented': 1.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/select': 1.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/slider': 1.1.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/steps': 1.2.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/switch': 1.0.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/table': 1.11.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/tabs': 1.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/tooltip': 1.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/tour': 2.4.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/tree': 1.4.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/tree-select': 1.16.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/trigger': 3.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/upload': 1.1.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/util': 1.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + dayjs: 1.11.21 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + scroll-into-view-if-needed: 3.1.0 + throttle-debounce: 5.0.2 + transitivePeerDependencies: + - date-fns + - luxon + - moment + + array-differ@4.0.0: {} + + array-union@3.0.1: {} + + async-mutex@0.5.0: + dependencies: + tslib: 2.8.1 + + async@3.2.6: {} + + atomic-sleep@1.0.0: {} + + atomically@2.1.1: + dependencies: + stubborn-fs: 2.0.0 + when-exit: 2.1.5 + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + bluebird@3.7.2: {} + + boolbase@2.0.0: {} + + boxen@8.0.1: + dependencies: + ansi-align: 3.0.1 + camelcase: 8.0.0 + chalk: 5.6.2 + cli-boxes: 3.0.0 + string-width: 7.2.0 + type-fest: 4.41.0 + widest-line: 5.0.0 + wrap-ansi: 9.0.2 + + brace-expansion@1.1.18: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@5.0.9: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + buffer-from@1.1.2: {} + + bundle-name@4.1.0: + dependencies: + run-applescript: 7.1.0 + + c12@3.3.4(magicast@0.3.5): + dependencies: + chokidar: 5.0.0 + confbox: 0.2.4 + defu: 6.1.7 + dotenv: 17.4.2 + exsolve: 1.1.1 + giget: 3.3.1 + jiti: 2.7.0 + ohash: 2.0.12 + pathe: 2.0.3 + perfect-debounce: 2.1.0 + pkg-types: 2.3.1 + rc9: 3.0.1 + optionalDependencies: + magicast: 0.3.5 + + cac@6.7.14: {} + + camelcase@8.0.0: {} + + chalk@5.6.2: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + chokidar@5.0.0: + dependencies: + readdirp: 5.1.1 + + chownr@2.0.0: {} + + chrome-launcher@1.2.0: + dependencies: + '@types/node': 26.2.0 + escape-string-regexp: 4.0.0 + is-wsl: 2.2.0 + lighthouse-logger: 2.0.2 + transitivePeerDependencies: + - supports-color + + ci-info@4.4.0: {} + + citty@0.1.6: + dependencies: + consola: 3.4.2 + + cli-boxes@3.0.0: {} + + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-spinners@2.9.2: {} + + cli-truncate@4.0.0: + dependencies: + slice-ansi: 5.0.0 + string-width: 7.2.0 + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clsx@2.1.1: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + colorette@2.0.20: {} + + commander@2.9.0: + dependencies: + graceful-readlink: 1.0.1 + + commander@9.5.0: {} + + compute-scroll-into-view@3.1.1: {} + + concat-map@0.0.1: {} + + concat-stream@1.6.2: + dependencies: + buffer-from: 1.1.2 + inherits: 2.0.4 + readable-stream: 2.3.8 + typedarray: 0.0.6 + + confbox@0.1.8: {} + + confbox@0.2.4: {} + + config-chain@1.1.13: + dependencies: + ini: 1.3.8 + proto-list: 1.2.4 + + configstore@7.1.0: + dependencies: + atomically: 2.1.1 + dot-prop: 9.0.0 + graceful-fs: 4.2.11 + xdg-basedir: 5.1.0 + + consola@3.4.2: {} + + core-util-is@1.0.3: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + css-select@7.0.0: + dependencies: + boolbase: 2.0.0 + css-what: 8.0.0 + domhandler: 6.0.1 + domutils: 4.0.2 + nth-check: 3.0.1 + + css-what@8.0.0: {} + + cssom@0.5.0: {} + + csstype@3.2.3: {} + + dayjs@1.11.21: {} + + debounce@1.2.1: {} + + debug@4.3.7: + dependencies: + ms: 2.1.3 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-extend@0.6.0: {} + + default-browser-id@5.0.1: {} + + default-browser@5.5.0: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.1 + + define-lazy-prop@2.0.0: {} + + define-lazy-prop@3.0.0: {} + + defu@6.1.7: {} + + destr@2.0.5: {} + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + dom-serializer@3.1.1: + dependencies: + domelementtype: 3.0.0 + domhandler: 6.0.1 + entities: 8.0.0 + + domelementtype@2.3.0: {} + + domelementtype@3.0.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domhandler@6.0.1: + dependencies: + domelementtype: 3.0.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + domutils@4.0.2: + dependencies: + dom-serializer: 3.1.1 + domelementtype: 3.0.0 + domhandler: 6.0.1 + + dot-prop@9.0.0: + dependencies: + type-fest: 4.41.0 + + dotenv-expand@12.0.3: + dependencies: + dotenv: 16.6.1 + + dotenv@16.6.1: {} + + dotenv@17.4.2: {} + + emoji-regex@10.6.0: {} + + emoji-regex@8.0.0: {} + + entities@4.5.0: {} + + entities@7.0.1: {} + + entities@8.0.0: {} + + environment@1.1.0: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + es-module-lexer@1.7.0: {} + + es6-error@4.1.1: {} + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + escalade@3.2.0: {} + + escape-goat@4.0.0: {} + + escape-string-regexp@4.0.0: {} + + escape-string-regexp@5.0.0: {} + + estree-walker@2.0.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + eventemitter3@5.0.4: {} + + execa@8.0.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 8.0.1 + human-signals: 5.0.0 + is-stream: 3.0.0 + merge-stream: 2.0.0 + npm-run-path: 5.3.0 + onetime: 6.0.0 + signal-exit: 4.1.0 + strip-final-newline: 3.0.0 + + exsolve@1.1.1: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-redact@3.5.0: {} + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + filesize@10.1.6: {} + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + firefox-profile@4.7.0: + dependencies: + adm-zip: 0.5.18 + fs-extra: 11.4.0 + ini: 4.1.3 + minimist: 1.2.8 + xml2js: 0.6.2 + + form-data-encoder@4.1.0: {} + + formdata-node@6.0.3: {} + + fs-extra@11.4.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + + fs-minipass@2.1.0: + dependencies: + minipass: 3.3.6 + + fsevents@2.3.3: + optional: true + + fx-runner@1.4.0: + dependencies: + commander: 2.9.0 + shell-quote: 1.7.3 + spawn-sync: 1.0.15 + when: 3.7.7 + which: 1.2.4 + winreg: 0.0.12 + + get-caller-file@2.0.5: {} + + get-east-asian-width@1.6.0: {} + + get-port-please@3.2.0: {} + + get-stream@8.0.1: {} + + giget@1.2.5: + dependencies: + citty: 0.1.6 + consola: 3.4.2 + defu: 6.1.7 + node-fetch-native: 1.6.7 + nypm: 0.5.4 + pathe: 2.0.3 + tar: 6.2.1 + + giget@3.3.1: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-to-regexp@0.4.1: {} + + global-directory@4.0.1: + dependencies: + ini: 4.1.1 + + graceful-fs@4.2.10: {} + + graceful-fs@4.2.11: {} + + graceful-readlink@1.0.1: {} + + growly@1.3.0: {} + + hookable@5.5.3: {} + + html-escaper@3.0.3: {} + + htmlparser2@10.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 7.0.1 + + human-signals@5.0.0: {} + + immediate@3.0.6: {} + + import-meta-resolve@4.2.0: {} + + inherits@2.0.4: {} + + ini@1.3.8: {} + + ini@4.1.1: {} + + ini@4.1.3: {} + + is-absolute@0.1.7: + dependencies: + is-relative: 0.1.3 + + is-arrayish@0.2.1: {} + + is-docker@2.2.1: {} + + is-docker@3.0.0: {} + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-fullwidth-code-point@4.0.0: {} + + is-fullwidth-code-point@5.1.0: + dependencies: + get-east-asian-width: 1.6.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-in-ci@1.0.0: {} + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + + is-installed-globally@1.0.0: + dependencies: + global-directory: 4.0.1 + is-path-inside: 4.0.0 + + is-interactive@2.0.0: {} + + is-mobile@5.0.0: {} + + is-npm@6.1.0: {} + + is-number@7.0.0: {} + + is-path-inside@4.0.0: {} + + is-plain-object@2.0.4: + dependencies: + isobject: 3.0.1 + + is-potential-custom-element-name@1.0.1: {} + + is-primitive@3.0.1: {} + + is-relative@0.1.3: {} + + is-stream@3.0.0: {} + + is-unicode-supported@1.3.0: {} + + is-unicode-supported@2.1.0: {} + + is-wsl@2.2.0: + dependencies: + is-docker: 2.2.1 + + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + + isarray@1.0.0: {} + + isexe@1.1.2: {} + + isexe@2.0.0: {} + + isobject@3.0.1: {} + + jiti@2.7.0: {} + + js-tokens@4.0.0: {} + + js-tokens@9.0.1: {} + + json-parse-even-better-errors@3.0.2: {} + + json2mq@0.2.0: + dependencies: + string-convert: 0.2.1 + + json5@2.2.3: {} + + jsonfile@6.2.1: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + jszip@3.10.1: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + + kleur@3.0.3: {} + + ky@1.14.3: {} + + latest-version@9.0.0: + dependencies: + package-json: 10.0.1 + + lie@3.3.0: + dependencies: + immediate: 3.0.6 + + lighthouse-logger@2.0.2: + dependencies: + debug: 4.4.3 + marky: 1.3.0 + transitivePeerDependencies: + - supports-color + + lines-and-columns@2.0.4: {} + + linkedom@0.18.13: + dependencies: + css-select: 7.0.0 + cssom: 0.5.0 + html-escaper: 3.0.3 + htmlparser2: 10.1.0 + uhyphen: 0.2.0 + + listr2@8.3.3: + dependencies: + cli-truncate: 4.0.0 + colorette: 2.0.20 + eventemitter3: 5.0.4 + log-update: 6.1.0 + rfdc: 1.4.1 + wrap-ansi: 9.0.2 + + local-pkg@1.2.1: + dependencies: + mlly: 1.8.2 + pkg-types: 2.3.1 + quansync: 0.2.11 + + lodash.merge@4.6.2: {} + + log-symbols@6.0.0: + dependencies: + chalk: 5.6.2 + is-unicode-supported: 1.3.0 + + log-update@6.1.0: + dependencies: + ansi-escapes: 7.3.0 + cli-cursor: 5.0.0 + slice-ansi: 7.1.2 + strip-ansi: 7.2.0 + wrap-ansi: 9.0.2 + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magicast@0.3.5: + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + source-map-js: 1.2.1 + + make-error@1.3.6: {} + + many-keys-map@3.0.3: {} + + marky@1.3.0: {} + + merge-stream@2.0.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mimic-fn@4.0.0: {} + + mimic-function@5.0.1: {} + + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.9 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.18 + + minimist@1.2.8: {} + + minipass@3.3.6: + dependencies: + yallist: 4.0.0 + + minipass@5.0.0: {} + + minizlib@2.1.2: + dependencies: + minipass: 3.3.6 + yallist: 4.0.0 + + mkdirp@1.0.4: {} + + mlly@1.8.2: + dependencies: + acorn: 8.18.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.4 + + ms@2.1.3: {} + + multimatch@6.0.0: + dependencies: + '@types/minimatch': 3.0.5 + array-differ: 4.0.0 + array-union: 3.0.1 + minimatch: 3.1.5 + + nano-spawn@0.2.1: {} + + nanoid@3.3.18: {} + + node-fetch-native@1.6.7: {} + + node-forge@1.4.0: {} + + node-notifier@10.0.1: + dependencies: + growly: 1.3.0 + is-wsl: 2.2.0 + semver: 7.8.5 + shellwords: 0.1.1 + uuid: 8.3.2 + which: 2.0.2 + + normalize-path@3.0.0: {} + + npm-run-path@5.3.0: + dependencies: + path-key: 4.0.0 + + nth-check@3.0.1: + dependencies: + boolbase: 2.0.0 + + nypm@0.3.12: + dependencies: + citty: 0.1.6 + consola: 3.4.2 + execa: 8.0.1 + pathe: 1.1.2 + pkg-types: 1.3.1 + ufo: 1.6.4 + + nypm@0.5.4: + dependencies: + citty: 0.1.6 + consola: 3.4.2 + pathe: 2.0.3 + pkg-types: 1.3.1 + tinyexec: 0.3.2 + ufo: 1.6.4 + + ofetch@1.5.1: + dependencies: + destr: 2.0.5 + node-fetch-native: 1.6.7 + ufo: 1.6.4 + + ohash@1.1.6: {} + + ohash@2.0.12: {} + + on-exit-leak-free@2.1.2: {} + + onetime@6.0.0: + dependencies: + mimic-fn: 4.0.0 + + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + + open@10.2.0: + dependencies: + default-browser: 5.5.0 + define-lazy-prop: 3.0.0 + is-inside-container: 1.0.0 + wsl-utils: 0.1.0 + + open@8.4.2: + dependencies: + define-lazy-prop: 2.0.0 + is-docker: 2.2.1 + is-wsl: 2.2.0 + + ora@8.2.0: + dependencies: + chalk: 5.6.2 + cli-cursor: 5.0.0 + cli-spinners: 2.9.2 + is-interactive: 2.0.0 + is-unicode-supported: 2.1.0 + log-symbols: 6.0.0 + stdin-discarder: 0.2.2 + string-width: 7.2.0 + strip-ansi: 7.2.0 + + os-shim@0.1.3: {} + + package-json@10.0.1: + dependencies: + ky: 1.14.3 + registry-auth-token: 5.1.1 + registry-url: 6.0.1 + semver: 7.8.5 + + pako@1.0.11: {} + + parse-json@7.1.1: + dependencies: + '@babel/code-frame': 7.29.7 + error-ex: 1.3.4 + json-parse-even-better-errors: 3.0.2 + lines-and-columns: 2.0.4 + type-fest: 3.13.1 + + path-key@3.1.1: {} + + path-key@4.0.0: {} + + pathe@1.1.2: {} + + pathe@2.0.3: {} + + perfect-debounce@1.0.0: {} + + perfect-debounce@2.1.0: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.5: {} + + pino-abstract-transport@2.0.0: + dependencies: + split2: 4.2.0 + + pino-std-serializers@7.1.0: {} + + pino@9.7.0: + dependencies: + atomic-sleep: 1.0.0 + fast-redact: 3.5.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 2.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.1.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 3.2.0 + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + pkg-types@2.3.1: + dependencies: + confbox: 0.2.4 + exsolve: 1.1.1 + pathe: 2.0.3 + + postcss@8.5.26: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + process-nextick-args@2.0.1: {} + + process-warning@5.1.0: {} + + promise-toolbox@0.21.0: + dependencies: + make-error: 1.3.6 + + prompts@2.4.2: + dependencies: + kleur: 3.0.3 + sisteransi: 1.0.5 + + proto-list@1.2.4: {} + + publish-browser-extension@3.0.3: + dependencies: + cac: 6.7.14 + consola: 3.4.2 + dotenv: 17.4.2 + form-data-encoder: 4.1.0 + formdata-node: 6.0.3 + listr2: 8.3.3 + ofetch: 1.5.1 + zod: 4.4.3 + + pupa@3.3.0: + dependencies: + escape-goat: 4.0.0 + + quansync@0.2.11: {} + + queue-microtask@1.2.3: {} + + quick-format-unescaped@4.0.4: {} + + rc9@3.0.1: + dependencies: + defu: 6.1.7 + destr: 2.0.5 + + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + + react-dom@18.3.1(react@18.3.1): + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + + react-is@19.2.8: {} + + react@18.3.1: + dependencies: + loose-envify: 1.4.0 + + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + readdirp@4.1.2: {} + + readdirp@5.1.1: {} + + real-require@0.2.0: {} + + registry-auth-token@5.1.1: + dependencies: + '@pnpm/npm-conf': 3.0.3 + + registry-url@6.0.1: + dependencies: + rc: 1.2.8 + + require-directory@2.1.1: {} + + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + + reusify@1.1.0: {} + + rfdc@1.4.1: {} + + rollup@4.62.4: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.62.4 + '@rollup/rollup-android-arm64': 4.62.4 + '@rollup/rollup-darwin-arm64': 4.62.4 + '@rollup/rollup-darwin-x64': 4.62.4 + '@rollup/rollup-freebsd-arm64': 4.62.4 + '@rollup/rollup-freebsd-x64': 4.62.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.4 + '@rollup/rollup-linux-arm-musleabihf': 4.62.4 + '@rollup/rollup-linux-arm64-gnu': 4.62.4 + '@rollup/rollup-linux-arm64-musl': 4.62.4 + '@rollup/rollup-linux-loong64-gnu': 4.62.4 + '@rollup/rollup-linux-loong64-musl': 4.62.4 + '@rollup/rollup-linux-ppc64-gnu': 4.62.4 + '@rollup/rollup-linux-ppc64-musl': 4.62.4 + '@rollup/rollup-linux-riscv64-gnu': 4.62.4 + '@rollup/rollup-linux-riscv64-musl': 4.62.4 + '@rollup/rollup-linux-s390x-gnu': 4.62.4 + '@rollup/rollup-linux-x64-gnu': 4.62.4 + '@rollup/rollup-linux-x64-musl': 4.62.4 + '@rollup/rollup-openbsd-x64': 4.62.4 + '@rollup/rollup-openharmony-arm64': 4.62.4 + '@rollup/rollup-win32-arm64-msvc': 4.62.4 + '@rollup/rollup-win32-ia32-msvc': 4.62.4 + '@rollup/rollup-win32-x64-gnu': 4.62.4 + '@rollup/rollup-win32-x64-msvc': 4.62.4 + fsevents: 2.3.3 + + run-applescript@7.1.0: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + safe-buffer@5.1.2: {} + + safe-stable-stringify@2.5.0: {} + + sax@1.6.1: {} + + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 + + scroll-into-view-if-needed@3.1.0: + dependencies: + compute-scroll-into-view: 3.1.1 + + scule@1.3.0: {} + + semver@7.8.5: {} + + set-value@4.1.0: + dependencies: + is-plain-object: 2.0.4 + is-primitive: 3.0.1 + + setimmediate@1.0.5: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + shell-quote@1.7.3: {} + + shellwords@0.1.1: {} + + signal-exit@4.1.0: {} + + sisteransi@1.0.5: {} + + slice-ansi@5.0.0: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 4.0.0 + + slice-ansi@7.1.2: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + + source-map-js@1.2.1: {} + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + source-map@0.7.6: {} + + spawn-sync@1.0.15: + dependencies: + concat-stream: 1.6.2 + os-shim: 0.1.3 + + split2@4.2.0: {} + + split@1.0.1: + dependencies: + through: 2.3.8 + + stdin-discarder@0.2.2: {} + + string-convert@0.2.1: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.3.0 + + strip-bom@5.0.0: {} + + strip-final-newline@3.0.0: {} + + strip-json-comments@2.0.1: {} + + strip-json-comments@5.0.2: {} + + strip-literal@2.1.1: + dependencies: + js-tokens: 9.0.1 + + stubborn-fs@2.0.0: + dependencies: + stubborn-utils: 1.0.2 + + stubborn-utils@1.0.2: {} + + stylis@4.4.0: {} + + superlock@1.3.5: {} + + tar@6.2.1: + dependencies: + chownr: 2.0.0 + fs-minipass: 2.1.0 + minipass: 5.0.0 + minizlib: 2.1.2 + mkdirp: 1.0.4 + yallist: 4.0.0 + + thread-stream@3.2.0: + dependencies: + real-require: 0.2.0 + + throttle-debounce@5.0.2: {} + + through@2.3.8: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tmp@0.2.5: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + tslib@2.8.1: {} + + type-fest@3.13.1: {} + + type-fest@4.41.0: {} + + typedarray@0.0.6: {} + + typescript@5.9.3: {} + + ufo@1.6.4: {} + + uhyphen@0.2.0: {} + + undici-types@8.3.0: {} + + unimport@3.14.6(rollup@4.62.4): + dependencies: + '@rollup/pluginutils': 5.4.0(rollup@4.62.4) + acorn: 8.18.0 + escape-string-regexp: 5.0.0 + estree-walker: 3.0.3 + fast-glob: 3.3.3 + local-pkg: 1.2.1 + magic-string: 0.30.21 + mlly: 1.8.2 + pathe: 2.0.3 + picomatch: 4.0.5 + pkg-types: 1.3.1 + scule: 1.3.0 + strip-literal: 2.1.1 + unplugin: 1.16.1 + transitivePeerDependencies: + - rollup + + universalify@2.0.1: {} + + unplugin@1.16.1: + dependencies: + acorn: 8.18.0 + webpack-virtual-modules: 0.6.2 + + update-notifier@7.3.1: + dependencies: + boxen: 8.0.1 + chalk: 5.6.2 + configstore: 7.1.0 + is-in-ci: 1.0.0 + is-installed-globally: 1.0.0 + is-npm: 6.1.0 + latest-version: 9.0.0 + pupa: 3.3.0 + semver: 7.8.5 + xdg-basedir: 5.1.0 + + util-deprecate@1.0.2: {} + + uuid@8.3.2: {} + + vite-node@3.2.4(@types/node@26.2.0)(jiti@2.7.0): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 6.4.3(@types/node@26.2.0)(jiti@2.7.0) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@6.4.3(@types/node@26.2.0)(jiti@2.7.0): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + postcss: 8.5.26 + rollup: 4.62.4 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.2.0 + fsevents: 2.3.3 + jiti: 2.7.0 + + watchpack@2.4.4: + dependencies: + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + + web-ext-run@0.2.4: + dependencies: + '@babel/runtime': 7.28.2 + '@devicefarmer/adbkit': 3.3.8 + chrome-launcher: 1.2.0 + debounce: 1.2.1 + es6-error: 4.1.1 + firefox-profile: 4.7.0 + fx-runner: 1.4.0 + multimatch: 6.0.0 + node-notifier: 10.0.1 + parse-json: 7.1.1 + pino: 9.7.0 + promise-toolbox: 0.21.0 + set-value: 4.1.0 + source-map-support: 0.5.21 + strip-bom: 5.0.0 + strip-json-comments: 5.0.2 + tmp: 0.2.5 + update-notifier: 7.3.1 + watchpack: 2.4.4 + zip-dir: 2.0.0 + transitivePeerDependencies: + - supports-color + + webextension-polyfill@0.12.0: {} + + webpack-virtual-modules@0.6.2: {} + + when-exit@2.1.5: {} + + when@3.7.7: {} + + which@1.2.4: + dependencies: + is-absolute: 0.1.7 + isexe: 1.1.2 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + widest-line@5.0.0: + dependencies: + string-width: 7.2.0 + + winreg@0.0.12: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.2.0 + + wsl-utils@0.1.0: + dependencies: + is-wsl: 3.1.1 + + wxt@0.19.29(@types/node@26.2.0)(rollup@4.62.4): + dependencies: + '@1natsu/wait-element': 4.2.0 + '@aklinker1/rollup-plugin-visualizer': 5.12.0(rollup@4.62.4) + '@types/chrome': 0.0.280 + '@types/webextension-polyfill': 0.12.5 + '@webext-core/fake-browser': 1.5.2 + '@webext-core/isolated-element': 1.1.5 + '@webext-core/match-patterns': 1.1.0 + '@wxt-dev/storage': 1.2.9 + async-mutex: 0.5.0 + c12: 3.3.4(magicast@0.3.5) + cac: 6.7.14 + chokidar: 4.0.3 + ci-info: 4.4.0 + consola: 3.4.2 + defu: 6.1.7 + dotenv: 16.6.1 + dotenv-expand: 12.0.3 + esbuild: 0.25.12 + fast-glob: 3.3.3 + filesize: 10.1.6 + fs-extra: 11.4.0 + get-port-please: 3.2.0 + giget: 1.2.5 + hookable: 5.5.3 + import-meta-resolve: 4.2.0 + is-wsl: 3.1.1 + jiti: 2.7.0 + json5: 2.2.3 + jszip: 3.10.1 + linkedom: 0.18.13 + magicast: 0.3.5 + minimatch: 10.2.6 + nano-spawn: 0.2.1 + normalize-path: 3.0.0 + nypm: 0.3.12 + ohash: 1.1.6 + open: 10.2.0 + ora: 8.2.0 + perfect-debounce: 1.0.0 + picocolors: 1.1.1 + prompts: 2.4.2 + publish-browser-extension: 3.0.3 + scule: 1.3.0 + unimport: 3.14.6(rollup@4.62.4) + vite: 6.4.3(@types/node@26.2.0)(jiti@2.7.0) + vite-node: 3.2.4(@types/node@26.2.0)(jiti@2.7.0) + web-ext-run: 0.2.4 + webextension-polyfill: 0.12.0 + transitivePeerDependencies: + - '@types/node' + - canvas + - less + - lightningcss + - rollup + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + xdg-basedir@5.1.0: {} + + xml2js@0.6.2: + dependencies: + sax: 1.6.1 + xmlbuilder: 11.0.1 + + xmlbuilder@11.0.1: {} + + y18n@5.0.8: {} + + yallist@4.0.0: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.3: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + zip-dir@2.0.0: + dependencies: + async: 3.2.6 + jszip: 3.10.1 + + zod@4.4.3: {} diff --git a/extension-v2/scripts/verify-pages.ts b/extension-v2/scripts/verify-pages.ts new file mode 100644 index 0000000..07ba1da --- /dev/null +++ b/extension-v2/scripts/verify-pages.ts @@ -0,0 +1,97 @@ +/** + * 用真实保存的 Ozon 页面验证采集逻辑(不依赖浏览器) + * 用法:先 esbuild 打包再 node 运行(见 README / 命令注释) + */ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { extractOzonState } from '../src/collector/ozon-state'; +import { extractJsonLd } from '../src/collector/jsonld'; +import { toOriginalUrl, toThumbUrl, pickBestFromSrcset } from '../src/collector/url'; + +class FakeEl { + id: string; + attrs: Record; + constructor(id: string, attrs: Record) { + this.id = id; + this.attrs = attrs; + } + getAttribute(name: string): string | null { + return this.attrs[name] ?? null; + } + get textContent(): string { + return this.attrs['text'] ?? ''; + } +} + +function buildFakeDoc(html: string) { + const stateEls: FakeEl[] = []; + const re = //gs; + let m: RegExpExecArray | null; + while ((m = re.exec(html))) { + stateEls.push(new FakeEl('state-' + m[1], { 'data-state': m[2] })); + } + const ldEls: FakeEl[] = []; + const re2 = /]*type="application\/ld\+json"[^>]*>(.*?)<\/script>/gs; + while ((m = re2.exec(html))) { + ldEls.push(new FakeEl('', { text: m[1] })); + } + return { + querySelectorAll(sel: string): FakeEl[] { + if (sel === 'div[id^="state-"]') return stateEls; + if (sel === 'script[type="application/ld+json"]') return ldEls; + return []; + }, + }; +} + +function check(name: string, actual: unknown, expected: unknown): void { + const a = JSON.stringify(actual); + const e = JSON.stringify(expected); + const ok = a === e; + console.log(`${ok ? '✅' : '❌'} ${name}`); + if (!ok) { + console.log(' expected:', e); + console.log(' actual :', a); + } +} + +for (const fn of ['../reference/ozon1.html', '../reference/ozon2.html']) { + console.log(`\n========== ${fn} ==========`); + const html = readFileSync(join(process.cwd(), fn), 'utf8'); + (globalThis as any).document = buildFakeDoc(html); + + const state = extractOzonState(); + console.log('state:', JSON.stringify({ + title: state.title, + price: state.price, + originalPrice: state.originalPrice, + rating: state.rating, + reviewCount: state.reviewCount, + gallery: state.galleryImages.length, + videos: state.videos, + variants: state.skuVariants, + characteristics: state.characteristics, + }, null, 1)); + + const ld = extractJsonLd(); + console.log('jsonld:', JSON.stringify(ld)); +} + +// ── URL 工具验证 ── +console.log('\n========== url tools =========='); +const rules = [ + { match: /\/wc\d+\//, replace: '/' }, + { match: /\/c\d+\//, replace: '/' }, + { match: /(? | null }>; + images: Array<{ + groupKey: string; + groupName: string; + variantName?: string | null; + url: string; + index: number; + type: string; + dedupeKey?: string | null; + }>; + refererOrigin?: string; +} + +/** 用(可能已二次修改的)文本 + 已勾选图片,组装 /api/materials 请求体 */ +export function buildMaterialsPayload( + result: ScanResult, + selectedKeys: Set, + edits?: TextEdits, +): MaterialsPayload { + const text = (kind: string) => result.texts.find((t) => t.kind === kind)?.content ?? ''; + + const title = edits?.title ?? text('title'); + const price = edits?.price ?? text('price'); + const brand = edits?.brand ?? text('brand'); + const desc = edits?.desc ?? text('desc'); + const sellingPoints = edits?.sellingPoints ?? text('selling_point'); + const params: Array<{ key: string; value: string }> = [ + ...(edits?.params ?? result.texts.find((t) => t.kind === 'params')?.pairs ?? []), + ]; + // 包装重量 / 包装尺寸合并进参数(后端存到 raw.params,studio 里再映射为 Ozon 字段) + if (edits?.weight) params.push({ key: '包装重量', value: edits.weight }); + const dimSuffix = edits?.dimsUnit === 'mm' ? ' mm' : ' cm'; + if (edits?.dims?.l) params.push({ key: '包装长度', value: `${edits.dims.l}${dimSuffix}` }); + if (edits?.dims?.w) params.push({ key: '包装宽度', value: `${edits.dims.w}${dimSuffix}` }); + if (edits?.dims?.h) params.push({ key: '包装高度', value: `${edits.dims.h}${dimSuffix}` }); + + const texts: MaterialsPayload['texts'] = []; + if (title) texts.push({ kind: 'title', content: title }); + if (price) texts.push({ kind: 'price', content: price }); + if (brand) texts.push({ kind: 'brand', content: brand }); + if (params.length) texts.push({ kind: 'params', content: '', pairs: params }); + if (sellingPoints) texts.push({ kind: 'selling_point', content: sellingPoints }); + if (desc) texts.push({ kind: 'desc', content: desc }); + + const images = result.images + .filter((img) => selectedKeys.has(img.key)) + .map((img) => ({ + groupKey: img.groupKey, + groupName: img.groupName, + variantName: img.variantName ?? null, + url: img.url, + index: img.index, + type: img.type, + dedupeKey: img.url, + })); + + return { + product_id: null, + source: { + platform: result.platform, + itemId: result.itemId, + url: result.url, + collectedAt: result.scannedAt, + }, + texts, + images, + refererOrigin: 'https://www.ozon.ru', + }; +} + +export async function uploadMaterials( + baseUrl: string, + token: string, + payload: MaterialsPayload, +): Promise<{ product_id: string; stage: string; assets_queued: number }> { + const headers: Record = { 'Content-Type': 'application/json' }; + if (token) headers.Authorization = `Bearer ${token}`; // 单用户宽松模式:token 可空 + + const res = await fetch(`${baseUrl.replace(/\/$/, '')}/api/materials`, { + method: 'POST', + headers, + body: JSON.stringify(payload), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error(data?.detail || `上传失败 HTTP ${res.status}`); + } + return data; +} diff --git a/extension-v2/src/collector/dom.ts b/extension-v2/src/collector/dom.ts new file mode 100644 index 0000000..a9e99a2 --- /dev/null +++ b/extension-v2/src/collector/dom.ts @@ -0,0 +1,54 @@ +/** + * DOM 工具 - 等待元素、Shadow DOM 穿透 + * 从 extension-v1 移植 + */ + +/** 等待任一选择器出现(MutationObserver + 超时) */ +export function waitForAny( + selectors: string[], + timeoutMs = 10_000 +): Promise { + const hit = () => selectors.map((s) => document.querySelector(s)).find(Boolean) ?? null; + + const found = hit(); + if (found) return Promise.resolve(found); + + return new Promise((resolve) => { + const timer = setTimeout(() => { + observer.disconnect(); + resolve(null); + }, timeoutMs); + + const observer = new MutationObserver(() => { + const el = hit(); + if (el) { + clearTimeout(timer); + observer.disconnect(); + resolve(el); + } + }); + + observer.observe(document.documentElement, { childList: true, subtree: true }); + }); +} + +/** 穿透 Shadow DOM 查询元素(Ozon 部分组件用了 Web Components) */ +export function queryAllDeep(selectors: string[]): Element[] { + const out: Element[] = []; + for (const sel of selectors) { + let nodes: NodeListOf; + try { + nodes = document.querySelectorAll(sel); + } catch { + continue; // 选择器写错不能拖垮整个扫描 + } + nodes.forEach((el) => { + if (el.shadowRoot) { + out.push(...Array.from(el.shadowRoot.querySelectorAll('img, video, source'))); + } else { + out.push(el); + } + }); + } + return out; +} diff --git a/extension-v2/src/collector/image.ts b/extension-v2/src/collector/image.ts new file mode 100644 index 0000000..ade829e --- /dev/null +++ b/extension-v2/src/collector/image.ts @@ -0,0 +1,146 @@ +/** + * 图片提取 - 主图、SKU、详情图、视频 + * 从 extension-v1 移植,新增: + * - srcset 处理(Ozon 画廊是 / ) + * - toOriginalUrl 传平台规则(Ozon /wc\d+/) + */ +import { + toAbsoluteUrl, + toOriginalUrl, + urlInBrackets, + looksLikeImageUrl, + dedupeKey, + pickBestFromSrcset, +} from './url'; +import { queryAllDeep } from './dom'; +import type { ImageGroupKey, SiteProfile, SrcProp } from '../profiles/types'; + +export interface ImageMaterial { + key: string; // 'main-001' + groupKey: ImageGroupKey; // 'main' + groupName: string; // '主图' + variantName?: string; // SKU 规格名(仅 sku 组) + url: string; // 已还原为原图 + thumbUrl: string; // 页面上的原始小图地址 + index: number; + type: 'img' | 'video'; + width?: number; + height?: number; +} + +/** 从元素上读出图片地址与名称,按 srcProps 顺序降级 */ +function readImageSource( + el: Element, + srcProps: SrcProp[], + nameSelectors?: string[] +): { url: string; name: string; imgEl: HTMLImageElement | null } { + let url = ''; + let name = ''; + let imgEl: HTMLImageElement | null = el instanceof HTMLImageElement ? el : null; + + for (const prop of srcProps) { + if (url) break; + + if (prop === 'backgroundImage') { + if (el.tagName === 'IMG') { + const img = el as HTMLImageElement; + url = img.currentSrc || img.src || ''; + name = img.alt || ''; + } else { + const bg = getComputedStyle(el).backgroundImage || ''; + const cand = (urlInBrackets(bg) || bg).replace(/['"]/g, ''); + if (looksLikeImageUrl(cand)) url = cand; + } + continue; + } + + if (prop === 'srcset') { + // + const raw = el.getAttribute('srcset') || (el as any).srcset || ''; + if (raw) url = pickBestFromSrcset(raw); + continue; + } + + const raw = (el as any)[prop] || el.getAttribute(prop); + if (raw) { + // srcset 场景下 currentSrc 才是实际加载的那张 + url = prop === 'src' ? ((el as HTMLImageElement).currentSrc || (el as HTMLImageElement).src || '') : raw; + } + } + + // 选择器命中的是容器、图在子节点上 + if (!url && el.tagName !== 'IMG') { + const inner = el.querySelector('img, source'); + if (inner) { + const srcset = inner.getAttribute('srcset'); + url = srcset + ? pickBestFromSrcset(srcset) + : inner.getAttribute('data-src') || (inner as HTMLImageElement).currentSrc || (inner as HTMLImageElement).src || ''; + if (inner instanceof HTMLImageElement) imgEl = inner; + if (!name && inner instanceof HTMLImageElement) name = inner.alt || ''; + } + } + + // 名称统一取(SKU 规格名) + if (!name && nameSelectors?.length) { + for (const sel of nameSelectors) { + const t = el.querySelector(sel)?.textContent?.trim(); + if (t) { + name = t; + break; + } + } + } + + return { url: url ? toAbsoluteUrl(url) : '', name, imgEl }; +} + +export function collectImages(profile: SiteProfile): ImageMaterial[] { + const result: ImageMaterial[] = []; + + for (const group of profile.imageGroups) { + const srcProps = group.srcProps ?? profile.defaultSrcProps; + // 去重按组独立:一张图同时是主图和 SKU 图是正常的 + const seen = new Set(); + const activeSet = new Set(group.activeSelectors ? queryAllDeep(group.activeSelectors) : []); + + for (const el of queryAllDeep(group.selectors)) { + if (activeSet.has(el)) continue; + if (group.excludeWithin?.some((sel) => el.closest(sel))) continue; + + const { url: rawUrl, name, imgEl } = readImageSource(el, srcProps, group.nameSelectors); + if (!rawUrl) continue; + + if (group.type === 'video' && !/\.(mp4|avi|mov|wmv|m3u8|webm)(\?|$)/i.test(rawUrl) && !/^blob:/i.test(rawUrl)) { + continue; + } + + const url = group.type === 'img' ? toOriginalUrl(rawUrl, profile.originalUrlRules) : rawUrl; + + // 尺寸过滤 + if (group.type === 'img' && (group.minWidth || group.minHeight)) { + const measured = imgEl ?? (el as HTMLElement); + const w = (measured as HTMLImageElement).naturalWidth || (measured as HTMLElement).offsetWidth || 0; + const h = (measured as HTMLImageElement).naturalHeight || (measured as HTMLElement).offsetHeight || 0; + if (w > 0 && h > 0 && (w < (group.minWidth ?? 0) || h < (group.minHeight ?? 0))) continue; + } + + const k = group.key === 'sku' ? `${dedupeKey(url, profile.originalUrlRules)}::${name}` : dedupeKey(url, profile.originalUrlRules); + if (seen.has(k)) continue; + seen.add(k); + + result.push({ + key: `${group.key}-${String(result.filter((r) => r.groupKey === group.key).length + 1).padStart(3, '0')}`, + groupKey: group.key, + groupName: group.name, + variantName: group.key === 'sku' ? name || undefined : undefined, + url, + thumbUrl: rawUrl, + index: result.length, + type: group.type, + }); + } + } + + return result; +} diff --git a/extension-v2/src/collector/jsonld.ts b/extension-v2/src/collector/jsonld.ts new file mode 100644 index 0000000..fbd153a --- /dev/null +++ b/extension-v2/src/collector/jsonld.ts @@ -0,0 +1,107 @@ +/** + * JSON-LD 提取器(schema.org/Product) + * + * Ozon 是 SSR 站点,商品页 HTML 里带 application/ld+json, + * 是 DOM 之外最稳定的结构化来源(比哈希类名稳定一个数量级)。 + * + * 参考实现(毛子ERP)也解析 application/ld+json 取 description / offers.url。 + */ + +export interface JsonLdProduct { + title?: string; + description?: string; + brand?: string; + sku?: string; + price?: string; + currency?: string; + images: string[]; + rating?: string; + reviewCount?: string; +} + +function asString(v: unknown): string | undefined { + if (typeof v === 'string') return v; + if (typeof v === 'number') return String(v); + return undefined; +} + +function findProduct(node: unknown): any | null { + if (Array.isArray(node)) { + for (const item of node) { + const r = findProduct(item); + if (r) return r; + } + return null; + } + if (!node || typeof node !== 'object') return null; + + const obj = node as Record; + const type = obj['@type']; + const types = Array.isArray(type) ? type : [type]; + if (types.some((t) => t === 'Product')) return obj; + + // @graph 包裹 + if (Array.isArray(obj['@graph'])) { + for (const g of obj['@graph']) { + const r = findProduct(g); + if (r) return r; + } + } + return null; +} + +function collectImages(node: unknown, out: string[]): void { + if (!node) return; + if (typeof node === 'string') { + if (/^(https?:)?\/\/.+/i.test(node) && !out.includes(node)) out.push(node); + return; + } + if (Array.isArray(node)) { + node.forEach((n) => collectImages(n, out)); + return; + } + if (typeof node === 'object') { + for (const v of Object.values(node as Record)) { + collectImages(v, out); + } + } +} + +export function extractJsonLd(): JsonLdProduct | null { + try { + const scripts = document.querySelectorAll('script[type="application/ld+json"]'); + for (const script of Array.from(scripts)) { + const text = script.textContent?.trim(); + if (!text) continue; + let data: unknown; + try { + data = JSON.parse(text); + } catch { + continue; + } + const product = findProduct(data); + if (!product) continue; + + const offers = Array.isArray(product.offers) ? product.offers[0] : product.offers; + const brandName = product.brand?.name ?? (typeof product.brand === 'string' ? product.brand : undefined); + + const images: string[] = []; + if (product.image) collectImages(product.image, images); + + return { + title: asString(product.name), + description: asString(product.description), + brand: asString(brandName), + sku: asString(product.sku), + price: asString(offers?.price), + currency: asString(offers?.priceCurrency), + images, + rating: asString(product.aggregateRating?.ratingValue), + reviewCount: asString(product.aggregateRating?.reviewCount), + }; + } + } catch (err) { + console.warn('[JSON-LD] 提取失败:', err); + } + return null; +} diff --git a/extension-v2/src/collector/ozon-api.ts b/extension-v2/src/collector/ozon-api.ts new file mode 100644 index 0000000..9f3bd3b --- /dev/null +++ b/extension-v2/src/collector/ozon-api.ts @@ -0,0 +1,283 @@ +/** + * Ozon 内部页 JSON API 提取器(补充路径) + * + * 参考实现(毛子ERP)的采集核心是直接请求 Ozon 自己的页数据接口: + * + * GET {origin}/api/entrypoint-api.bx/page/json/v2?url=/product/{id}/ + * → { widgetStates: { "webCharacteristics-…": "...", "webGallery-…": "...", ... } } + * + * ★ 关键点(毛子ERP 的做法,也是本文件修复点): + * - 默认页 `/product/{id}/` 里带 **webCharacteristics(全量「特征」)**, + * SSR 里的 webShortCharacteristics 只给前 5 项(limit:5)。 + * - 描述页 `/product/{id}/?layout_container=pdpPage2column&layout_page_index=2` + * 里带 webDescription(富文本描述)。 + * 所以要两个 URL 都请求、合并,才能拿到完整参数表 + 描述。 + * + * ★ 图片只从画廊类 widget 收(白名单),绝不递归全部 widgetStates, + * 避免「为您推荐 / 一起购买」等 carousel 图混入。 + */ + +export interface OzonPageData { + title?: string; + price?: string; + oldPrice?: string; + description?: string; + /** 主图画廊(仅来自画廊 widget) */ + images: string[]; + videos: string[]; + /** 参数表(kv) */ + characteristics: Array<{ key: string; value: string }>; +} + +const IMG_EXT = /\.(jpg|jpeg|png|webp|gif|avif)(\?|$)/i; +const VID_EXT = /\.(mp4|m3u8|webm|mov)(\?|$)/i; + +function parseWidgetState(v: unknown): unknown { + if (typeof v !== 'string') return v; + try { + return JSON.parse(v); + } catch { + return v; + } +} + +function parseWidgetStates(widgetStates: unknown): Record { + const out: Record = {}; + if (!widgetStates || typeof widgetStates !== 'object') return out; + for (const [k, v] of Object.entries(widgetStates as Record)) { + out[k] = parseWidgetState(v); + } + return out; +} + +function pushUnique(arr: string[], v: string): void { + if (v && !arr.includes(v)) arr.push(v); +} + +/** 递归收集画廊 widget 内的图片/视频 URL(只在这个 widget 内走) */ +function collectMedia(node: unknown, images: string[], videos: string[]): void { + if (!node) return; + if (typeof node === 'string') { + if (IMG_EXT.test(node)) pushUnique(images, node); + else if (VID_EXT.test(node)) pushUnique(videos, node); + return; + } + if (Array.isArray(node)) { + node.forEach((n) => collectMedia(n, images, videos)); + return; + } + if (typeof node !== 'object') return; + for (const v of Object.values(node as Record)) { + collectMedia(v, images, videos); + } +} + +/** 从 characteristic 类 widget 里收参数表 */ +function collectCharacteristics(node: unknown, out: Array<{ key: string; value: string }>): void { + if (!node || typeof node !== 'object') return; + const walk = (n: unknown): void => { + if (!n || typeof n !== 'object') return; + if (Array.isArray(n)) { + n.forEach(walk); + return; + } + const obj = n as Record; + for (const [k, v] of Object.entries(obj)) { + if (/characteristic|aspect/i.test(k) && Array.isArray(v)) { + for (const row of v) { + if (!row || typeof row !== 'object') continue; + const r = row as Record; + // { title: {textRs:[{content}]}, values:[{text}] }(Ozon 实测结构) + const key = readText(r.title); + if (key && Array.isArray(r.values)) { + const vals = r.values + .map((x) => (x && typeof x === 'object' ? readText((x as Record).text) : '')) + .filter(Boolean); + if (vals.length) out.push({ key, value: vals.join(', ') }); + continue; + } + // { key/value } / { name/value } / { title/text } + const k2 = (r.key ?? r.name ?? r.title) as string | undefined; + const v2 = (r.value ?? r.text) as string | undefined; + if (typeof k2 === 'string' && k2 && typeof v2 === 'string' && v2) { + out.push({ key: k2, value: v2 }); + } + } + } else if (/characteristic|aspect/i.test(k) && typeof v === 'object') { + walk(v); + } + } + }; + walk(node); +} + +function readText(node: unknown): string { + if (!node) return ''; + if (typeof node === 'string') return node.trim(); + if (typeof node !== 'object') return ''; + // { textRs: [{ type, content }] } / { content } / { text } + const obj = node as Record; + if (Array.isArray(obj.textRs)) { + return obj.textRs + .map((t) => (t && typeof t === 'object' ? (t as Record).content ?? '' : '')) + .join('') + .trim(); + } + if (typeof obj.content === 'string') return obj.content.trim(); + if (typeof obj.text === 'string') return obj.text.trim(); + return ''; +} + +/** 从描述类 widget 里收富文本描述 */ +function collectDescription(node: unknown, out: { description?: string }): void { + if (!node || typeof node !== 'object') return; + const obj = node as Record; + if (typeof obj.richAnnotationJson === 'string') { + try { + const rich = JSON.parse(obj.richAnnotationJson); + out.description = richToString(rich); + } catch { + out.description = obj.richAnnotationJson; + } + return; + } + if (typeof obj.description === 'string') { + out.description = obj.description; + return; + } +} + +/** richAnnotationJson(富文本块数组)→ 纯文本 */ +function richToString(rich: unknown): string { + if (!rich) return ''; + if (typeof rich === 'string') return rich; + const texts: string[] = []; + const walk = (n: unknown): void => { + if (!n) return; + if (typeof n === 'string') { + texts.push(n); + return; + } + if (Array.isArray(n)) { + n.forEach(walk); + return; + } + if (typeof n === 'object') { + for (const [k, v] of Object.entries(n as Record)) { + if (k === 'text' && typeof v === 'string') texts.push(v); + else if (k !== 'type') walk(v); + } + } + }; + walk(rich); + return texts.join('\n').trim(); +} + +/** 解析单个 widgetStates → 部分 OzonPageData */ +function parsePage(widgets: Record): OzonPageData { + const images: string[] = []; + const videos: string[] = []; + const characteristics: Array<{ key: string; value: string }> = []; + const desc: { description?: string } = {}; + let title: string | undefined; + let price: string | undefined; + let oldPrice: string | undefined; + + for (const [wkey, wval] of Object.entries(widgets)) { + const key = wkey.toLowerCase(); + + // 图片/视频:只收主画廊 widget(webGallery), + // 不能按 "gallery" 子串匹配 —— webReviewGallery 是「买家照片和视频」,会混入 + if (key.startsWith('webgallery')) { + collectMedia(wval, images, videos); + } + // 参数表(含全量 webCharacteristics) + if (/(characteristic|aspect)/.test(key)) { + collectCharacteristics(wval, characteristics); + } + // 描述 + if (/(description|richcontent)/.test(key)) { + collectDescription(wval, desc); + } + // 标题 / 价格(各自的 widget) + if (/heading|title/.test(key) && !title) { + const v = (wval as Record)?.title ?? (wval as Record)?.name; + if (typeof v === 'string' && v && !/^https?:/i.test(v)) title = v; + } + if (/webprice/.test(key) && !price) { + const p = (wval as Record)?.price; + if (typeof p === 'string') price = p; + const op = (wval as Record)?.originalPrice; + if (typeof op === 'string') oldPrice = op; + } + } + + return { + title, + price, + oldPrice, + description: desc.description, + images, + videos, + characteristics: dedupePairs(characteristics), + }; +} + +async function fetchPage(url: string): Promise | null> { + try { + const res = await fetch(url, { credentials: 'include', headers: { Accept: 'application/json' } }); + if (!res.ok) return null; + const json = (await res.json()) as { widgetStates?: unknown }; + return parseWidgetStates(json.widgetStates); + } catch (err) { + console.warn('[Ozon API] 请求失败:', url, err); + return null; + } +} + +export async function fetchOzonPageData(itemId: string): Promise { + // 默认页(标题/价格/画廊 + 全量特征 webCharacteristics)+ 描述页(富文本描述) + const urls = [ + `/product/${itemId}/`, + `/product/${itemId}/?layout_container=pdpPage2column&layout_page_index=2`, + ]; + + const merged: OzonPageData = { images: [], videos: [], characteristics: [] }; + let gotAny = false; + + for (const target of urls) { + const widgets = await fetchPage( + `${location.origin}/api/entrypoint-api.bx/page/json/v2?url=${encodeURIComponent(target)}`, + ); + if (!widgets) continue; + const p = parsePage(widgets); + gotAny = true; + + merged.title = merged.title || p.title; + merged.price = merged.price || p.price; + merged.oldPrice = merged.oldPrice || p.oldPrice; + merged.description = merged.description || p.description; + for (const img of p.images) if (!merged.images.includes(img)) merged.images.push(img); + for (const v of p.videos) if (!merged.videos.includes(v)) merged.videos.push(v); + for (const c of p.characteristics) merged.characteristics.push(c); + } + + merged.characteristics = dedupePairs(merged.characteristics); + + return gotAny && + (merged.images.length || merged.title || merged.price || merged.characteristics.length || merged.description) + ? merged + : null; +} + +function dedupePairs(pairs: Array<{ key: string; value: string }>): Array<{ key: string; value: string }> { + const seen = new Set(); + const out: Array<{ key: string; value: string }> = []; + for (const p of pairs) { + const k = `${p.key}::${p.value}`; + if (seen.has(k)) continue; + seen.add(k); + out.push(p); + } + return out; +} diff --git a/extension-v2/src/collector/ozon-state.ts b/extension-v2/src/collector/ozon-state.ts new file mode 100644 index 0000000..99c0513 --- /dev/null +++ b/extension-v2/src/collector/ozon-state.ts @@ -0,0 +1,244 @@ +/** + * Ozon SSR widget state 提取器(主路径) + * + * Ozon 页面把每个 widget 的 JSON state 内嵌在 DOM 里: + *
+ * content script 直接读 data-state 即可,无需访问页面 JS(main world)。 + * + * 结构已在真实页面实测(reference/ozon1.html、ozon2.html): + * - webGallery: coverImage / images[{src,alt}](原图)/ videos[{url,coverUrl}] + * - webPrice: price / originalPrice / cardPrice(如 "108,26 ¥") + * - webProductHeading: title + * - webShortCharacteristics / webDetailedCharacteristics: characteristics[] + * - webAspects: aspects[].variants[].data.{searchableText, coverImage}(SKU 变体) + * - webReviewProductScore: totalScore / reviewsCount + * + * ★ 白名单机制:只读上面这几个 widget 的 state。 + * 绝不遍历全页 —— "为您推荐 / 一起购买" 等其它商品 carousel 的 state + * (webRecommendedProducts / webCarousel / 类似 widget)根本不会被读到。 + */ +import { toAbsoluteUrl } from './url'; + +export interface OzonVariant { + name: string; + image?: string; // 可能为 undefined(纯文字规格,如尺码) +} + +export interface BreadcrumbItem { + name: string; // 类目名称(如"扑满"、"儿童房") + href: string; // 原始链接(/category/kopilki-15056/ 或 ?category=7041) + searchCategoryId?: number; // Ozon 搜索类目 ID(从 ?category=xxx 解析) + slug?: string; // URL slug(从 /category/xxx-123/ 解析,含数字 ID) +} + +export interface OzonStateData { + title?: string; + price?: string; + originalPrice?: string; + rating?: string; + reviewCount?: string; + galleryImages: string[]; // 原图(无尺寸标记) + videos: string[]; + videoCovers: string[]; + skuVariants: OzonVariant[]; + characteristics: Array<{ key: string; value: string }>; + breadcrumbs: BreadcrumbItem[]; // 面包屑类目路径 +} + +/** 允许读取的 widget 前缀白名单 */ +const ALLOWED_WIDGETS = [ + 'webGallery-', + 'webPrice-', + 'webProductHeading-', + 'webShortCharacteristics-', + 'webDetailedCharacteristics-', + 'webCharacteristics-', + 'webAspects-', + 'webReviewProductScore-', + 'breadCrumbs-', // 面包屑类目路径 +]; + +function pushUnique(arr: string[], v: string): void { + const abs = toAbsoluteUrl(v); + if (abs && !arr.includes(abs)) arr.push(abs); +} + +function readTextRs(node: unknown): string { + // 提取 textRs / descriptionRs 里的展示文本。 + // 规则:content/text 字段的值收进文本;递归进入数组/对象找嵌套的 content/text; + // 跳过 type/font/color/id/href 等样式与元数据字段(type=newLine 除外)。 + if (node == null) return ''; + if (typeof node === 'string') return node.trim(); + if (typeof node !== 'object') return ''; + const texts: string[] = []; + const walk = (n: unknown): void => { + if (!n) return; + if (typeof n === 'string') { + texts.push(n); + return; + } + if (Array.isArray(n)) { + n.forEach(walk); + return; + } + if (typeof n === 'object') { + for (const [k, v] of Object.entries(n as Record)) { + if (k === 'type' && (v === 'newLine' || v === 'lineBreak')) { + texts.push('\n'); + } else if (k === 'content' || k === 'text') { + walk(v); + } else if (v && typeof v === 'object') { + walk(v); + } + // 其它原始值(font/color/id/type='text' 等)直接跳过 + } + } + }; + walk(node); + return texts.join('').trim(); +} + +function parseCharacteristics(chars: unknown): Array<{ key: string; value: string }> { + if (!Array.isArray(chars)) return []; + const out: Array<{ key: string; value: string }> = []; + for (const c of chars) { + if (!c || typeof c !== 'object') continue; + const row = c as Record; + // 结构 A:{ title: { textRs: [...] }, values: [{ text: ... }] }(实测) + const key = readTextRs(row.title); + if (Array.isArray(row.values)) { + const vals = row.values + .map((v) => (v && typeof v === 'object' ? readTextRs((v as Record).text) : '')) + .map((t) => t.replace(/,\s*$/, '')) // 源数据值自带尾逗号(如 "音乐, ") + .filter(Boolean); + if (key && vals.length) out.push({ key, value: vals.join(', ') }); + continue; + } + // 结构 B:{ key, value } / { name, value } / { title, text } + const k2 = (row.key ?? row.name ?? row.title) as string | undefined; + const v2 = (row.value ?? row.text) as string | undefined; + if (typeof k2 === 'string' && k2 && typeof v2 === 'string' && v2) { + out.push({ key: k2, value: v2 }); + } + } + return out; +} + +export function extractOzonState(): OzonStateData { + const data: OzonStateData = { + galleryImages: [], + videos: [], + videoCovers: [], + skuVariants: [], + characteristics: [], + breadcrumbs: [], + }; + const seenChars = new Set(); + + const els = document.querySelectorAll('div[id^="state-"]'); + for (const el of Array.from(els)) { + const id = el.id.slice('state-'.length); + if (!ALLOWED_WIDGETS.some((p) => id.startsWith(p))) continue; + const raw = el.getAttribute('data-state'); + if (!raw) continue; + let state: unknown; + try { + state = JSON.parse(raw); + } catch { + continue; + } + if (!state || typeof state !== 'object') continue; + const s = state as Record; + + if (id.startsWith('webGallery-')) { + if (typeof s.coverImage === 'string') pushUnique(data.galleryImages, s.coverImage); + if (Array.isArray(s.images)) { + for (const img of s.images) { + const src = img && typeof (img as Record).src === 'string' + ? (img as Record).src as string + : undefined; + if (src) pushUnique(data.galleryImages, src); + } + } + if (Array.isArray(s.videos)) { + for (const v of s.videos) { + const rec = v as Record; + if (typeof rec.url === 'string') pushUnique(data.videos, rec.url); + if (typeof rec.coverUrl === 'string') pushUnique(data.videoCovers, rec.coverUrl); + } + } + } else if (id.startsWith('webPrice-')) { + if (typeof s.price === 'string') data.price = s.price; + if (typeof s.originalPrice === 'string') data.originalPrice = s.originalPrice; + if (!data.price && typeof s.cardPrice === 'string') data.price = s.cardPrice; + } else if (id.startsWith('webProductHeading-')) { + if (typeof s.title === 'string') data.title = s.title; + } else if ( + id.startsWith('webShortCharacteristics-') || + id.startsWith('webDetailedCharacteristics-') || + id.startsWith('webCharacteristics-') + ) { + for (const c of parseCharacteristics(s.characteristics)) { + const k = `${c.key}::${c.value}`; + if (!seenChars.has(k)) { + seenChars.add(k); + data.characteristics.push(c); + } + } + } else if (id.startsWith('webAspects-')) { + if (Array.isArray(s.aspects)) { + for (const aspect of s.aspects) { + const a = aspect as Record; + if (!Array.isArray(a.variants)) continue; + for (const v of a.variants) { + const rec = v as Record; + const d = rec.data as Record | undefined; + const name = typeof d?.searchableText === 'string' ? d.searchableText + : typeof d?.title === 'string' ? d.title : ''; + const image = typeof d?.coverImage === 'string' ? d.coverImage : undefined; + if (name) data.skuVariants.push({ name, image }); + } + } + } + } else if (id.startsWith('webReviewProductScore-')) { + if (typeof s.totalScore === 'number') data.rating = String(s.totalScore); + if (typeof s.reviewsCount === 'number') data.reviewCount = String(s.reviewsCount); + } else if (id.startsWith('breadCrumbs-')) { + // breadCrumbs widget state: { breadcrumbs: [{text, link, crumbType}] } + if (Array.isArray(s.breadcrumbs) && data.breadcrumbs.length === 0) { + for (const crumb of s.breadcrumbs) { + const c = crumb as Record; + const name = typeof c.text === 'string' ? c.text.trim() : ''; + const href = typeof c.link === 'string' ? c.link : ''; + if (!name || !href) continue; + // 解析 ?category=7041(highlight 样式链接) + const catMatch = href.match(/[?&]category=(\d+)/); + const searchCategoryId = catMatch ? Number(catMatch[1]) : undefined; + // 解析 /category/kopilki-15056/(末尾带数字 ID 的 slug) + const slugMatch = href.match(/\/category\/([^/?]+)/); + const slug = slugMatch ? slugMatch[1] : undefined; + data.breadcrumbs.push({ name, href, searchCategoryId, slug }); + } + } + } + } + + // 如果 widget state 没有面包屑(旧版页面),尝试读 DOM 渲染的 ol + if (data.breadcrumbs.length === 0) { + const ol = document.querySelector('[class*="breadCrumbs"] ol, nav ol, ol[class*="breadcrumb"]'); + if (ol) { + for (const a of Array.from(ol.querySelectorAll('a[href]'))) { + const href = a.getAttribute('href') ?? ''; + const name = a.textContent?.trim() ?? ''; + if (!name) continue; + const catMatch = href.match(/[?&]category=(\d+)/); + const searchCategoryId = catMatch ? Number(catMatch[1]) : undefined; + const slugMatch = href.match(/\/category\/([^/?]+)/); + const slug = slugMatch ? slugMatch[1] : undefined; + data.breadcrumbs.push({ name, href, searchCategoryId, slug }); + } + } + } + + return data; +} diff --git a/extension-v2/src/collector/scan.ts b/extension-v2/src/collector/scan.ts new file mode 100644 index 0000000..135500b --- /dev/null +++ b/extension-v2/src/collector/scan.ts @@ -0,0 +1,278 @@ +/** + * 采集引擎入口 - 扫描当前页 + * + * Ozon 四路径(优先级从高到低): + * ① SSR widget state(DOM data-state 属性,同步、白名单、无需网络)★ 主路径 + * ② JSON-LD(schema.org/Product) + * ③ Ozon 内部页 JSON API(entrypoint-api.bx,只收画廊 widget 的图) + * ④ DOM 选择器(data-widget 区块)—— 兜底 + 详情图补充 + * + * ① 白名单保证不会读到「为您推荐 / 一起购买」等其它商品 carousel 的图片。 + */ +import { matchProfile } from '../profiles'; +import { waitForAny } from './dom'; +import { collectImages, type ImageMaterial } from './image'; +import { collectTexts, mergeTexts, type TextMaterial } from './text'; +import { extractJsonLd } from './jsonld'; +import { fetchOzonPageData, type OzonPageData } from './ozon-api'; +import { extractOzonState, type OzonStateData } from './ozon-state'; +import { dedupeKey, toOriginalUrl, toThumbUrl } from './url'; +import type { SiteProfile } from '../profiles/types'; + +export type { ImageMaterial, TextMaterial }; + +import type { BreadcrumbItem } from './ozon-state'; + +export interface ScanResult { + platform: string; + itemId: string | null; + url: string; + texts: TextMaterial[]; + images: ImageMaterial[]; + breadcrumbs: BreadcrumbItem[]; // 面包屑类目路径(用于 studio 类目推荐) + scannedAt: number; + stats: Record; // 分组统计 + warnings: string[]; // 警告(如详情图为 0) + source: 'state' | 'jsonld' | 'api' | 'dom' | 'mixed'; // 主路径 +} + +const GROUP_ORDER: Array<{ key: ImageMaterial['groupKey']; name: string }> = [ + { key: 'main', name: '主图' }, + { key: 'sku', name: 'SKU图片' }, + { key: 'detail', name: '详情图' }, + { key: 'video', name: '视频' }, +]; + +/** 合并后的结构化素材 */ +interface StructuredBundle { + title?: string; + price?: string; + brand?: string; + description?: string; + characteristics: Array<{ key: string; value: string }>; + galleryImages: string[]; + videos: string[]; + videoCovers: string[]; + skuVariants: Array<{ name: string; image?: string }>; +} + +/** 合并 state + JSON-LD + API,靠前来源优先,靠后来源填空缺 */ +function mergeStructured( + state: OzonStateData, + jsonld: ReturnType, + api: OzonPageData | null +): StructuredBundle { + const bundle: StructuredBundle = { + title: state.title || jsonld?.title || api?.title, + price: state.price || jsonld?.price || api?.price, + brand: jsonld?.brand, + description: api?.description || jsonld?.description, + characteristics: [...state.characteristics], + galleryImages: [...state.galleryImages], + videos: [...state.videos], + videoCovers: [...state.videoCovers], + skuVariants: [...state.skuVariants], + }; + + // API 补充:画廊图片、视频、参数(state 没有才补) + for (const u of api?.images ?? []) { + if (!bundle.galleryImages.includes(u)) bundle.galleryImages.push(u); + } + for (const u of api?.videos ?? []) { + if (!bundle.videos.includes(u)) bundle.videos.push(u); + } + const seenChars = new Set(bundle.characteristics.map((c) => `${c.key}::${c.value}`)); + for (const c of api?.characteristics ?? []) { + const k = `${c.key}::${c.value}`; + if (!seenChars.has(k)) { + seenChars.add(k); + bundle.characteristics.push(c); + } + } + + return bundle; +} + +/** 从合并后的结构化素材构建文本与图片 */ +function buildFromBundle(profile: SiteProfile, bundle: StructuredBundle): { + texts: TextMaterial[]; + images: ImageMaterial[]; +} { + const texts: TextMaterial[] = []; + const images: ImageMaterial[] = []; + + if (bundle.title) texts.push({ kind: 'title', content: bundle.title }); + if (bundle.price) texts.push({ kind: 'price', content: bundle.price }); + if (bundle.brand) texts.push({ kind: 'brand', content: bundle.brand }); + if (bundle.characteristics.length) { + texts.push({ + kind: 'params', + content: bundle.characteristics.map((p) => `${p.key}: ${p.value}`).join('\n'), + pairs: bundle.characteristics, + }); + } + if (bundle.description) texts.push({ kind: 'desc', content: bundle.description }); + + let idx = 0; + bundle.galleryImages.forEach((u, i) => { + const orig = toOriginalUrl(u, profile.originalUrlRules); + images.push({ + key: `main-${String(i + 1).padStart(3, '0')}`, + groupKey: 'main', + groupName: '主图', + url: orig, + thumbUrl: toThumbUrl(orig), + index: idx++, + type: 'img', + }); + }); + + bundle.skuVariants.forEach((s, i) => { + if (!s.image) return; + const orig = toOriginalUrl(s.image, profile.originalUrlRules); + images.push({ + key: `sku-${String(i + 1).padStart(3, '0')}`, + groupKey: 'sku', + groupName: 'SKU图片', + variantName: s.name || undefined, + url: orig, + thumbUrl: toThumbUrl(orig), + index: idx++, + type: 'img', + }); + }); + + bundle.videos.forEach((u, i) => { + images.push({ + key: `video-${String(i + 1).padStart(3, '0')}`, + groupKey: 'video', + groupName: '视频', + url: u, + // 用视频封面图做缩略图(首帧),拿不到再留空走 ▶ 占位 + thumbUrl: bundle.videoCovers[i] ?? '', + index: idx++, + type: 'video', + }); + }); + + return { texts, images }; +} + +/** 按组分组合并:结构化优先,DOM 填缺,按 dedupeKey 去重后重排 index */ +function mergeImages( + structured: ImageMaterial[], + dom: ImageMaterial[], + profile: SiteProfile +): ImageMaterial[] { + const byGroup = new Map(); + const seen = new Set(); + let counter = 0; + + const push = (m: ImageMaterial) => { + const k = m.groupKey === 'sku' + ? `${dedupeKey(m.url, profile.originalUrlRules)}::${m.variantName ?? ''}` + : dedupeKey(m.url, profile.originalUrlRules); + if (seen.has(k)) return; + seen.add(k); + const arr = byGroup.get(m.groupKey) ?? []; + arr.push({ ...m, index: counter++ }); + byGroup.set(m.groupKey, arr); + }; + + for (const m of structured) push(m); + for (const m of dom) push(m); + + const out: ImageMaterial[] = []; + for (const g of GROUP_ORDER) { + const arr = byGroup.get(g.key); + if (!arr) continue; + // 组内重排 key(main-001 …) + arr.forEach((m, i) => { + m.key = `${g.key}-${String(i + 1).padStart(3, '0')}`; + m.groupName = g.name; + }); + out.push(...arr); + } + return out; +} + +export async function scanCurrentPage(): Promise { + const profile = matchProfile(location.href); + if (!profile) { + console.warn('[Ozon Seller Kit] 当前页面不支持采集:', location.href); + return null; + } + + const itemId = profile.extractItemId(location.href); + console.log('[Ozon Seller Kit] 开始采集:', profile.name, itemId, location.href); + + // ── 路径①:SSR widget state(同步、白名单)── + const state = extractOzonState(); + let source: ScanResult['source'] = state.title || state.galleryImages.length ? 'state' : 'dom'; + + // ── 路径②:JSON-LD ── + const jsonld = extractJsonLd(); + + // ── 路径③:Ozon 页 JSON API(异步,失败不阻塞)── + let api: OzonPageData | null = null; + if (profile.id === 'ozon' && itemId) { + try { + api = await fetchOzonPageData(itemId); + } catch (err) { + console.warn('[Ozon Seller Kit] API 提取异常:', err); + } + } + + const bundle = mergeStructured(state, jsonld, api); + const structured = buildFromBundle(profile, bundle); + const usedStructured = structured.texts.some((t) => t.kind === 'title') || structured.images.length > 0; + if (usedStructured && source === 'dom') source = 'mixed'; + + // ── 路径④:DOM 采集(兜底 + 详情图补充)── + const anchor = await waitForAny(profile.readySelectors, profile.readyTimeoutMs ?? 8_000); + if (!anchor) { + console.warn('[Ozon Seller Kit] 等待页面就绪超时(继续尝试 DOM 采集)'); + } + const domTexts = collectTexts(profile).materials; + const domImages = collectImages(profile); + + // ── 合并 ── + const texts = mergeTexts(structured.texts, domTexts); + const images = mergeImages(structured.images, domImages, profile); + + const stats: Record = {}; + for (const img of images) stats[img.groupKey] = (stats[img.groupKey] ?? 0) + 1; + + const warnings: string[] = []; + if (!texts.some((t) => t.kind === 'title')) warnings.push('未采集到标题(所有路径均失败)'); + if (images.length === 0) warnings.push('未扫描到任何图片/视频'); + if ((stats.detail ?? 0) === 0) warnings.push('详情图为 0 张,请滚动到页面底部后重新采集'); + + console.log('[Ozon Seller Kit] 采集完成:', { + texts: texts.map((t) => t.kind), + images: images.length, + stats, + warnings, + source, + }); + + return { + platform: profile.id, + itemId, + url: location.href, + texts, + images, + breadcrumbs: state.breadcrumbs, + scannedAt: Date.now(), + stats, + warnings, + source, + }; +} + +// 暴露到全局供 side panel / console 调用 +if (typeof window !== 'undefined') { + (window as any).__SellerHelperOzon = { + scan: scanCurrentPage, + }; +} diff --git a/extension-v2/src/collector/text.ts b/extension-v2/src/collector/text.ts new file mode 100644 index 0000000..341aea7 --- /dev/null +++ b/extension-v2/src/collector/text.ts @@ -0,0 +1,110 @@ +/** + * 文本提取 - 标题、价格、参数表、卖点、描述、品牌 + * 从 extension-v1 移植(DOM 兜底路径) + */ +import type { SiteProfile, TextRule } from '../profiles/types'; + +export interface TextMaterial { + kind: TextRule['kind']; + content: string; + pairs?: Array<{ key: string; value: string }>; // table 模式的结构化结果 +} + +function clean(s: string): string { + return s.replace(/\s+/g, ' ').trim(); +} + +function extractOne(rule: TextRule): TextMaterial | null { + for (const sel of rule.selectors) { + let nodes: NodeListOf; + try { + nodes = document.querySelectorAll(sel); + } catch { + continue; + } + if (!nodes.length) continue; + + // table 模式:参数表 + if (rule.extract === 'table') { + const pairs: Array<{ key: string; value: string }> = []; + nodes.forEach((row) => { + const k = clean(row.querySelector(rule.tableKeySelector ?? '')?.textContent ?? ''); + const v = clean(row.querySelector(rule.tableValueSelector ?? '')?.textContent ?? ''); + if (k && v) pairs.push({ key: k.replace(/[::]$/, ''), value: v }); + }); + if (pairs.length) { + return { + kind: rule.kind, + content: pairs.map((p) => `${p.key}: ${p.value}`).join('\n'), + pairs, + }; + } + continue; + } + + // join 模式:标题被拆成多个 span + if (rule.extract === 'join') { + let text = ''; + nodes.forEach((n) => { + text += n.textContent ?? ''; + }); + text = clean(text); + if (text) return { kind: rule.kind, content: text }; + continue; + } + + // first 模式:只取第一个 + const first = clean(nodes[0].textContent ?? ''); + if (first) return { kind: rule.kind, content: first }; + } + return null; +} + +export function collectTexts(profile: SiteProfile): { + materials: TextMaterial[]; + missingRequired: string[]; +} { + const materials: TextMaterial[] = []; + const missingRequired: string[] = []; + + for (const rule of profile.textRules) { + const m = extractOne(rule); + if (m) materials.push(m); + else if (rule.required) missingRequired.push(rule.kind); + } + + return { materials, missingRequired }; +} + +/** 合并去重:以 kind 为键,结构化来源优先,DOM 来源兜底。 + * 参数表(params)特殊处理:两边的 pairs 做并集合并(按 key 去重), + * 因为「关于商品」只给前几项,完整「特征」在 DOM 里,需要合并才能拿全。 + */ +export function mergeTexts( + primary: TextMaterial[], + fallback: TextMaterial[] +): TextMaterial[] { + const map = new Map(); + for (const m of [...primary, ...fallback]) { + if (m.kind === 'params') { + const existing = map.get('params'); + if (!existing) { + map.set('params', { ...m, pairs: [...(m.pairs ?? [])] }); + } else { + const merged = [...(existing.pairs ?? [])]; + const seen = new Set(merged.map((p) => p.key)); + for (const p of m.pairs ?? []) { + if (!seen.has(p.key)) { + merged.push(p); + seen.add(p.key); + } + } + existing.pairs = merged; + existing.content = merged.map((p) => `${p.key}: ${p.value}`).join('\n'); + } + continue; + } + if (!map.has(m.kind)) map.set(m.kind, m); + } + return Array.from(map.values()); +} diff --git a/extension-v2/src/collector/url.ts b/extension-v2/src/collector/url.ts new file mode 100644 index 0000000..040b7c3 --- /dev/null +++ b/extension-v2/src/collector/url.ts @@ -0,0 +1,132 @@ +/** + * URL 工具链 + * 从 extension-v1 移植,新增: + * - toOriginalUrl 支持平台自定义规则(Ozon 的 /wc\d+/ 路径段尺寸标记) + * - pickBestFromSrcset:从 srcset 里挑最大尺寸候选 + */ + +const IMG_EXT = /\.(jpg|jpeg|png|gif|bmp|heic|webp|avif)$/i; + +export interface UrlRule { + match: RegExp; + replace: string; +} + +/** + * 缩略图 URL → 原图 URL + * 先走平台规则(Ozon 的 /wc\d+/ → /wc1200/), + * 再走阿里系通用规则:xxx.jpg_400x400.jpg → xxx.jpg + */ +export function toOriginalUrl(url: string, rules?: UrlRule[]): string { + let out = url; + for (const r of rules ?? []) { + // 带 g 标志的正则(query 清洗)要反复 replace,不带 g 的只替换一次 + if (r.match.global) { + out = out.replace(r.match, r.replace); + } else if (r.match.test(out)) { + out = out.replace(r.match, r.replace); + } + } + const m = out.match(/^(.+?\.(jpg|jpeg|png|gif|bmp|heic|webp|avif))_/i); + return m ? m[1] : out; +} + +/** url("https://...") → https://... */ +export function urlInBrackets(s: string): string { + if (!s?.trim()) return ''; + return s.match(/\((.*?)\)/)?.[1]?.replace(/['"]/g, '') ?? ''; +} + +export function isDataUrl(u: string): boolean { + return /^data:image/.test(u); +} + +/** 协议相对 // / 根相对 / / 相对路径 → 绝对 URL */ +export function toAbsoluteUrl(u: string): string { + if (!u) return u; + if (isDataUrl(u) || u.startsWith('blob:')) return u; + const proto = u.startsWith('http:') ? 'http' : 'https'; + if (/^\/\//.test(u)) return `${proto}:${u}`; + if (/^\//.test(u)) return `${location.origin}${u}`; + if (!/^(.*):/.test(u)) return `${location.origin}/${u}`; + return u; +} + +/** 去重用的归一化 key:还原原图 + 剥 query/hash */ +export function dedupeKey(url: string, rules?: UrlRule[]): string { + const base = toOriginalUrl(url, rules); + try { + const u = new URL(base); + u.search = ''; + u.hash = ''; + return u.toString(); + } catch { + return base; + } +} + +export function looksLikeImageUrl(u: string): boolean { + if (isDataUrl(u)) return true; + try { + return IMG_EXT.test(new URL(u).pathname); + } catch { + return IMG_EXT.test(u); + } +} + +/** + * 从 srcset 里挑最大尺寸候选。 + * 支持两种语法: + * "a.jpg 100w, b.jpg 200w, c.jpg 300w" → c.jpg + * "a.jpg 1x, b.jpg 2x" → 最后一个 + * "a.jpg 400w, b.jpg 800w, c.jpg 1200w, d.jpg" → 最后一个(无描述符 = 兜底最大) + */ +export function pickBestFromSrcset(srcset: string): string { + if (!srcset) return ''; + const parts = srcset.split(',').map((p) => p.trim()).filter(Boolean); + if (!parts.length) return ''; + + let best = ''; + let bestSize = -1; + for (const part of parts) { + const seg = part.split(/\s+/); + const url = seg[0]; + const desc = seg[1] ?? ''; + let size = -1; + const w = desc.match(/^(\d+)w$/); + const x = desc.match(/^(\d+(?:\.\d+)?)x$/); + if (w) size = Number(w[1]); + else if (x) size = Math.round(Number(x[1]) * 1000); + else size = 0; // 无描述符,通常是最小的兜底,但也可能是唯一候选 + + if (size >= bestSize) { + bestSize = size; + best = url; + } + } + return best; +} + +/** + * Ozon CDN 原图 → wc200 缩略图(侧边栏预览用,省流量) + * 实测结构(reference/ozon1.html): + * https://ir.ozone.ru/s3/multimedia-1-5/9290076089.jpg + * → https://ir.ozone.ru/s3/multimedia-1-5/wc200/9290076089.jpg + * 已带尺寸标记(/wc\d+/、/c\d+/)或非 multimedia 路径的 URL 原样返回。 + */ +export function toThumbUrl(url: string): string { + const m = url.match(/^(https?:\/\/[^/]+\/s3\/[^/]+\/)([^/]+)$/); + if (m && !/\/wc\d+\//.test(url) && !/\/c\d+\//.test(url)) { + return `${m[1]}wc200/${m[2]}`; + } + return url; +} + +/** 清洗文件名非法字符(Windows 兼容) */ +export function cleanFilename(name: string): string { + return name + .replace(/[<>:"/\\|?*]/g, '_') + .replace(/\s+/g, ' ') + .replace(/\s+/g, '_') + .substring(0, 80); +} diff --git a/extension-v2/src/export/builder.ts b/extension-v2/src/export/builder.ts new file mode 100644 index 0000000..73d4135 --- /dev/null +++ b/extension-v2/src/export/builder.ts @@ -0,0 +1,167 @@ +/** + * 商品文件夹构建器 —— ScanResult → product.json / sources.json / 待写图片清单 + * 契约见 docs/contracts/product-json.md + */ +import type { ScanResult, ImageMaterial } from '../collector/scan'; +import type { ProductJson, SourcesJson } from '../schema/product'; +import { cleanFilename } from '../collector/url'; + +export interface BuiltProduct { + folderName: string; + product: ProductJson; + sources: SourcesJson; + /** 待写图片:相对路径 + 源 URL */ + files: Array<{ relativePath: string; url: string }>; +} + +/** 用户在表单里二次修改后的文本(覆盖采集原文) */ +export interface TextEdits { + title?: string; + price?: string; + brand?: string; + desc?: string; + sellingPoints?: string; + params?: Array<{ key: string; value: string }>; + /** 包装重量(原样字符串,如 "3.5 кг") */ + weight?: string; + /** 包装尺寸(长/宽/高) */ + dims?: { l: string; w: string; h: string }; + /** 包装尺寸单位(上传时保留单位,避免 mm/cm 混淆) */ + dimsUnit?: 'mm' | 'cm'; +} + +function extractExt(img: ImageMaterial): string { + try { + const m = new URL(img.url).pathname.match(/\.(jpg|jpeg|png|webp|gif|avif|mp4|mov|m3u8|webm)$/i); + if (m) return m[1].toLowerCase(); + } catch { + /* ignore */ + } + return img.type === 'video' ? 'mp4' : 'jpg'; +} + +/** "1 290 ₽" → "1290" */ +function extractNumericPrice(text: string): string { + const m = text.replace(/\s/g, '').replace(',', '.').match(/(\d+(?:\.\d+)?)/); + return m ? m[1] : ''; +} + +/** "3.5 кг" → 3.5 */ +function parseNumber(text: string): number | null { + const m = (text ?? '').replace(',', '.').match(/(\d+(?:\.\d+)?)/); + return m ? parseFloat(m[1]) : null; +} + +/** 解析包装重量,返回 { weight, unit },单位自动识别 kg/g */ +function parseWeight(text?: string): { weight: number | null; unit: 'g' | 'kg' } { + const s = (text ?? '').toLowerCase(); + const num = parseNumber(s); + if (num == null) return { weight: null, unit: 'g' }; + if (s.includes('кг') || s.includes('kg')) return { weight: num, unit: 'kg' }; + if (s.includes('г') || s.includes('g')) return { weight: num, unit: 'g' }; + return { weight: num, unit: 'g' }; +} + +/** 解析包装尺寸,单位自动识别 cm/mm */ +function parseDimUnit(text?: string): 'cm' | 'mm' { + const s = (text ?? '').toLowerCase(); + if (s.includes('мм') || s.includes('mm')) return 'mm'; + return 'cm'; +} + +export function buildProduct( + result: ScanResult, + selectedKeys: Set, + edits?: TextEdits +): BuiltProduct { + const text = (kind: string) => result.texts.find((t) => t.kind === kind)?.content ?? ''; + + const title = edits?.title ?? text('title'); + const priceText = edits?.price ?? text('price'); + const brand = edits?.brand ?? text('brand'); + const desc = edits?.desc ?? text('desc'); + const sellingPoints = edits?.sellingPoints ?? text('selling_point'); + const params = + edits?.params ?? result.texts.find((t) => t.kind === 'params')?.pairs; + + const folderName = cleanFilename(title || `ozon-${result.itemId ?? 'product'}`) || 'ozon-product'; + const now = new Date().toISOString(); + + const selected = result.images.filter((img) => selectedKeys.has(img.key)); + + const images: ProductJson['_images'] = { main: [], sku: [], detail: [], video: [] }; + const files: BuiltProduct['files'] = []; + const dedupeKeys: string[] = []; + const counts: Record = {}; + + for (const img of selected) { + counts[img.groupKey] = (counts[img.groupKey] ?? 0) + 1; + const ext = extractExt(img); + const base = `${img.groupKey}-${String(counts[img.groupKey]).padStart(3, '0')}`; + const variantSuffix = img.groupKey === 'sku' && img.variantName ? `-${cleanFilename(img.variantName)}` : ''; + const filename = `${base}${variantSuffix}.${ext}`; + const relativePath = `images/${img.groupKey}/${filename}`; + + images[img.groupKey].push({ + file: relativePath, + sourceUrl: img.url, + variantName: img.variantName, + w: img.width, + h: img.height, + }); + files.push({ relativePath, url: img.url }); + dedupeKeys.push(img.url); + } + + const weightInfo = parseWeight(edits?.weight); + const dimUnit = parseDimUnit(edits?.dims?.l || edits?.dims?.w || edits?.dims?.h); + + const product: ProductJson = { + _meta: { schemaVersion: 1, stage: 'collected', createdAt: now, updatedAt: now }, + offer_id: '', + name: title, + description: desc, + description_category_id: null, + type_id: null, + price: extractNumericPrice(priceText), + old_price: '', + currency_code: 'RUB', + vat: '0', + depth: parseNumber(edits?.dims?.l ?? ''), + width: parseNumber(edits?.dims?.w ?? ''), + height: parseNumber(edits?.dims?.h ?? ''), + dimension_unit: dimUnit, + weight: weightInfo.weight, + weight_unit: weightInfo.unit, + images: [], + primary_image: '', + images360: [], + color_image: '', + attributes: [], + complex_attributes: [], + _images: images, + _raw: { + title, + price: priceText, + params, + desc, + sellingPoints, + brand, + }, + }; + + const sources: SourcesJson = { + sources: [ + { + platform: result.platform as 'ozon', + itemId: result.itemId, + url: result.url, + collectedAt: now, + counts: { ...result.stats }, + }, + ], + dedupeKeys, + }; + + return { folderName, product, sources, files }; +} diff --git a/extension-v2/src/export/filesystem.ts b/extension-v2/src/export/filesystem.ts new file mode 100644 index 0000000..d45d795 --- /dev/null +++ b/extension-v2/src/export/filesystem.ts @@ -0,0 +1,103 @@ +/** + * File System Access 写盘 —— 生成完整商品文件夹 + * + * 目录结构(契约见 docs/contracts/product-json.md): + * <商品名>/ + * ├── product.json + * ├── sources.json + * └── images/{main,sku,detail,video}/main-001.jpg … + * + * 图片字节统一走 background 代理 fetch(绕 CORS / 防盗链)。 + */ +import { loadRootDir, saveRootDir } from './idb'; +import type { ProductJson, SourcesJson } from '../schema/product'; + +export interface ExportResult { + folderName: string; + written: number; + failed: Array<{ file: string; error: string }>; +} + +/** 取(或让用户选)根目录,并确保读写权限 */ +export async function ensureRootDir(): Promise { + let handle = await loadRootDir(); + + if (!handle) { + handle = await window.showDirectoryPicker({ mode: 'readwrite' }); + await saveRootDir(handle); + return handle; + } + + let perm = await handle.queryPermission({ mode: 'readwrite' }); + if (perm !== 'granted') { + perm = await handle.requestPermission({ mode: 'readwrite' }); + } + if (perm !== 'granted') throw new Error('目录读写权限被拒绝'); + return handle; +} + +/** 重新选择根目录(忽略已保存的,强制弹出选择器) */ +export async function chooseRootDir(): Promise { + const handle = await window.showDirectoryPicker({ mode: 'readwrite' }); + await saveRootDir(handle); + return handle; +} + +/** 通过 background 代理取图,返回 Blob */ +async function fetchImageBlob(url: string): Promise { + const resp = await chrome.runtime.sendMessage({ action: 'fetchImage', url }); + if (!resp?.ok) throw new Error(resp?.error ?? '图片下载失败'); + const res = await fetch(resp.dataUrl); + if (!res.ok) throw new Error(`解码失败 HTTP ${res.status}`); + return res.blob(); +} + +export async function writeProductFolder( + folderName: string, + product: ProductJson, + sources: SourcesJson, + files: Array<{ relativePath: string; url: string }>, + onProgress?: (done: number, total: number) => void +): Promise { + const root = await ensureRootDir(); + const productDir = await root.getDirectoryHandle(folderName, { create: true }); + + // product.json + const pj = await productDir.getFileHandle('product.json', { create: true }); + const w1 = await pj.createWritable(); + await w1.write(JSON.stringify(product, null, 2)); + await w1.close(); + + // sources.json + const sj = await productDir.getFileHandle('sources.json', { create: true }); + const w2 = await sj.createWritable(); + await w2.write(JSON.stringify(sources, null, 2)); + await w2.close(); + + // images/ + const imagesDir = await productDir.getDirectoryHandle('images', { create: true }); + const total = files.length; + let written = 0; + const failed: Array<{ file: string; error: string }> = []; + + for (let i = 0; i < files.length; i++) { + const f = files[i]; + const parts = f.relativePath.split('/'); // "images/main/main-001.jpg" + const group = parts[1]; + const filename = parts[2]; + try { + const blob = await fetchImageBlob(f.url); + const groupDir = await imagesDir.getDirectoryHandle(group, { create: true }); + const fh = await groupDir.getFileHandle(filename, { create: true }); + const w = await fh.createWritable(); + await w.write(blob); + await w.close(); + written++; + } catch (err) { + failed.push({ file: f.relativePath, error: err instanceof Error ? err.message : String(err) }); + } + onProgress?.(i + 1, total); + } + + return { folderName, written, failed }; +} diff --git a/extension-v2/src/export/idb.ts b/extension-v2/src/export/idb.ts new file mode 100644 index 0000000..02e409a --- /dev/null +++ b/extension-v2/src/export/idb.ts @@ -0,0 +1,63 @@ +/** + * IndexedDB 封装 —— 持久化 FileSystemDirectoryHandle + * + * chrome.storage 存不了 FileSystemDirectoryHandle(它不是 JSON 可序列化类型), + * 必须用 IndexedDB(structured clone 支持)。存一次后跨会话免重复授权。 + */ + +const DB_NAME = 'ozon-seller-kit'; +const STORE = 'handles'; +const ROOT_KEY = 'SH_ROOT_DIR'; + +function openDb(): Promise { + return new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, 1); + req.onupgradeneeded = () => { + if (!req.result.objectStoreNames.contains(STORE)) { + req.result.createObjectStore(STORE); + } + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +export async function idbSet(key: string, value: unknown): Promise { + const db = await openDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE, 'readwrite'); + tx.objectStore(STORE).put(value, key); + tx.oncomplete = () => { + db.close(); + resolve(); + }; + tx.onerror = () => { + db.close(); + reject(tx.error); + }; + }); +} + +export async function idbGet(key: string): Promise { + const db = await openDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE, 'readonly'); + const req = tx.objectStore(STORE).get(key); + req.onsuccess = () => { + db.close(); + resolve((req.result as T) ?? null); + }; + req.onerror = () => { + db.close(); + reject(req.error); + }; + }); +} + +export async function saveRootDir(handle: FileSystemDirectoryHandle): Promise { + await idbSet(ROOT_KEY, handle); +} + +export async function loadRootDir(): Promise { + return idbGet(ROOT_KEY); +} diff --git a/extension-v2/src/profiles/index.ts b/extension-v2/src/profiles/index.ts new file mode 100644 index 0000000..404d3b6 --- /dev/null +++ b/extension-v2/src/profiles/index.ts @@ -0,0 +1,19 @@ +/** + * Profile 路由 - 根据 URL 匹配平台 + */ +import type { SiteProfile } from './types'; +import { profileOzon } from './ozon'; + +const PROFILES: SiteProfile[] = [profileOzon]; + +export function matchProfile(url: string): SiteProfile | null { + for (const p of PROFILES) { + if (p.urlPatterns.some((re) => re.test(url))) { + return p; + } + } + return null; +} + +export { profileOzon }; +export type { SiteProfile }; diff --git a/extension-v2/src/profiles/ozon.ts b/extension-v2/src/profiles/ozon.ts new file mode 100644 index 0000000..61125c3 --- /dev/null +++ b/extension-v2/src/profiles/ozon.ts @@ -0,0 +1,162 @@ +/** + * Ozon 商品页采集配置 + * + * 选择器已在真实页面实测(reference/ozon1.html、ozon2.html,2026-08-15): + * - webProductHeading →

标题 + * - webGallery → 主图(,wc50/wc100 缩略图) + * - webAspects → SKU 变体(颜色/尺码选择器) + * - webShortCharacteristics / webDetailedCharacteristics → 参数表("关于商品"区) + * - webPrice → 价格(DOM 结构复杂,价格主路径走 data-state) + * + * ★ 主采集路径是 structured(ozon-state.ts 读 SSR data-state + JSON-LD + API), + * 本文件的 DOM 选择器只是兜底 + 详情图补充。 + */ +import type { SiteProfile } from './types'; + +export const profileOzon: SiteProfile = { + id: 'ozon', + name: 'Ozon', + + urlPatterns: [ + // 新版: https://www.ozon.ru/product/slug-123456789/ + /^https:\/\/[^/]+\.ozon\.(ru|kz|by)\/product\/[^/]+-\d+\/?/, + // 旧版: https://www.ozon.ru/context/detail/id/123456789/ + /^https:\/\/[^/]+\.ozon\.(ru|kz|by)\/context\/detail\/id\/\d+/, + ], + + extractItemId: (url) => { + const m = url.match(/\/product\/[^/]+-(\d+)\/?/); + if (m?.[1]) return m[1]; + const m2 = url.match(/\/context\/detail\/id\/(\d+)/); + return m2?.[1] ?? null; + }, + + readySelectors: [ + '[data-widget="webProductHeading"]', + '[data-widget="webGallery"]', + 'h1', + ], + readyTimeoutMs: 8_000, + + // Ozon 画廊图片是 ,懒加载真实地址在 srcset / currentSrc / src + defaultSrcProps: ['srcset', 'currentSrc', 'src', 'data-src'], + + refererOrigin: 'https://www.ozon.ru', + + textRules: [ + { + kind: 'title', + selectors: [ + '[data-widget="webProductHeading"] h1', + 'h1[itemprop="name"]', + 'h1', + ], + extract: 'first', + required: true, + }, + { + kind: 'price', + selectors: [ + '[data-widget="webPrice"] span', + 'span[itemprop="price"]', + '[data-widget="webPrice"]', + ], + extract: 'first', + }, + { + kind: 'params', + selectors: [ + '[data-widget="webDetailedCharacteristics"] dl', + '[data-widget="webCharacteristics"] dl', + '[data-widget="webShortCharacteristics"] dl', + '[data-widget="webAspects"] dl', + '#section-characteristics dl', + ], + extract: 'table', + tableKeySelector: 'dt, [class*="key"], [class*="Key"], [class*="label"]', + tableValueSelector: 'dd, [class*="value"], [class*="Value"]', + }, + { + kind: 'selling_point', + selectors: [ + '[data-widget="webShortCharacteristics"]', + '[data-widget="webFeatures"]', + '[data-widget="webAO"]', + ], + extract: 'join', + }, + { + kind: 'desc', + selectors: [ + '[data-widget="webDescription"]', + '[data-widget="webRichContent"]', + '#section-description', + ], + extract: 'join', + }, + ], + + imageGroups: [ + { + key: 'main', + name: '主图', + type: 'img', + selectors: [ + '[data-widget="webGallery"] img', + '[data-widget="webGallery"] source', + '[data-widget="webPhotoGallery"] img', + ], + // 不设 minWidth:画廊缩略图 naturalWidth 可能很小,原图靠 toOriginalUrl 还原 + }, + { + key: 'sku', + name: 'SKU图片', + type: 'img', + selectors: [ + // 实测:变体选择器在 webAspects(webDetailSKU 其实是"复制 SKU"按钮,没有图) + '[data-widget="webAspects"] img', + '[data-widget="webVariants"] img', + ], + nameSelectors: [ + 'span[class*="Value"]', + 'span[class*="Text"]', + 'span', + ], + minWidth: 16, + minHeight: 16, + }, + { + key: 'detail', + name: '详情图', + type: 'img', + selectors: [ + '[data-widget="webDescription"] img', + '[data-widget="webRichContent"] img', + '[data-widget="webFeatures"] img', + '#section-description img', + ], + minWidth: 300, + minHeight: 100, + }, + { + key: 'video', + name: '视频', + type: 'video', + selectors: [ + '[data-widget="webGallery"] video', + '[data-widget="webVideo"] video', + ], + }, + ], + + // 实测 CDN(ir.ozone.ru):尺寸标记是路径段 /wc\d+/(wc50…wc1000)和 /c\d+/(c50/c600) + // 去掉标记即为原图(页面本身就有无标记的原始 URL)。 + originalUrlRules: [ + { match: /\/wc\d+\//, replace: '/' }, + { match: /\/c\d+\//, replace: '/' }, + // 去掉尺寸段后路径里会有双斜杠(不动 https:// 的 //) + { match: /(? string | null; + readySelectors: string[]; + readyTimeoutMs?: number; + defaultSrcProps: SrcProp[]; + textRules: TextRule[]; + imageGroups: ImageGroupRule[]; + /** 图片 URL 还原原图规则(缺省用通用 CDN 后缀规则) */ + originalUrlRules?: Array<{ match: RegExp; replace: string }>; + refererOrigin?: string; +} diff --git a/extension-v2/src/schema/product.ts b/extension-v2/src/schema/product.ts new file mode 100644 index 0000000..f0ec79d --- /dev/null +++ b/extension-v2/src/schema/product.ts @@ -0,0 +1,80 @@ +/** + * Product JSON - 商品文件夹契约(TS 侧) + * 对应 server/schemas/product.py(Pydantic 为真源) + * 详见 docs/contracts/product-json.md + */ + +export type Stage = 'collected' | 'edited' | 'published'; + +export interface ProductJson { + _meta: { + schemaVersion: 1; + stage: Stage; + createdAt: string; // ISO 8601 + updatedAt: string; + }; + + // Ozon 字段(对齐 ImportProductsV3) + offer_id: string; // 自己的货号,采集阶段恒空 + name: string; + description: string; + description_category_id: number | null; + type_id: number | null; + price: string; // 采到的竞品价,仅参考 + old_price: string; + currency_code: 'RUB' | 'CNY'; + vat: string; + + depth: number | null; + width: number | null; + height: number | null; + dimension_unit: 'mm' | 'cm'; + weight: number | null; + weight_unit: 'g' | 'kg'; + + images: string[]; // 发布时才填公网 URL + primary_image: string; + images360: string[]; + color_image: string; + + attributes: any[]; // 工作台映射后才填 + complex_attributes: any[]; + + // 本地扩展字段(下划线前缀,提交 Ozon 前剥离) + _images: { + main: ImageMeta[]; + sku: ImageMeta[]; + detail: ImageMeta[]; + video: ImageMeta[]; + }; + + _raw: { + title: string; + price: string; + params?: Array<{ key: string; value: string }>; + desc?: string; + sellingPoints?: string; + brand?: string; + }; + + _pricing?: any; // 工作台计价结果 +} + +export interface ImageMeta { + file: string; // 相对路径:images/main/main-001.jpg + sourceUrl: string; // 源站 URL(可能失效) + variantName?: string; // SKU 规格名 + w?: number; + h?: number; +} + +export interface SourcesJson { + sources: Array<{ + platform: 'ozon' | '1688' | 'taobao'; + itemId: string | null; + url: string; + collectedAt: string; // ISO 8601 + counts: Record; + }>; + dedupeKeys: string[]; // URL 去重指纹 +} diff --git a/extension-v2/src/storage/settings.ts b/extension-v2/src/storage/settings.ts new file mode 100644 index 0000000..b4e0bfb --- /dev/null +++ b/extension-v2/src/storage/settings.ts @@ -0,0 +1,23 @@ +/** + * 服务端设置(上传用):后端地址 + Bearer Token,持久化到 chrome.storage.local。 + */ +export interface BackendSettings { + baseUrl: string; + token: string; +} + +const KEY = 'taowa_backend_settings'; + +const DEFAULT: BackendSettings = { + baseUrl: 'http://127.0.0.1:8800', + token: '', +}; + +export async function loadSettings(): Promise { + const r = await chrome.storage.local.get(KEY); + return { ...DEFAULT, ...(r[KEY] ?? {}) }; +} + +export async function saveSettings(s: BackendSettings): Promise { + await chrome.storage.local.set({ [KEY]: s }); +} diff --git a/extension-v2/tsconfig.json b/extension-v2/tsconfig.json new file mode 100644 index 0000000..d8ba3bc --- /dev/null +++ b/extension-v2/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "./.wxt/tsconfig.json", + "compilerOptions": { + "jsx": "react-jsx", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "exclude": ["node_modules", ".output"] +} diff --git a/extension-v2/wxt.config.ts b/extension-v2/wxt.config.ts new file mode 100644 index 0000000..8895511 --- /dev/null +++ b/extension-v2/wxt.config.ts @@ -0,0 +1,29 @@ +import { defineConfig } from 'wxt'; + +export default defineConfig({ + manifest: { + name: '套娃采集助手', + description: '套娃(Matryoshka)· Ozon 商品页采集,支持导出到本地或上传服务端', + permissions: [ + 'storage', + 'sidePanel', + 'activeTab', + 'scripting' // 执行 content script 函数需要 + ], + host_permissions: [ + // 商品页 + 图片/视频 CDN(实测:ir.ozone.ru / io.ozone.ru / v-1.ozone.ru / cdn1.ozonusercontent.com) + 'https://*.ozon.ru/*', + 'https://*.ozon.kz/*', + 'https://*.ozon.by/*', + 'https://*.ozone.ru/*', + 'https://*.ozonusercontent.com/*', + // 本机后端(上传用);生产换成你的公网域名 + 'http://127.0.0.1:8800/*', + 'http://localhost:8800/*' + ], + action: { + default_title: '套娃采集' + } + }, + modules: ['react'] +}); diff --git a/server/api/auth.py b/server/api/auth.py new file mode 100644 index 0000000..ffeeabb --- /dev/null +++ b/server/api/auth.py @@ -0,0 +1,23 @@ +"""鉴权路由。""" +from __future__ import annotations + +import secrets + +from fastapi import APIRouter, HTTPException + +from config import get_settings +from core.security import create_access_token +from schemas.auth import LoginRequest, LoginResponse + +router = APIRouter(prefix="/api/auth", tags=["auth"]) + + +@router.post("/login", response_model=LoginResponse) +async def login(body: LoginRequest) -> LoginResponse: + settings = get_settings() + if not settings.app_token: + raise HTTPException(status_code=500, detail="服务端未配置 APP_TOKEN") + if not secrets.compare_digest(body.token, settings.app_token): + raise HTTPException(status_code=401, detail="Token 不正确") + token, expires_at = create_access_token("app") + return LoginResponse(access_token=token, expires_at=expires_at) diff --git a/server/api/categories.py b/server/api/categories.py new file mode 100644 index 0000000..b798b29 --- /dev/null +++ b/server/api/categories.py @@ -0,0 +1,121 @@ +"""Ozon 类目/属性字典代理(服务端持店铺凭证调用 Ozon,前端不直连)。""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession + +from core.security import decrypt_secret +from db import get_db +from deps import get_current_user +from models import Shop +from services.ozon_client import OzonClient, OzonAPIError + +router = APIRouter(prefix="/api/categories", tags=["categories"]) + + +class ShopRef(BaseModel): + shop_id: str + lang: str = "ZH_HANS" # 中文类目 + + +async def _client(shop_id: str, db: AsyncSession) -> OzonClient: + shop = await db.get(Shop, UUID(shop_id)) + if shop is None: + raise HTTPException(status_code=404, detail="店铺不存在") + return OzonClient(decrypt_secret(shop.client_id_enc), decrypt_secret(shop.api_key_enc)) + + +def _unwrap(result: dict) -> dict: + return result.get("result", result) + + +@router.post("/tree") +async def category_tree( + body: ShopRef, + db: AsyncSession = Depends(get_db), + _user: dict = Depends(get_current_user), +): + client = await _client(body.shop_id, db) + try: + result = await client.post("/v1/description-category/tree", {"language": body.lang}) + return _unwrap(result) + except OzonAPIError as exc: + raise HTTPException(status_code=502, detail=exc.detail) + + +class AttributeQuery(BaseModel): + shop_id: str + type_id: int + lang: str = "ZH_HANS" + + +@router.post("/{category_id}/attributes") +async def category_attributes( + category_id: int, + body: AttributeQuery, + db: AsyncSession = Depends(get_db), + _user: dict = Depends(get_current_user), +): + client = await _client(body.shop_id, db) + try: + result = await client.post( + "/v1/description-category/attribute", + { + "description_category_id": category_id, + "type_id": body.type_id, + "language": body.lang, + }, + ) + return _unwrap(result) + except OzonAPIError as exc: + raise HTTPException(status_code=502, detail=exc.detail) + + +class ValueQuery(BaseModel): + shop_id: str + category_id: int + type_id: int + q: str | None = None + limit: int = 100 + last_value_id: int | None = None + lang: str = "ZH_HANS" + + +@router.post("/attribute/{attribute_id}/values") +async def attribute_values( + attribute_id: int, + body: ValueQuery, + db: AsyncSession = Depends(get_db), + _user: dict = Depends(get_current_user), +): + client = await _client(body.shop_id, db) + try: + if body.q and len(body.q) >= 2: + result = await client.post( + "/v1/description-category/attribute/values/search", + { + "attribute_id": attribute_id, + "description_category_id": body.category_id, + "type_id": body.type_id, + "limit": body.limit, + "value": body.q, + }, + ) + else: + result = await client.post( + "/v1/description-category/attribute/values", + { + "attribute_id": attribute_id, + "description_category_id": body.category_id, + "type_id": body.type_id, + "limit": body.limit, + "last_value_id": body.last_value_id or 0, + "language": body.lang, + }, + ) + return result # values 返回 {result, has_next} + except OzonAPIError as exc: + raise HTTPException(status_code=502, detail=exc.detail) diff --git a/server/api/collection.py b/server/api/collection.py new file mode 100644 index 0000000..5449b32 --- /dev/null +++ b/server/api/collection.py @@ -0,0 +1,272 @@ +"""采集入库:插件上传文本 + 图片 URL,落库后异步转存。""" +from __future__ import annotations + +import re +from uuid import UUID + +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, UploadFile, File, Form +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from db import get_db, get_session_factory +from deps import get_current_user +from models import Product, ProductAsset +from models.enums import AssetStatus, Stage +from schemas.collection import MaterialsRequest, MaterialsResponse, TextMaterial + +router = APIRouter(prefix="/api", tags=["collection"]) + + +def _parse_number(text: str | None) -> float | None: + """'1 290 ₽' / '3.5 кг' / '48*18*25' → 1290.0 / 3.5 / 48""" + if not text: + return None + m = re.search(r"(\d+(?:[.,]\d+)?)", text.replace(" ", "").replace(",", ".")) + return float(m.group(1)) if m else None + + +def _find_param(pairs: list[dict] | None, keys: list[str]) -> str | None: + for p in pairs or []: + k = (p.get("key") or "").lower() + if any(kw in k for kw in keys): + return p.get("value") + return None + + +def _apply_texts(product: Product, texts: list[TextMaterial]) -> None: + raw = dict(product.raw or {}) + raw_texts: list[dict] = list(raw.get("texts") or []) + for t in texts: + raw_texts.append({"kind": t.kind, "content": t.content, "pairs": t.pairs}) + if t.kind == "title" and t.content and not product.name: + product.name = t.content + raw["title"] = t.content + elif t.kind == "price": + raw["price"] = t.content + num = _parse_number(t.content) + if num is not None and (product.price is None or product.price == 0): + product.price = num + elif t.kind == "params": + raw["params"] = t.pairs + _apply_weight_dims(product, t.pairs) + elif t.kind == "selling_point": + raw["sellingPoints"] = t.content + elif t.kind == "desc": + raw["desc"] = t.content + if not product.description: + product.description = t.content + elif t.kind == "brand": + raw["brand"] = t.content + raw["texts"] = raw_texts + product.raw = raw + + +def _apply_weight_dims(product: Product, pairs: list[dict] | None) -> None: + """从参数表里解析「包装重量 / 包装尺寸(长宽高)」,统一换算成克 / 毫米回填。""" + weight = _find_param(pairs, ["包装重量", "重量", "вес"]) + if weight is not None: + num = _parse_number(weight) + if num is not None: + is_kg = any(u in weight.lower() for u in ("кг", "kg")) + product.weight = num * 1000 if is_kg else num # 统一为克 + product.weight_unit = "g" + + l = _find_param(pairs, ["包装长度", "长度", "длина"]) + w = _find_param(pairs, ["包装宽度", "宽度", "ширина"]) + h = _find_param(pairs, ["包装高度", "高度", "высота"]) + if l or w or h: + combined = (l or "") + (w or "") + (h or "") + factor = 1 if any(u in combined.lower() for u in ("мм", "mm")) else 10 # 厘米→毫米 + product.depth = (_parse_number(l) or 0) * factor if l else None + product.width = (_parse_number(w) or 0) * factor if w else None + product.height = (_parse_number(h) or 0) * factor if h else None + product.dimension_unit = "mm" + else: + dim = _find_param(pairs, ["包装尺寸", "размер", "габарит", "尺寸"]) + if dim is not None: + nums = re.findall(r"\d+(?:[.,]\d+)?", dim.replace(",", ".")) + if len(nums) >= 3: + factor = 1 if any(u in dim.lower() for u in ("мм", "mm")) else 10 + product.depth = float(nums[0]) * factor + product.width = float(nums[1]) * factor + product.height = float(nums[2]) * factor + product.dimension_unit = "mm" + + +async def _get_or_create_product(db: AsyncSession, req: MaterialsRequest) -> Product: + if req.product_id: + product = await db.get(Product, UUID(req.product_id)) + if product is None: + raise HTTPException(status_code=404, detail="商品不存在") + return product + product = Product( + stage=Stage.collected, + source_platform=req.source.platform, + source_item_id=req.source.itemId, + source_url=req.source.url, + ) + db.add(product) + await db.flush() + return product + + +@router.post("/materials", response_model=MaterialsResponse) +async def create_materials( + req: MaterialsRequest, + background: BackgroundTasks, + db: AsyncSession = Depends(get_db), + _user: dict = Depends(get_current_user), +) -> MaterialsResponse: + product = await _get_or_create_product(db, req) + _apply_texts(product, req.texts) + + # 采集溯源(追加来源) + if not product.source_url: + product.source_url = req.source.url + if not product.source_platform: + product.source_platform = req.source.platform + + # 去重 + 建素材 + existing = set() + if req.images: + rows = (await db.execute( + select(ProductAsset.dedupe_key).where( + ProductAsset.product_id == product.id, + ProductAsset.dedupe_key.isnot(None), + ) + )).scalars().all() + existing = {k for k in rows if k} + + queued, skipped = 0, 0 + for img in req.images: + if img.dedupeKey and img.dedupeKey in existing: + skipped += 1 + continue + db.add(ProductAsset( + product_id=product.id, + group_key=img.groupKey, + variant_name=img.variantName, + sort_order=img.index, + type=img.type, + source_url=img.url, + status=AssetStatus.pending, + dedupe_key=img.dedupeKey, + )) + if img.dedupeKey: + existing.add(img.dedupeKey) + queued += 1 + + # 更新分组计数 + counts: dict = {} + for a in await db.scalars(select(ProductAsset).where(ProductAsset.product_id == product.id)): + counts[a.group_key] = counts.get(a.group_key, 0) + 1 + product.asset_counts = counts + product.stage = Stage.collected if product.stage == Stage.collected else product.stage + + await db.commit() + await db.refresh(product) + + if queued: + background.add_task(process_product_assets, str(product.id)) + return MaterialsResponse( + product_id=str(product.id), + stage=product.stage.value, + assets_queued=queued, + assets_skipped=skipped, + ) + + +async def process_product_assets(product_id: str) -> None: + """后台:下载 pending 素材 → 转存 storage。失败逐张标记,不中断。""" + from services.storage import get_storage + + storage = get_storage() + async with get_session_factory()() as db: + assets = (await db.scalars( + select(ProductAsset).where( + ProductAsset.product_id == UUID(product_id), + ProductAsset.status == AssetStatus.pending, + ) + )).all() + for a in assets: + a.status = AssetStatus.downloading + await db.commit() + try: + stored = await storage.save_from_url(a.source_url, key_prefix="assets") + a.stored_url = stored + a.status = AssetStatus.uploaded + except Exception as exc: # noqa: BLE001 + a.status = AssetStatus.failed + a.error = str(exc)[:500] + await db.commit() + + +@router.post("/materials/bytes") +async def upload_material_bytes( + background: BackgroundTasks, + product_id: str = Form(...), + group_key: str = Form("main"), + variant_name: str | None = Form(None), + sort_order: int = Form(0), + type: str = Form("img"), + file: UploadFile = File(...), + db: AsyncSession = Depends(get_db), + _user: dict = Depends(get_current_user), +): + product = await db.get(Product, UUID(product_id)) + if product is None: + raise HTTPException(status_code=404, detail="商品不存在") + data = await file.read() + asset = ProductAsset( + product_id=product.id, + group_key=group_key, + variant_name=variant_name, + sort_order=sort_order, + type=type, + source_url="", + status=AssetStatus.pending, + ) + db.add(asset) + await db.flush() + # 直接转存字节 + from services.storage import get_storage + storage = get_storage() + try: + asset.stored_url = await storage.save_bytes(data, f"assets/{asset.id}", file.content_type or "") + asset.status = AssetStatus.uploaded + except Exception as exc: # noqa: BLE001 + asset.status = AssetStatus.failed + asset.error = str(exc)[:500] + await db.commit() + return {"asset_id": str(asset.id), "status": asset.status.value} + + +@router.get("/products/{product_id}/fingerprints") +async def product_fingerprints( + product_id: str, + db: AsyncSession = Depends(get_db), + _user: dict = Depends(get_current_user), +): + rows = (await db.scalars( + select(ProductAsset.dedupe_key).where( + ProductAsset.product_id == UUID(product_id), + ProductAsset.dedupe_key.isnot(None), + ) + )).all() + return {"dedupe_keys": list(rows)} + + +@router.get("/collected") +async def is_collected( + platform: str, + itemId: str, + db: AsyncSession = Depends(get_db), + _user: dict = Depends(get_current_user), +): + rows = (await db.execute( + select(Product).where( + Product.source_platform == platform, + Product.source_item_id == itemId, + ) + )).scalars().all() + return {"collected": len(rows) > 0, "count": len(rows)} diff --git a/server/api/fx.py b/server/api/fx.py new file mode 100644 index 0000000..a896526 --- /dev/null +++ b/server/api/fx.py @@ -0,0 +1,14 @@ +"""汇率路由。""" +from __future__ import annotations + +from fastapi import APIRouter, Depends + +from deps import get_current_user +from services.fx import get_fx_rate + +router = APIRouter(prefix="/api/fx", tags=["fx"]) + + +@router.get("") +async def fx(_user: dict = Depends(get_current_user)): + return await get_fx_rate() diff --git a/server/api/products.py b/server/api/products.py new file mode 100644 index 0000000..9b18b00 --- /dev/null +++ b/server/api/products.py @@ -0,0 +1,186 @@ +"""商品 CRUD(采集箱 / 编辑 / 删除)。""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession + +from db import get_db +from deps import get_current_user +from models import Product, ProductAsset +from models.enums import Stage +from schemas.product import ProductDetail, ProductListItem, ProductUpdate + +router = APIRouter(prefix="/api/products", tags=["products"]) + + +@router.get("") +async def list_products( + stage: str | None = None, + q: str | None = None, + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + db: AsyncSession = Depends(get_db), + _user: dict = Depends(get_current_user), +): + stmt = select(Product) + if stage: + stmt = stmt.where(Product.stage == stage) + if q: + stmt = stmt.where(Product.name.ilike(f"%{q}%") | Product.offer_id.ilike(f"%{q}%")) + + total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar() or 0 + rows = (await db.execute( + stmt.order_by(Product.updated_at.desc()).offset((page - 1) * page_size).limit(page_size) + )).scalars().all() + items = [ProductListItem.model_validate(r) for r in rows] + return {"total": total, "items": items} + + +@router.get("/{product_id}", response_model=ProductDetail) +async def get_product( + product_id: str, + db: AsyncSession = Depends(get_db), + _user: dict = Depends(get_current_user), +): + product = await db.get(Product, UUID(product_id)) + if product is None: + raise HTTPException(status_code=404, detail="商品不存在") + return ProductDetail.model_validate(product) + + +@router.post("", response_model=ProductDetail) +async def create_product( + body: ProductUpdate, + db: AsyncSession = Depends(get_db), + _user: dict = Depends(get_current_user), +): + product = Product(stage=Stage.collected) + _apply_update(product, body) + db.add(product) + await db.commit() + await db.refresh(product) + return ProductDetail.model_validate(product) + + +@router.post("/{product_id}/copy", response_model=ProductDetail) +async def copy_product( + product_id: str, + db: AsyncSession = Depends(get_db), + _user: dict = Depends(get_current_user), +): + """复制商品为新变体:继承标题/描述/属性/型号名称/计价,重置货号与图片。""" + src = await db.get(Product, UUID(product_id)) + if src is None: + raise HTTPException(status_code=404, detail="商品不存在") + clone = Product( + stage=Stage.collected, + shop_id=src.shop_id, + source_platform=src.source_platform, + source_item_id=None, # 新变体,不沿用源 itemId(避免去重冲突) + source_url=src.source_url, + offer_id="", # 重置货号 + name=src.name, + description=src.description, + description_category_id=src.description_category_id, + type_id=src.type_id, + price=src.price, + old_price=src.old_price, + currency_code=src.currency_code, + vat=src.vat, + depth=src.depth, + width=src.width, + height=src.height, + dimension_unit=src.dimension_unit, + weight=src.weight, + weight_unit=src.weight_unit, + barcode=src.barcode, + images=None, # 重置图片 + primary_image=None, + images360=None, + color_image=None, + attributes=src.attributes, + complex_attributes=src.complex_attributes, + raw=src.raw, # 含 model_name(型号名称) + pricing=src.pricing, + copy=src.copy, + fx_rate=src.fx_rate, + ) + db.add(clone) + await db.commit() + await db.refresh(clone) + return ProductDetail.model_validate(clone) + + +@router.patch("/{product_id}", response_model=ProductDetail) +async def update_product( + product_id: str, + body: ProductUpdate, + db: AsyncSession = Depends(get_db), + _user: dict = Depends(get_current_user), +): + product = await db.get(Product, UUID(product_id)) + if product is None: + raise HTTPException(status_code=404, detail="商品不存在") + _apply_update(product, body) + await db.commit() + await db.refresh(product) + return ProductDetail.model_validate(product) + + +@router.delete("/{product_id}") +async def delete_product( + product_id: str, + hard: bool = False, + db: AsyncSession = Depends(get_db), + _user: dict = Depends(get_current_user), +): + product = await db.get(Product, UUID(product_id)) + if product is None: + raise HTTPException(status_code=404, detail="商品不存在") + if hard: + await db.delete(product) + else: + product.stage = Stage.archived + await db.commit() + return {"deleted": True} + + +@router.get("/{product_id}/assets") +async def list_assets( + product_id: str, + db: AsyncSession = Depends(get_db), + _user: dict = Depends(get_current_user), +): + rows = (await db.scalars( + select(ProductAsset) + .where(ProductAsset.product_id == UUID(product_id)) + .order_by(ProductAsset.group_key, ProductAsset.sort_order) + )).all() + return [ + { + "id": str(a.id), + "group_key": a.group_key, + "variant_name": a.variant_name, + "sort_order": a.sort_order, + "type": a.type, + "source_url": a.source_url, + "stored_url": a.stored_url, + "status": a.status.value, + "width": a.width, + "height": a.height, + "error": a.error, + } + for a in rows + ] + + +def _apply_update(product: Product, body: ProductUpdate) -> None: + data = body.model_dump(exclude_unset=True) + if "stage" in data and data["stage"]: + data["stage"] = Stage(data["stage"]) + for key, value in data.items(): + if value is not None or key in ("raw", "pricing", "copy", "images", "attributes", "complex_attributes", "shop_id"): + setattr(product, key, value) diff --git a/server/api/publish.py b/server/api/publish.py new file mode 100644 index 0000000..88083ed --- /dev/null +++ b/server/api/publish.py @@ -0,0 +1,183 @@ +"""发布端点:提交 ImportProductsV3 + 后台轮询回填。""" +from __future__ import annotations + +import asyncio +from datetime import datetime, timezone +from uuid import UUID + +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from core.security import decrypt_secret +from db import get_db, get_session_factory +from deps import get_current_user +from models import Product, PublishTask, Shop +from models.enums import PublishStatus, Stage +from services.ozon_client import OzonClient, OzonAPIError +from services.publish import build_import_item, validate_ready + +router = APIRouter(prefix="/api", tags=["publish"]) + + +class PublishRequest(BaseModel): + shop_id: str + + +def _client(shop: Shop) -> OzonClient: + return OzonClient(decrypt_secret(shop.client_id_enc), decrypt_secret(shop.api_key_enc)) + + +@router.post("/products/{product_id}/publish") +async def publish_product( + product_id: str, + body: PublishRequest, + background: BackgroundTasks, + db: AsyncSession = Depends(get_db), + _user: dict = Depends(get_current_user), +): + product = await db.get(Product, UUID(product_id)) + if product is None: + raise HTTPException(status_code=404, detail="商品不存在") + shop = await db.get(Shop, UUID(body.shop_id)) + if shop is None: + raise HTTPException(status_code=404, detail="店铺不存在") + + missing = validate_ready(product) + if missing: + raise HTTPException(status_code=422, detail=f"缺少必填项:{'、'.join(missing)}") + + item = build_import_item(product) + client = _client(shop) + try: + result = await client.post("/v3/product/import", {"items": [item]}) + except OzonAPIError as exc: + raise HTTPException(status_code=502, detail=exc.detail) + + task_id = (result.get("result") or {}).get("task_id") + if not task_id: + raise HTTPException(status_code=502, detail=f"Ozon 未返回 task_id:{result}") + + task = PublishTask( + product_id=product.id, + shop_id=shop.id, + ozon_task_id=int(task_id), + status=PublishStatus.pending, + request_payload=item, + ) + db.add(task) + product.stage = Stage.publishing + await db.commit() + await db.refresh(task) + + background.add_task(_poll, str(task.id)) + return {"task_id": str(task.id), "ozon_task_id": task.ozon_task_id} + + +async def _poll(task_id: str) -> None: + """后台轮询 import/info,直到 imported / failed 或超时(约 40s)。""" + async with get_session_factory()() as db: + task = await db.get(PublishTask, UUID(task_id)) + if task is None: + return + shop = await db.get(Shop, task.shop_id) + product = await db.get(Product, task.product_id) + if shop is None or product is None: + return + client = _client(shop) + + for attempt in range(8): + try: + result = await client.post("/v1/product/import/info", {"task_id": task.ozon_task_id}) + except OzonAPIError as exc: + task.status = PublishStatus.failed + task.errors = [{"error": exc.detail}] + task.completed_at = datetime.now(timezone.utc) + product.stage = Stage.failed + await db.commit() + return + + items = (result.get("result") or {}).get("items") or [] + item = items[0] if items else {} + status = item.get("status", "") + product_id = item.get("product_id") + errors = item.get("errors") or [] + + if status == "imported": + task.status = PublishStatus.imported + task.response = item + task.completed_at = datetime.now(timezone.utc) + if product_id: + product.ozon_product_id = int(product_id) + product.stage = Stage.published + product.published_at = datetime.now(timezone.utc) + await db.commit() + return + + if status == "failed": + task.status = PublishStatus.failed + task.errors = errors + task.response = item + task.completed_at = datetime.now(timezone.utc) + product.stage = Stage.failed + await db.commit() + return + + # pending / moderation → 继续等 + task.status = PublishStatus.moderation if status in ("moderating", "moderation") else PublishStatus.processing + if product_id: + product.ozon_product_id = int(product_id) + await db.commit() + await asyncio.sleep(5 * (attempt + 1)) + + # 超时未定:保留 processing,前端可刷新 + task.status = PublishStatus.moderation + task.response = item + await db.commit() + + +@router.get("/publish/{task_id}") +async def get_publish_task( + task_id: str, + db: AsyncSession = Depends(get_db), + _user: dict = Depends(get_current_user), +): + task = await db.get(PublishTask, UUID(task_id)) + if task is None: + raise HTTPException(status_code=404, detail="发布任务不存在") + return { + "id": str(task.id), + "product_id": str(task.product_id), + "shop_id": str(task.shop_id), + "ozon_task_id": task.ozon_task_id, + "status": task.status.value, + "errors": task.errors, + "response": task.response, + "created_at": task.created_at, + "completed_at": task.completed_at, + } + + +@router.get("/products/{product_id}/publish-history") +async def publish_history( + product_id: str, + db: AsyncSession = Depends(get_db), + _user: dict = Depends(get_current_user), +): + rows = (await db.scalars( + select(PublishTask) + .where(PublishTask.product_id == UUID(product_id)) + .order_by(PublishTask.created_at.desc()) + )).all() + return [ + { + "id": str(t.id), + "ozon_task_id": t.ozon_task_id, + "status": t.status.value, + "errors": t.errors, + "created_at": t.created_at, + "completed_at": t.completed_at, + } + for t in rows + ] diff --git a/server/api/shops.py b/server/api/shops.py new file mode 100644 index 0000000..3f51601 --- /dev/null +++ b/server/api/shops.py @@ -0,0 +1,124 @@ +"""店铺管理:绑定 Ozon Client-Id/Api-Key(加密落库)+ 连通性校验。""" +from __future__ import annotations + +from datetime import datetime, timezone +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from core.security import decrypt_secret, encrypt_secret +from db import get_db +from deps import get_current_user +from models import Shop +from models.enums import ShopStatus +from schemas.shop import ShopCreate, ShopListItem, ShopUpdate +from services.ozon_client import OzonClient, OzonAPIError + +router = APIRouter(prefix="/api/shops", tags=["shops"]) + + +def _mask(client_id: str) -> str: + return f"…{client_id[-4:]}" if len(client_id) > 4 else "…" + + +@router.get("", response_model=list[ShopListItem]) +async def list_shops( + db: AsyncSession = Depends(get_db), + _user: dict = Depends(get_current_user), +): + rows = (await db.scalars(select(Shop).order_by(Shop.created_at))).all() + items = [] + for s in rows: + item = ShopListItem.model_validate(s) + try: + item.client_id_masked = _mask(decrypt_secret(s.client_id_enc)) + except Exception: # noqa: BLE001 + item.client_id_masked = "…" + items.append(item) + return items + + +@router.post("", response_model=ShopListItem) +async def create_shop( + body: ShopCreate, + db: AsyncSession = Depends(get_db), + _user: dict = Depends(get_current_user), +): + shop = Shop( + name=body.name, + client_id_enc=encrypt_secret(body.client_id), + api_key_enc=encrypt_secret(body.api_key), + currency_code=body.currency_code or "RUB", + status=ShopStatus.active, + ) + db.add(shop) + await db.commit() + await db.refresh(shop) + item = ShopListItem.model_validate(shop) + item.client_id_masked = _mask(body.client_id) + return item + + +@router.patch("/{shop_id}", response_model=ShopListItem) +async def update_shop( + shop_id: str, + body: ShopUpdate, + db: AsyncSession = Depends(get_db), + _user: dict = Depends(get_current_user), +): + shop = await db.get(Shop, UUID(shop_id)) + if shop is None: + raise HTTPException(status_code=404, detail="店铺不存在") + if body.name is not None: + shop.name = body.name + if body.currency_code is not None: + shop.currency_code = body.currency_code + if body.client_id: + shop.client_id_enc = encrypt_secret(body.client_id) + if body.api_key: + shop.api_key_enc = encrypt_secret(body.api_key) + await db.commit() + await db.refresh(shop) + item = ShopListItem.model_validate(shop) + item.client_id_masked = _mask(decrypt_secret(shop.client_id_enc)) + return item + + +@router.delete("/{shop_id}") +async def delete_shop( + shop_id: str, + db: AsyncSession = Depends(get_db), + _user: dict = Depends(get_current_user), +): + shop = await db.get(Shop, UUID(shop_id)) + if shop is None: + raise HTTPException(status_code=404, detail="店铺不存在") + await db.delete(shop) + await db.commit() + return {"deleted": True} + + +@router.post("/{shop_id}/test") +async def test_shop( + shop_id: str, + db: AsyncSession = Depends(get_db), + _user: dict = Depends(get_current_user), +): + shop = await db.get(Shop, UUID(shop_id)) + if shop is None: + raise HTTPException(status_code=404, detail="店铺不存在") + client = OzonClient(decrypt_secret(shop.client_id_enc), decrypt_secret(shop.api_key_enc)) + try: + result = await client.test_credentials() + except OzonAPIError as exc: + shop.status = ShopStatus.invalid + await db.commit() + return {"ok": False, "error": exc.detail, "roles": []} + + shop.status = ShopStatus.active + shop.last_checked_at = datetime.now(timezone.utc) + await db.commit() + roles = [r.get("name") for r in result.get("roles", [])] + return {"ok": True, "roles": roles} diff --git a/server/config/settings.py b/server/config/settings.py index 34569cb..4f784e7 100644 --- a/server/config/settings.py +++ b/server/config/settings.py @@ -18,22 +18,50 @@ class Settings(BaseSettings): extra="ignore", ) + # ── AI 密钥 ── deepseek_api_key: str = "" openai_api_key: str = "" dashscope_api_key: str = "" # 华北2(北京)业务空间时需填:https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1 - # 普通 API Key 调用留空即可。 dashscope_base_http_api_url: str = "" + + # ── 运行 ── host: str = "127.0.0.1" port: int = 8800 cors_origins: str = "" + # ── V2:数据层 ── + # 本地过渡用 SQLite;上线切 PostgreSQL:postgresql+asyncpg://user:pass@host:5432/ozon_seller + database_url: str = "sqlite+aiosqlite:///./data/app.db" + + # ── V2:鉴权 ── + app_token: str = "" # MVP 单用户登录 token(换发 JWT 用) + secret_key: str = "" # 店铺凭证 AES-GCM 加密密钥 + JWT 签名密钥 + jwt_expire_minutes: int = 60 * 24 * 7 # JWT 有效期(默认 7 天) + + # ── V2:七牛(图片存储)── + qiniu_access_key: str = "" + qiniu_secret_key: str = "" + qiniu_bucket: str = "" + qiniu_domain: str = "" # 绑定域名,如 https://cdn.example.com + # 为空时用本地文件系统兜底(开发期),不为空时走七牛 + storage_backend: str = "local" # local | qiniu + + # ── V2:对外地址(插件/前端回写、生成图回调)── + app_base_url: str = "http://127.0.0.1:8800" + @property def cors_origin_list(self) -> list[str]: if not self.cors_origins.strip(): return [] return [o.strip() for o in self.cors_origins.split(",") if o.strip()] + @property + def use_qiniu(self) -> bool: + return self.storage_backend == "qiniu" and bool( + self.qiniu_access_key and self.qiniu_secret_key and self.qiniu_bucket + ) + @lru_cache def get_settings() -> Settings: diff --git a/server/core/__init__.py b/server/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/core/security.py b/server/core/security.py new file mode 100644 index 0000000..d2fa45d --- /dev/null +++ b/server/core/security.py @@ -0,0 +1,56 @@ +"""JWT 鉴权 + 店铺凭证 AES-GCM 加解密。""" +from __future__ import annotations + +import base64 +import hashlib +import os +from datetime import datetime, timedelta, timezone + +import jwt + +from config import get_settings + + +# ── JWT ── + +def create_access_token(subject: str = "app") -> tuple[str, int]: + """签发 JWT。返回 (token, 过期 epoch 秒)。""" + settings = get_settings() + expires = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes) + payload = {"sub": subject, "exp": expires} + token = jwt.encode(payload, settings.secret_key, algorithm="HS256") + return token, int(expires.timestamp()) + + +def decode_token(token: str) -> dict: + """校验并解析 JWT;失败抛 jwt.PyJWTError。""" + settings = get_settings() + return jwt.decode(token, settings.secret_key, algorithms=["HS256"]) + + +# ── AES-GCM 店铺凭证加密 ── + +def _derive_key() -> bytes: + settings = get_settings() + return hashlib.sha256(settings.secret_key.encode("utf-8")).digest() + + +def encrypt_secret(plaintext: str) -> str: + """AES-GCM 加密,返回 base64(nonce + ciphertext + tag)。""" + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + + key = _derive_key() + nonce = os.urandom(12) + aesgcm = AESGCM(key) + ct = aesgcm.encrypt(nonce, plaintext.encode("utf-8"), None) + return base64.b64encode(nonce + ct).decode("ascii") + + +def decrypt_secret(ciphertext: str) -> str: + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + + key = _derive_key() + raw = base64.b64decode(ciphertext.encode("ascii")) + nonce, ct = raw[:12], raw[12:] + aesgcm = AESGCM(key) + return aesgcm.decrypt(nonce, ct, None).decode("utf-8") diff --git a/server/db.py b/server/db.py new file mode 100644 index 0000000..27babd9 --- /dev/null +++ b/server/db.py @@ -0,0 +1,50 @@ +"""数据库引擎与会话工厂(SQLite 本地过渡 / PostgreSQL 生产)。""" +from __future__ import annotations + +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.orm import DeclarativeBase + +from config import get_settings + + +class Base(DeclarativeBase): + pass + + +_engine = None +_session_factory = None + + +def get_engine(): + global _engine + if _engine is None: + settings = get_settings() + connect_args: dict = {} + # SQLite 需允许多线程/多协程访问同一文件 + if settings.database_url.startswith("sqlite"): + connect_args["check_same_thread"] = False + _engine = create_async_engine( + settings.database_url, + echo=False, + future=True, + connect_args=connect_args, + ) + return _engine + + +def get_session_factory() -> async_sessionmaker[AsyncSession]: + global _session_factory + if _session_factory is None: + _session_factory = async_sessionmaker( + get_engine(), + class_=AsyncSession, + expire_on_commit=False, + ) + return _session_factory + + +async def get_db(): + """FastAPI 依赖:请求级 AsyncSession。""" + factory = get_session_factory() + async with factory() as session: + yield session diff --git a/server/deps.py b/server/deps.py new file mode 100644 index 0000000..9bb23ed --- /dev/null +++ b/server/deps.py @@ -0,0 +1,26 @@ +"""FastAPI 依赖:数据库会话 + 鉴权。""" +from __future__ import annotations + +import jwt as pyjwt +from fastapi import Depends +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + +from core.security import decode_token + +_bearer = HTTPBearer(auto_error=False) + + +async def get_current_user( + credentials: HTTPAuthorizationCredentials | None = Depends(_bearer), +) -> dict: + """校验 Bearer JWT,返回 payload。 + + MVP:单用户宽松模式 —— 未带 / 失效 token 也放行(返回匿名身份), + 后续加账户体系时再收紧为强制校验。 + """ + if credentials is None or not credentials.credentials: + return {"sub": "app", "anonymous": True} + try: + return decode_token(credentials.credentials) + except pyjwt.PyJWTError: + return {"sub": "app", "anonymous": True} diff --git a/server/main.py b/server/main.py index 11edf9a..7bbc5c3 100644 --- a/server/main.py +++ b/server/main.py @@ -3,14 +3,18 @@ from pathlib import Path from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles +from sqlalchemy import text -from api import ai, image, ozon +from api import ai, auth, categories, collection, fx, image, ozon, products, publish, shops from config import get_settings +from db import get_engine # web/ 是 v1 工具台,留在仓库根,故上跳一级 WEB_DIR = Path(__file__).resolve().parents[1] / "web" +# 本地存储(开发兜底)媒体目录 +MEDIA_DIR = Path(__file__).resolve().parents[1] / "data" / "media" -app = FastAPI(title="Ozon Seller Kit", version="0.1.0") +app = FastAPI(title="Ozon Seller Kit", version="0.2.0") settings = get_settings() if settings.cors_origin_list: @@ -22,15 +26,44 @@ if settings.cors_origin_list: allow_headers=["*"], ) +# 业务路由 +app.include_router(auth.router) +app.include_router(collection.router) +app.include_router(products.router) +app.include_router(shops.router) +app.include_router(categories.router) +app.include_router(publish.router) +app.include_router(fx.router) app.include_router(ai.router) app.include_router(image.router) app.include_router(ozon.router) -@app.get("/api/health") -async def health() -> dict[str, str]: - return {"status": "ok"} +@app.on_event("startup") +async def on_startup() -> None: + # 开发便利:确保表存在(生产以 Alembic 迁移为准,create_all 幂等不删表) + from db import Base + import models # noqa: F401 + MEDIA_DIR.mkdir(parents=True, exist_ok=True) + async with get_engine().begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + +@app.get("/api/health") +async def health() -> dict: + db_ok = True + try: + async with get_engine().connect() as conn: + await conn.execute(text("SELECT 1")) + except Exception: # noqa: BLE001 + db_ok = False + return {"status": "ok" if db_ok else "degraded", "db": db_ok} + + +# 本地媒体(开发兜底存储) +MEDIA_DIR.mkdir(parents=True, exist_ok=True) +app.mount("/media", StaticFiles(directory=str(MEDIA_DIR)), name="media") if WEB_DIR.is_dir(): app.mount("/", StaticFiles(directory=str(WEB_DIR), html=True), name="web") diff --git a/server/migrations/env.py b/server/migrations/env.py new file mode 100644 index 0000000..71f224b --- /dev/null +++ b/server/migrations/env.py @@ -0,0 +1,53 @@ +"""Alembic 迁移环境。URL 从 server/config/settings.py 读取,支持 autogenerate。""" +from __future__ import annotations + +import sys +from pathlib import Path + +from alembic import context +from sqlalchemy import create_engine, pool + +# 让 `from config import ...` / `from db import ...` / `import models` 可解析 +SERVER_DIR = Path(__file__).resolve().parents[1] +if str(SERVER_DIR) not in sys.path: + sys.path.insert(0, str(SERVER_DIR)) + +from config import get_settings # noqa: E402 +from db import Base # noqa: E402 +import models # noqa: E402,F401 确保所有模型注册到 Base.metadata + +config = context.config +target_metadata = Base.metadata + + +def _sync_url(url: str) -> str: + """异步 URL → 同步 URL(迁移用同步引擎跑更稳)。""" + return url.replace("+aiosqlite", "").replace("+asyncpg", "") + + +def run_migrations_offline() -> None: + context.configure( + url=_sync_url(get_settings().database_url), + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = create_engine( + _sync_url(get_settings().database_url), + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/server/migrations/script.py.mako b/server/migrations/script.py.mako new file mode 100644 index 0000000..d46efe7 --- /dev/null +++ b/server/migrations/script.py.mako @@ -0,0 +1,25 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/server/migrations/versions/.gitkeep b/server/migrations/versions/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/server/migrations/versions/51715d16e5c3_initial_v2_schema.py b/server/migrations/versions/51715d16e5c3_initial_v2_schema.py new file mode 100644 index 0000000..374d8a5 --- /dev/null +++ b/server/migrations/versions/51715d16e5c3_initial_v2_schema.py @@ -0,0 +1,203 @@ +"""initial v2 schema + +Revision ID: 51715d16e5c3 +Revises: +Create Date: 2026-08-15 10:06:57.793304 + +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = '51715d16e5c3' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('attribute_values', + sa.Column('id', sa.BigInteger(), nullable=False), + sa.Column('attribute_id', sa.BigInteger(), nullable=False), + sa.Column('description_category_id', sa.BigInteger(), nullable=False), + sa.Column('type_id', sa.BigInteger(), nullable=False), + sa.Column('value', sa.String(length=512), nullable=False), + sa.Column('picture', sa.Text(), nullable=False), + sa.Column('info', sa.Text(), nullable=False), + sa.Column('lang', sa.String(length=8), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.PrimaryKeyConstraint('id', 'attribute_id', 'description_category_id', 'type_id') + ) + op.create_table('category_attributes', + sa.Column('description_category_id', sa.BigInteger(), nullable=False), + sa.Column('type_id', sa.BigInteger(), nullable=False), + sa.Column('attribute_id', sa.BigInteger(), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('description', sa.Text(), nullable=False), + sa.Column('type', sa.String(length=32), nullable=False), + sa.Column('dictionary_id', sa.BigInteger(), nullable=False), + sa.Column('group_id', sa.BigInteger(), nullable=True), + sa.Column('group_name', sa.String(length=255), nullable=False), + sa.Column('is_required', sa.Boolean(), nullable=False), + sa.Column('is_aspect', sa.Boolean(), nullable=False), + sa.Column('is_collection', sa.Boolean(), nullable=False), + sa.Column('max_value_count', sa.Integer(), nullable=False), + sa.Column('attribute_complex_id', sa.BigInteger(), nullable=True), + sa.Column('complex_is_collection', sa.Boolean(), nullable=False), + sa.Column('category_dependent', sa.Boolean(), nullable=False), + sa.Column('lang', sa.String(length=8), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.PrimaryKeyConstraint('description_category_id', 'type_id', 'attribute_id') + ) + op.create_table('category_tree', + sa.Column('description_category_id', sa.BigInteger(), nullable=False), + sa.Column('parent_id', sa.BigInteger(), nullable=True), + sa.Column('category_name', sa.String(length=255), nullable=False), + sa.Column('type_id', sa.BigInteger(), nullable=True), + sa.Column('type_name', sa.String(length=255), nullable=False), + sa.Column('disabled', sa.Boolean(), nullable=False), + sa.Column('level', sa.Integer(), nullable=False), + sa.Column('lang', sa.String(length=8), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.PrimaryKeyConstraint('description_category_id') + ) + op.create_index(op.f('ix_category_tree_parent_id'), 'category_tree', ['parent_id'], unique=False) + op.create_table('products', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('user_id', sa.Uuid(), nullable=True), + sa.Column('stage', sa.Enum('collected', 'editing', 'ready', 'publishing', 'published', 'failed', 'archived', name='stage', native_enum=False, length=16), nullable=False), + sa.Column('source_platform', sa.String(length=16), nullable=True), + sa.Column('source_item_id', sa.String(length=64), nullable=True), + sa.Column('source_url', sa.Text(), nullable=True), + sa.Column('offer_id', sa.String(length=255), nullable=False), + sa.Column('ozon_product_id', sa.BigInteger(), nullable=True), + sa.Column('ozon_sku', sa.BigInteger(), nullable=True), + sa.Column('name', sa.Text(), nullable=False), + sa.Column('description', sa.Text(), nullable=False), + sa.Column('description_category_id', sa.BigInteger(), nullable=True), + sa.Column('type_id', sa.BigInteger(), nullable=True), + sa.Column('price', sa.Float(), nullable=True), + sa.Column('old_price', sa.Float(), nullable=True), + sa.Column('currency_code', sa.String(length=3), server_default='RUB', nullable=False), + sa.Column('vat', sa.String(length=8), server_default='0', nullable=False), + sa.Column('depth', sa.Float(), nullable=True), + sa.Column('width', sa.Float(), nullable=True), + sa.Column('height', sa.Float(), nullable=True), + sa.Column('dimension_unit', sa.String(length=4), server_default='mm', nullable=False), + sa.Column('weight', sa.Float(), nullable=True), + sa.Column('weight_unit', sa.String(length=4), server_default='g', nullable=False), + sa.Column('barcode', sa.String(length=64), nullable=True), + sa.Column('images', sa.JSON(), nullable=True), + sa.Column('primary_image', sa.Text(), nullable=True), + sa.Column('images360', sa.JSON(), nullable=True), + sa.Column('color_image', sa.Text(), nullable=True), + sa.Column('pdf_list', sa.JSON(), nullable=True), + sa.Column('promotions', sa.JSON(), nullable=True), + sa.Column('attributes', sa.JSON(), nullable=True), + sa.Column('complex_attributes', sa.JSON(), nullable=True), + sa.Column('raw', sa.JSON(), nullable=True), + sa.Column('pricing', sa.JSON(), nullable=True), + sa.Column('copy', sa.JSON(), nullable=True), + sa.Column('fx_rate', sa.Float(), nullable=True), + sa.Column('published_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('asset_counts', sa.JSON(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_products_offer_id'), 'products', ['offer_id'], unique=False) + op.create_index(op.f('ix_products_ozon_product_id'), 'products', ['ozon_product_id'], unique=False) + op.create_index(op.f('ix_products_source_item_id'), 'products', ['source_item_id'], unique=False) + op.create_index(op.f('ix_products_stage'), 'products', ['stage'], unique=False) + op.create_index(op.f('ix_products_updated_at'), 'products', ['updated_at'], unique=False) + op.create_table('shops', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('user_id', sa.Uuid(), nullable=True), + sa.Column('name', sa.String(length=128), nullable=False), + sa.Column('client_id_enc', sa.String(length=1024), nullable=False), + sa.Column('api_key_enc', sa.String(length=1024), nullable=False), + sa.Column('currency_code', sa.String(length=3), server_default='RUB', nullable=False), + sa.Column('status', sa.Enum('active', 'invalid', 'disabled', name='shopstatus', native_enum=False, length=16), nullable=False), + sa.Column('last_checked_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('users', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('username', sa.String(length=64), nullable=False), + sa.Column('password_hash', sa.String(length=255), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('username') + ) + op.create_table('product_assets', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('product_id', sa.Uuid(), nullable=False), + sa.Column('group_key', sa.String(length=16), nullable=False), + sa.Column('variant_name', sa.String(length=128), nullable=True), + sa.Column('sort_order', sa.Integer(), nullable=False), + sa.Column('type', sa.String(length=8), nullable=False), + sa.Column('source_url', sa.Text(), nullable=False), + sa.Column('stored_url', sa.Text(), nullable=True), + sa.Column('status', sa.Enum('pending', 'downloading', 'uploaded', 'failed', name='assetstatus', native_enum=False, length=16), nullable=False), + sa.Column('dedupe_key', sa.String(length=512), nullable=True), + sa.Column('width', sa.Integer(), nullable=True), + sa.Column('height', sa.Integer(), nullable=True), + sa.Column('error', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['product_id'], ['products.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_product_assets_dedupe_key'), 'product_assets', ['dedupe_key'], unique=False) + op.create_index(op.f('ix_product_assets_product_id'), 'product_assets', ['product_id'], unique=False) + op.create_index(op.f('ix_product_assets_status'), 'product_assets', ['status'], unique=False) + op.create_table('publish_tasks', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('product_id', sa.Uuid(), nullable=False), + sa.Column('shop_id', sa.Uuid(), nullable=False), + sa.Column('ozon_task_id', sa.BigInteger(), nullable=True), + sa.Column('status', sa.Enum('pending', 'processing', 'moderation', 'imported', 'failed', name='publishstatus', native_enum=False, length=16), nullable=False), + sa.Column('request_payload', sa.JSON(), nullable=True), + sa.Column('response', sa.JSON(), nullable=True), + sa.Column('errors', sa.JSON(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['product_id'], ['products.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['shop_id'], ['shops.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_publish_tasks_ozon_task_id'), 'publish_tasks', ['ozon_task_id'], unique=False) + op.create_index(op.f('ix_publish_tasks_product_id'), 'publish_tasks', ['product_id'], unique=False) + op.create_index(op.f('ix_publish_tasks_shop_id'), 'publish_tasks', ['shop_id'], unique=False) + op.create_index(op.f('ix_publish_tasks_status'), 'publish_tasks', ['status'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_publish_tasks_status'), table_name='publish_tasks') + op.drop_index(op.f('ix_publish_tasks_shop_id'), table_name='publish_tasks') + op.drop_index(op.f('ix_publish_tasks_product_id'), table_name='publish_tasks') + op.drop_index(op.f('ix_publish_tasks_ozon_task_id'), table_name='publish_tasks') + op.drop_table('publish_tasks') + op.drop_index(op.f('ix_product_assets_status'), table_name='product_assets') + op.drop_index(op.f('ix_product_assets_product_id'), table_name='product_assets') + op.drop_index(op.f('ix_product_assets_dedupe_key'), table_name='product_assets') + op.drop_table('product_assets') + op.drop_table('users') + op.drop_table('shops') + op.drop_index(op.f('ix_products_updated_at'), table_name='products') + op.drop_index(op.f('ix_products_stage'), table_name='products') + op.drop_index(op.f('ix_products_source_item_id'), table_name='products') + op.drop_index(op.f('ix_products_ozon_product_id'), table_name='products') + op.drop_index(op.f('ix_products_offer_id'), table_name='products') + op.drop_table('products') + op.drop_index(op.f('ix_category_tree_parent_id'), table_name='category_tree') + op.drop_table('category_tree') + op.drop_table('category_attributes') + op.drop_table('attribute_values') + # ### end Alembic commands ### diff --git a/server/migrations/versions/658b0503f71c_add_shop_id_to_products.py b/server/migrations/versions/658b0503f71c_add_shop_id_to_products.py new file mode 100644 index 0000000..88e7095 --- /dev/null +++ b/server/migrations/versions/658b0503f71c_add_shop_id_to_products.py @@ -0,0 +1,29 @@ +"""add shop_id to products + +Revision ID: 658b0503f71c +Revises: 51715d16e5c3 +Create Date: 2026-08-15 14:30:34.342589 + +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = '658b0503f71c' +down_revision = '51715d16e5c3' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('products', sa.Column('shop_id', sa.Uuid(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('products', 'shop_id') + # ### end Alembic commands ### diff --git a/server/models/__init__.py b/server/models/__init__.py new file mode 100644 index 0000000..7167329 --- /dev/null +++ b/server/models/__init__.py @@ -0,0 +1,18 @@ +"""模型统一导出(供 Alembic autogenerate 与业务代码 import)。""" +from models.asset import ProductAsset +from models.category import AttributeValue, CategoryAttribute, CategoryTree +from models.product import Product +from models.publish_task import PublishTask +from models.shop import Shop +from models.user import User + +__all__ = [ + "User", + "Shop", + "Product", + "ProductAsset", + "PublishTask", + "CategoryTree", + "CategoryAttribute", + "AttributeValue", +] diff --git a/server/models/asset.py b/server/models/asset.py new file mode 100644 index 0000000..5a06270 --- /dev/null +++ b/server/models/asset.py @@ -0,0 +1,34 @@ +"""采集素材(图片/视频):分组、源站 URL、转存 URL、状态。""" +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, Enum, ForeignKey, Integer, String, Text, Uuid, func +from sqlalchemy.orm import Mapped, mapped_column + +from db import Base +from models.enums import AssetStatus + + +class ProductAsset(Base): + __tablename__ = "product_assets" + + id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4) + product_id: Mapped[uuid.UUID] = mapped_column( + Uuid(as_uuid=True), ForeignKey("products.id", ondelete="CASCADE"), index=True + ) + group_key: Mapped[str] = mapped_column(String(16), default="main") # main/sku/detail/video/param/generated + variant_name: Mapped[str | None] = mapped_column(String(128), nullable=True) # SKU 规格名 + sort_order: Mapped[int] = mapped_column(Integer, default=0) + type: Mapped[str] = mapped_column(String(8), default="img") # img / video + source_url: Mapped[str] = mapped_column(Text, default="") + stored_url: Mapped[str | None] = mapped_column(Text, nullable=True) # 本地路径或七牛公网 URL + status: Mapped[AssetStatus] = mapped_column( + Enum(AssetStatus, native_enum=False, length=16), default=AssetStatus.pending, index=True + ) + dedupe_key: Mapped[str | None] = mapped_column(String(512), nullable=True, index=True) + width: Mapped[int | None] = mapped_column(Integer, nullable=True) + height: Mapped[int | None] = mapped_column(Integer, nullable=True) + error: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) diff --git a/server/models/category.py b/server/models/category.py new file mode 100644 index 0000000..18e7eff --- /dev/null +++ b/server/models/category.py @@ -0,0 +1,60 @@ +"""Ozon 类目字典缓存(可重建,不作为业务真源)。""" +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import BigInteger, Boolean, DateTime, Integer, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column + +from db import Base + + +class CategoryTree(Base): + __tablename__ = "category_tree" + + description_category_id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + parent_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True, index=True) + category_name: Mapped[str] = mapped_column(String(255), default="") + type_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + type_name: Mapped[str] = mapped_column(String(255), default="") + disabled: Mapped[bool] = mapped_column(Boolean, default=False) + level: Mapped[int] = mapped_column(Integer, default=0) + lang: Mapped[str] = mapped_column(String(8), default="DEFAULT") + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + +class CategoryAttribute(Base): + __tablename__ = "category_attributes" + + description_category_id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + type_id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + attribute_id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + name: Mapped[str] = mapped_column(String(255), default="") + description: Mapped[str] = mapped_column(Text, default="") + type: Mapped[str] = mapped_column(String(32), default="") + dictionary_id: Mapped[int] = mapped_column(BigInteger, default=0) + group_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + group_name: Mapped[str] = mapped_column(String(255), default="") + is_required: Mapped[bool] = mapped_column(Boolean, default=False) + is_aspect: Mapped[bool] = mapped_column(Boolean, default=False) + is_collection: Mapped[bool] = mapped_column(Boolean, default=False) + max_value_count: Mapped[int] = mapped_column(Integer, default=0) + attribute_complex_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + complex_is_collection: Mapped[bool] = mapped_column(Boolean, default=False) + category_dependent: Mapped[bool] = mapped_column(Boolean, default=False) + lang: Mapped[str] = mapped_column(String(8), default="DEFAULT") + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + +class AttributeValue(Base): + __tablename__ = "attribute_values" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + attribute_id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + description_category_id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + type_id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + value: Mapped[str] = mapped_column(String(512), default="") + picture: Mapped[str] = mapped_column(Text, default="") + info: Mapped[str] = mapped_column(Text, default="") + lang: Mapped[str] = mapped_column(String(8), default="DEFAULT") + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) diff --git a/server/models/enums.py b/server/models/enums.py new file mode 100644 index 0000000..fa3c40b --- /dev/null +++ b/server/models/enums.py @@ -0,0 +1,39 @@ +"""业务枚举。值写入数据库字符串列(native_enum=False,跨 SQLite/PG 一致)。""" +from __future__ import annotations + +import enum + + +class Stage(str, enum.Enum): + collected = "collected" # 插件刚上传,只有素材与原文 + editing = "editing" # 用户正在编辑 + ready = "ready" # 必填项齐全,可发布 + publishing = "publishing" # 已提交 ImportProductsV3,等待轮询 + published = "published" # 轮询 imported 成功 + failed = "failed" # 轮询返回 errors / 校验失败 + archived = "archived" # 手动归档(软删) + + +class AssetStatus(str, enum.Enum): + pending = "pending" # 已入库,等待下载 + downloading = "downloading" # 正在下载源图 + uploaded = "uploaded" # 已转存(本地/七牛) + failed = "failed" # 下载或转存失败 + + +class PublishStatus(str, enum.Enum): + pending = "pending" + processing = "processing" + moderation = "moderation" + imported = "imported" + failed = "failed" + + +class ShopStatus(str, enum.Enum): + active = "active" + invalid = "invalid" # 连通性校验失败 + disabled = "disabled" + + +# 图片分组(对齐契约 _images) +IMAGE_GROUPS = ("main", "sku", "detail", "video", "param", "generated") diff --git a/server/models/product.py b/server/models/product.py new file mode 100644 index 0000000..85de71e --- /dev/null +++ b/server/models/product.py @@ -0,0 +1,76 @@ +"""商品主表:对齐 Ozon ImportProductsV3 字段 + 本地扩展(raw/pricing/copy)。""" +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import BigInteger, DateTime, Enum, Float, Integer, String, Text, Uuid, func +from sqlalchemy.orm import Mapped, mapped_column + +from db import Base +from models.enums import Stage +from models.types import JSONType + + +class Product(Base): + __tablename__ = "products" + + id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID | None] = mapped_column(Uuid(as_uuid=True), nullable=True) + shop_id: Mapped[uuid.UUID | None] = mapped_column(Uuid(as_uuid=True), nullable=True) # 上架店铺 + stage: Mapped[Stage] = mapped_column( + Enum(Stage, native_enum=False, length=16), default=Stage.collected, index=True + ) + + # 采集溯源 + source_platform: Mapped[str | None] = mapped_column(String(16), nullable=True) + source_item_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + source_url: Mapped[str | None] = mapped_column(Text, nullable=True) + + # ── Ozon 字段(对齐 ImportProductsV3)── + offer_id: Mapped[str] = mapped_column(String(255), default="", index=True) + ozon_product_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True, index=True) + ozon_sku: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + name: Mapped[str] = mapped_column(Text, default="") + description: Mapped[str] = mapped_column(Text, default="") + description_category_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + type_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + price: Mapped[float | None] = mapped_column(Float, nullable=True) + old_price: Mapped[float | None] = mapped_column(Float, nullable=True) + # 币种固定人民币(跨境卖家 CNY 计价);vat 恒为 0(简化税制,无 НДС),前端不再展示 + currency_code: Mapped[str] = mapped_column(String(3), default="CNY", server_default="CNY") + vat: Mapped[str] = mapped_column(String(8), default="0", server_default="0") + depth: Mapped[float | None] = mapped_column(Float, nullable=True) + width: Mapped[float | None] = mapped_column(Float, nullable=True) + height: Mapped[float | None] = mapped_column(Float, nullable=True) + dimension_unit: Mapped[str] = mapped_column(String(4), default="mm", server_default="mm") + weight: Mapped[float | None] = mapped_column(Float, nullable=True) + weight_unit: Mapped[str] = mapped_column(String(4), default="g", server_default="g") + barcode: Mapped[str | None] = mapped_column(String(64), nullable=True) + + # 图片(有序公网 URL,≤15) + images: Mapped[list | None] = mapped_column(JSONType, nullable=True) + primary_image: Mapped[str | None] = mapped_column(Text, nullable=True) + images360: Mapped[list | None] = mapped_column(JSONType, nullable=True) + color_image: Mapped[str | None] = mapped_column(Text, nullable=True) + pdf_list: Mapped[list | None] = mapped_column(JSONType, nullable=True) + promotions: Mapped[list | None] = mapped_column(JSONType, nullable=True) + + # 动态属性(工作台映射后填) + attributes: Mapped[list | None] = mapped_column(JSONType, nullable=True) + complex_attributes: Mapped[list | None] = mapped_column(JSONType, nullable=True) + + # ── 本地扩展(提交 Ozon 前剥离)── + raw: Mapped[dict | None] = mapped_column(JSONType, nullable=True) # 采集原文 + texts + pricing: Mapped[dict | None] = mapped_column(JSONType, nullable=True) # 计价结果 + copy: Mapped[dict | None] = mapped_column(JSONType, nullable=True) # AI 文案结果 + fx_rate: Mapped[float | None] = mapped_column(Float, nullable=True) + + published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), index=True + ) + + # 采集素材数量(冗余,供列表快速展示;由 service 维护) + asset_counts: Mapped[dict | None] = mapped_column(JSONType, nullable=True) diff --git a/server/models/publish_task.py b/server/models/publish_task.py new file mode 100644 index 0000000..b29becb --- /dev/null +++ b/server/models/publish_task.py @@ -0,0 +1,33 @@ +"""发布任务:一次 ImportProductsV3 请求与轮询结果。""" +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import BigInteger, DateTime, Enum, ForeignKey, Uuid, func +from sqlalchemy.orm import Mapped, mapped_column + +from db import Base +from models.enums import PublishStatus +from models.types import JSONType + + +class PublishTask(Base): + __tablename__ = "publish_tasks" + + id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4) + product_id: Mapped[uuid.UUID] = mapped_column( + Uuid(as_uuid=True), ForeignKey("products.id", ondelete="CASCADE"), index=True + ) + shop_id: Mapped[uuid.UUID] = mapped_column( + Uuid(as_uuid=True), ForeignKey("shops.id", ondelete="CASCADE"), index=True + ) + ozon_task_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True, index=True) + status: Mapped[PublishStatus] = mapped_column( + Enum(PublishStatus, native_enum=False, length=16), default=PublishStatus.pending, index=True + ) + request_payload: Mapped[dict | None] = mapped_column(JSONType, nullable=True) # 脱敏后的 items[0] + response: Mapped[dict | None] = mapped_column(JSONType, nullable=True) # import/info 原始结果 + errors: Mapped[list | None] = mapped_column(JSONType, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) diff --git a/server/models/shop.py b/server/models/shop.py new file mode 100644 index 0000000..a03edc8 --- /dev/null +++ b/server/models/shop.py @@ -0,0 +1,30 @@ +"""Ozon 店铺(Client-Id / Api-Key 加密落库)。""" +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, Enum, String, Uuid, func +from sqlalchemy.orm import Mapped, mapped_column + +from db import Base +from models.enums import ShopStatus + + +class Shop(Base): + __tablename__ = "shops" + + id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID | None] = mapped_column(Uuid(as_uuid=True), nullable=True) # 预留多用户 + name: Mapped[str] = mapped_column(String(128), nullable=False) + client_id_enc: Mapped[str] = mapped_column(String(1024), nullable=False) # AES-GCM 密文 + api_key_enc: Mapped[str] = mapped_column(String(1024), nullable=False) + currency_code: Mapped[str] = mapped_column(String(3), default="RUB", server_default="RUB") + status: Mapped[ShopStatus] = mapped_column( + Enum(ShopStatus, native_enum=False, length=16), default=ShopStatus.active + ) + last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) diff --git a/server/models/types.py b/server/models/types.py new file mode 100644 index 0000000..c51aaf3 --- /dev/null +++ b/server/models/types.py @@ -0,0 +1,8 @@ +"""共享列类型:JSON(SQLite 存 TEXT,PostgreSQL 存 JSON;跨库一致)。""" +from __future__ import annotations + +from sqlalchemy import JSON + +# 统一用 generic JSON:SQLite/PostgreSQL 均可,避免 autogenerate 对 JSONB 变体渲染异常。 +# 生产若需 JSONB 的索引能力,可再按需迁移,量级上差异可忽略。 +JSONType = JSON diff --git a/server/models/user.py b/server/models/user.py new file mode 100644 index 0000000..2c5ad37 --- /dev/null +++ b/server/models/user.py @@ -0,0 +1,19 @@ +"""用户表(预留多用户;MVP 用 APP_TOKEN 时为空)。""" +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, String, Uuid, func +from sqlalchemy.orm import Mapped, mapped_column + +from db import Base + + +class User(Base): + __tablename__ = "users" + + id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4) + username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) + password_hash: Mapped[str] = mapped_column(String(255), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) diff --git a/server/requirements.txt b/server/requirements.txt index 10361c4..e70fe71 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -3,5 +3,14 @@ uvicorn[standard]>=0.32.0 httpx>=0.27.0 pydantic-settings>=2.6.0 python-dotenv>=1.0.0 +python-multipart>=0.0.9 PyYAML>=6.0.0 dashscope>=1.23.8 + +# V2:数据层 / 鉴权 / 对象存储 +sqlalchemy[asyncio]>=2.0.0 +aiosqlite>=0.20.0 +alembic>=1.13.0 +PyJWT>=2.8.0 +cryptography>=42.0.0 +qiniu>=7.13.0 diff --git a/server/schemas/auth.py b/server/schemas/auth.py new file mode 100644 index 0000000..9aa0d72 --- /dev/null +++ b/server/schemas/auth.py @@ -0,0 +1,14 @@ +"""鉴权请求/响应模型。""" +from __future__ import annotations + +from pydantic import BaseModel + + +class LoginRequest(BaseModel): + token: str + + +class LoginResponse(BaseModel): + access_token: str + token_type: str = "bearer" + expires_at: int diff --git a/server/schemas/collection.py b/server/schemas/collection.py new file mode 100644 index 0000000..fa25e62 --- /dev/null +++ b/server/schemas/collection.py @@ -0,0 +1,42 @@ +"""采集上传(插件 → 服务端)请求/响应模型,对齐 docs/extension/plan.md §14。""" +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class SourceInfo(BaseModel): + platform: str = Field(..., description="ozon | 1688 | taobao") + itemId: str | None = None + url: str = "" + collectedAt: int | None = None # epoch 毫秒 + + +class TextMaterial(BaseModel): + kind: str = Field(..., description="title | params | selling_point | desc | price | brand") + content: str = "" + pairs: list[dict] | None = None # table 模式 kv:[{key, value}] + + +class ImageMaterial(BaseModel): + groupKey: str = Field(..., description="main | sku | detail | video | param") + groupName: str = "" + variantName: str | None = None # SKU 规格名 + url: str = Field(..., description="源站原图 URL") + index: int = 0 + type: str = "img" # img | video + dedupeKey: str | None = None + + +class MaterialsRequest(BaseModel): + product_id: str | None = Field(default=None, description="传了=追加到已有商品(跨平台补素材)") + source: SourceInfo + texts: list[TextMaterial] = Field(default_factory=list) + images: list[ImageMaterial] = Field(default_factory=list) + refererOrigin: str | None = None # 下载源图时需带的 Referer + + +class MaterialsResponse(BaseModel): + product_id: str + stage: str + assets_queued: int + assets_skipped: int = 0 # 因 dedupeKey 重复而跳过 diff --git a/server/schemas/product.py b/server/schemas/product.py new file mode 100644 index 0000000..9b070e6 --- /dev/null +++ b/server/schemas/product.py @@ -0,0 +1,102 @@ +"""商品 Pydantic 模型(列表 / 详情 / 部分更新)。""" +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + + +class ProductListItem(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: UUID + stage: str + name: str = "" + offer_id: str = "" + price: float | None = None + currency_code: str = "RUB" + source_platform: str | None = None + source_url: str | None = None + asset_counts: dict | None = None + ozon_product_id: int | None = None + created_at: datetime + updated_at: datetime + + +class ProductDetail(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: UUID + shop_id: UUID | None = None + stage: str + source_platform: str | None = None + source_item_id: str | None = None + source_url: str | None = None + + offer_id: str = "" + ozon_product_id: int | None = None + name: str = "" + description: str = "" + description_category_id: int | None = None + type_id: int | None = None + price: float | None = None + old_price: float | None = None + currency_code: str = "RUB" + vat: str = "0" + depth: float | None = None + width: float | None = None + height: float | None = None + dimension_unit: str = "mm" + weight: float | None = None + weight_unit: str = "g" + barcode: str | None = None + + images: list | None = None + primary_image: str | None = None + images360: list | None = None + color_image: str | None = None + attributes: list | None = None + complex_attributes: list | None = None + + raw: dict | None = None + pricing: dict | None = None + copy: dict | None = None + fx_rate: float | None = None + asset_counts: dict | None = None + + published_at: datetime | None = None + created_at: datetime + updated_at: datetime + + +class ProductUpdate(BaseModel): + """编辑页 autosave 的部分更新。仅允许业务字段,id/时间由服务端维护。""" + + shop_id: UUID | None = None + stage: str | None = None + offer_id: str | None = None + name: str | None = None + description: str | None = None + description_category_id: int | None = None + type_id: int | None = None + price: float | None = None + old_price: float | None = None + currency_code: str | None = None + vat: str | None = None + depth: float | None = None + width: float | None = None + height: float | None = None + dimension_unit: str | None = None + weight: float | None = None + weight_unit: str | None = None + barcode: str | None = None + images: list | None = None + primary_image: str | None = None + attributes: list | None = None + complex_attributes: list | None = None + raw: dict | None = None + pricing: dict | None = None + copy: dict | None = None + fx_rate: float | None = None + source_url: str | None = None diff --git a/server/schemas/shop.py b/server/schemas/shop.py new file mode 100644 index 0000000..6f6f762 --- /dev/null +++ b/server/schemas/shop.py @@ -0,0 +1,33 @@ +"""店铺(Ozon 凭证)请求/响应模型。""" +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict + + +class ShopCreate(BaseModel): + name: str + client_id: str + api_key: str + currency_code: str = "CNY" + + +class ShopUpdate(BaseModel): + name: str | None = None + client_id: str | None = None + api_key: str | None = None + currency_code: str | None = None + + +class ShopListItem(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: UUID + name: str + currency_code: str + status: str + client_id_masked: str = "" # 打码尾号 + last_checked_at: datetime | None = None + created_at: datetime diff --git a/server/services/fx.py b/server/services/fx.py new file mode 100644 index 0000000..36b90fc --- /dev/null +++ b/server/services/fx.py @@ -0,0 +1,58 @@ +"""汇率服务:CNY→RUB。数据源三级降级(FloatRates → 俄央行 → 兜底),服务端缓存。""" +from __future__ import annotations + +import time + +import httpx + +_FALLBACK_RATE = 11.5 +_MIN, _MAX = 5.0, 25.0 +_CACHE_TTL = 3600 # 秒 + +_cache: dict = {"rate": None, "source": "", "ts": 0.0} + + +def _valid(rate: float) -> bool: + return _MIN <= rate <= _MAX + + +async def _fetch_floatrates() -> float | None: + async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client: + resp = await client.get("https://www.floatrates.com/daily/cny.json") + resp.raise_for_status() + rub = resp.json().get("rub", {}) + rate = rub.get("rate") + return float(rate) if rate else None + + +async def _fetch_cbr() -> float | None: + async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client: + resp = await client.get("https://www.cbr-xml-daily.ru/daily_json.js") + resp.raise_for_status() + cny = resp.json().get("Valute", {}).get("CNY", {}) + value = cny.get("Value") + return float(value) if value else None + + +async def get_fx_rate() -> dict: + """返回 {rate, source, updated_at}。带 1 小时内存缓存。""" + now = time.time() + if _cache["rate"] and (now - _cache["ts"]) < _CACHE_TTL: + return dict(_cache) + + rate = None + source = "" + for name, fn in (("floatrates", _fetch_floatrates), ("cbr", _fetch_cbr)): + try: + r = await fn() + if r is not None and _valid(r): + rate, source = r, name + break + except Exception: # noqa: BLE001 - 数据源失败降级 + continue + + if rate is None: + rate, source = _FALLBACK_RATE, "fallback" + + _cache.update({"rate": rate, "source": source, "ts": now}) + return dict(_cache) diff --git a/server/services/ozon_client.py b/server/services/ozon_client.py new file mode 100644 index 0000000..c93493d --- /dev/null +++ b/server/services/ozon_client.py @@ -0,0 +1,51 @@ +"""Ozon Seller API 客户端(薄封装:鉴权头 + 错误映射 + 退避)。""" +from __future__ import annotations + +import httpx + +OZON_BASE_URL = "https://api-seller.ozon.ru" + + +class OzonAPIError(Exception): + def __init__(self, status: int, detail: str): + self.status = status + self.detail = detail + super().__init__(f"Ozon API {status}: {detail}") + + +class OzonClient: + def __init__(self, client_id: str, api_key: str, base_url: str = OZON_BASE_URL): + self.client_id = client_id + self.api_key = api_key + self.base_url = base_url + + def _headers(self) -> dict: + return { + "Client-Id": self.client_id, + "Api-Key": self.api_key, + "Content-Type": "application/json", + } + + async def post(self, path: str, body: dict | None = None, timeout: float = 60.0) -> dict: + async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client: + resp = await client.post(f"{self.base_url}{path}", headers=self._headers(), json=body or {}) + if resp.status_code >= 400: + raise OzonAPIError(resp.status_code, resp.text[:500]) + try: + return resp.json() + except Exception: # noqa: BLE001 + return {} + + async def get(self, path: str, timeout: float = 60.0) -> dict: + async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client: + resp = await client.get(f"{self.base_url}{path}", headers=self._headers()) + if resp.status_code >= 400: + raise OzonAPIError(resp.status_code, resp.text[:500]) + try: + return resp.json() + except Exception: # noqa: BLE001 + return {} + + async def test_credentials(self) -> dict: + """调 /v1/roles 校验凭证与权限范围。""" + return await self.post("/v1/roles", {}) diff --git a/server/services/publish.py b/server/services/publish.py new file mode 100644 index 0000000..f0194cc --- /dev/null +++ b/server/services/publish.py @@ -0,0 +1,79 @@ +"""发布:组装 ImportProductsV3 items[0] + 必填校验 + 轮询回填。""" +from __future__ import annotations + +from models import Product + + +def _fmt(v) -> str: + if v is None: + return "" + return str(v) + + +def validate_ready(product: Product) -> list[str]: + """返回缺失/非法必填项的中文提示列表;空列表表示可发布。""" + missing: list[str] = [] + if not product.offer_id.strip(): + missing.append("货号 offer_id") + if not product.name.strip(): + missing.append("商品名 name") + if not product.description.strip(): + missing.append("描述 description") + if not product.description_category_id: + missing.append("类目 description_category_id") + if product.price is None or product.price <= 0: + missing.append("售价 price") + if not product.weight or product.weight <= 0: + missing.append("重量 weight") + for label, val in (("长 depth", product.depth), ("宽 width", product.width), ("高 height", product.height)): + if not val or val <= 0: + missing.append(label) + if not product.images: + missing.append("主图 images(至少 1 张)") + elif any(u and u.startswith("http://") for u in product.images): + missing.append("图片链接必须使用 https(Ozon 不接受 http 直链)") + return missing + + +def _with_model_name(product: Product) -> list: + """把 raw.model_name 自动注入为 attribute 9048(型号名称),用于多变体合并。""" + attrs = list(product.attributes or []) + model_name = (product.raw or {}).get("model_name") if product.raw else None + if not model_name: + return attrs + # 已手动映射 9048 就不重复添加 + for a in attrs: + if isinstance(a, dict) and a.get("id") == 9048: + return attrs + attrs.append({"complex_id": 0, "id": 9048, "values": [{"value": model_name}]}) + return attrs + + +def build_import_item(product: Product) -> dict: + item: dict = { + "offer_id": product.offer_id, + "name": product.name, + "description": product.description, + "description_category_id": product.description_category_id, + "price": _fmt(product.price), + "old_price": _fmt(product.old_price), + "currency_code": product.currency_code or "CNY", + "vat": product.vat or "0", + "depth": product.depth, + "width": product.width, + "height": product.height, + "dimension_unit": product.dimension_unit or "mm", + "weight": product.weight, + "weight_unit": product.weight_unit or "g", + "images": product.images or [], + "primary_image": product.primary_image or "", + "images360": product.images360 or [], + "color_image": product.color_image or "", + "attributes": _with_model_name(product), + "complex_attributes": product.complex_attributes or [], + } + if product.type_id: + item["type_id"] = product.type_id + if product.barcode: + item["barcode"] = product.barcode + return item diff --git a/server/services/storage.py b/server/services/storage.py new file mode 100644 index 0000000..265f36d --- /dev/null +++ b/server/services/storage.py @@ -0,0 +1,105 @@ +"""图片/文件存储抽象:本地文件系统(开发兜底)+ 七牛(生产)。""" +from __future__ import annotations + +import mimetypes +import uuid +from pathlib import Path + +import httpx + +from config import get_settings + +# 本地存储根目录(仓库根 data/media/) +_LOCAL_ROOT = Path(__file__).resolve().parents[2] / "data" / "media" + + +def _ext_from_url(url: str) -> str: + ext = mimetypes.guess_extension(url.split("?")[0].lower()) or ".jpg" + if ext == ".jpe": + ext = ".jpg" + return ext + + +def _ext_from_content_type(content_type: str) -> str: + ctype = (content_type or "").split(";")[0].strip().lower() + mapping = { + "image/jpeg": ".jpg", + "image/png": ".png", + "image/webp": ".webp", + "image/gif": ".gif", + "image/bmp": ".bmp", + "image/heic": ".heic", + "video/mp4": ".mp4", + } + return mapping.get(ctype, ".jpg") + + +async def download_bytes(url: str, referer: str | None = None, timeout: float = 60.0) -> tuple[bytes, str]: + """下载远程字节。返回 (bytes, content_type)。""" + headers = {"Referer": referer} if referer else {} + async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client: + resp = await client.get(url, headers=headers) + resp.raise_for_status() + ctype = (resp.headers.get("content-type") or "application/octet-stream").split(";")[0].strip() + return resp.content, ctype + + +class LocalStorage: + """开发期:落 data/media/,由 FastAPI /media 静态托管,返回 app_base_url 可访问 URL。""" + + async def save_from_url(self, url: str, key_prefix: str = "", referer: str | None = None) -> str: + data, _ = await download_bytes(url, referer) + key = self._write(data, key_prefix, url) + return self.public_url(key) + + async def save_bytes(self, data: bytes, key: str, content_type: str = "") -> str: + key = self._write(data, "", key) + return self.public_url(key) + + def _write(self, data: bytes, key_prefix: str, hint: str) -> str: + _LOCAL_ROOT.mkdir(parents=True, exist_ok=True) + ext = _ext_from_url(hint) if hint and not hint.startswith("data:") else ".jpg" + key = f"{key_prefix + '/' if key_prefix else ''}{uuid.uuid4().hex}{ext}" + (_LOCAL_ROOT / key).parent.mkdir(parents=True, exist_ok=True) + (_LOCAL_ROOT / key).write_bytes(data) + return key + + def public_url(self, key: str) -> str: + settings = get_settings() + return f"{settings.app_base_url.rstrip('/')}/media/{key}" + + +class QiniuStorage: + """生产:上传七牛,返回绑定域名公网 URL(Ozon 可拉取)。""" + + def _client(self): + import qiniu + + settings = get_settings() + return qiniu.Auth(settings.qiniu_access_key, settings.qiniu_secret_key), settings + + async def save_from_url(self, url: str, key_prefix: str = "", referer: str | None = None) -> str: + data, ctype = await download_bytes(url, referer) + return await self.save_bytes(data, f"{key_prefix}/{uuid.uuid4().hex}{_ext_from_content_type(ctype)}", ctype) + + async def save_bytes(self, data: bytes, key: str, content_type: str = "") -> str: + import qiniu + + auth, settings = self._client() + bucket = settings.qiniu_bucket + token = auth.upload_token(bucket, key, 3600) + ret, info = qiniu.put_data(token, key, data) + if info.status_code not in (200,): + raise RuntimeError(f"七牛上传失败:{info.error or info.text_body or info.status_code}") + return self.public_url(key) + + def public_url(self, key: str) -> str: + settings = get_settings() + return f"{settings.qiniu_domain.rstrip('/')}/{key}" + + +def get_storage(): + settings = get_settings() + if settings.use_qiniu: + return QiniuStorage() + return LocalStorage() diff --git a/studio/src/components/RequireAuth.tsx b/studio/src/components/RequireAuth.tsx new file mode 100644 index 0000000..dcd9e51 --- /dev/null +++ b/studio/src/components/RequireAuth.tsx @@ -0,0 +1,16 @@ +import { useEffect } from 'react'; +import { useNavigate } from 'react-router'; +import { getToken } from '@/services/auth'; + +/** 未登录则跳 /login */ +export default function RequireAuth({ children }: { children: React.ReactNode }) { + const navigate = useNavigate(); + const token = getToken(); + + useEffect(() => { + if (!token) navigate('/login', { replace: true }); + }, [token, navigate]); + + if (!token) return null; + return <>{children}; +} diff --git a/studio/src/layouts/menuConfig.tsx b/studio/src/layouts/menuConfig.tsx index ffaf2f6..498de3c 100644 --- a/studio/src/layouts/menuConfig.tsx +++ b/studio/src/layouts/menuConfig.tsx @@ -1,4 +1,4 @@ -import { PictureOutlined } from '@ant-design/icons'; +import { InboxOutlined, PictureOutlined, ShopOutlined } from '@ant-design/icons'; import type { ReactNode } from 'react'; export interface RouteMenuConfig { @@ -16,14 +16,30 @@ export interface RouteMenuConfig { subtitle: string; } -/** 菜单配置列表(当前仅「AI 图生图」一页) */ +/** 菜单配置列表 */ export const routeMenuConfig: RouteMenuConfig[] = [ + { + path: '/collection', + key: '/collection', + icon: , + label: '采集箱', + title: '采集箱', + subtitle: '查看已采集的商品,进入编辑', + }, + { + path: '/shops', + key: '/shops', + icon: , + label: '店铺管理', + title: '店铺管理', + subtitle: '绑定 Ozon 店铺 Client-Id / Api-Key', + }, { path: '/ai-image', key: '/ai-image', icon: , - label: 'AI 图生图', - title: 'AI 图生图', + label: '智能修图', + title: '智能修图', subtitle: '上传图片、加水印、用万相模型进行图生图编辑', }, ]; @@ -34,6 +50,9 @@ export const getPageInfo = (path: string): { title: string; subtitle: string } = const first = routeMenuConfig[0]; return { title: first.title, subtitle: first.subtitle }; } + if (path.startsWith('/product/')) { + return { title: '商品编辑', subtitle: '编辑商品信息、计价、文案与图片' }; + } const config = routeMenuConfig.find((item) => item.path === path); return config ? { title: config.title, subtitle: config.subtitle } : getPageInfo('/'); }; diff --git a/studio/src/pages/collection/CollectionPage.tsx b/studio/src/pages/collection/CollectionPage.tsx new file mode 100644 index 0000000..b513e63 --- /dev/null +++ b/studio/src/pages/collection/CollectionPage.tsx @@ -0,0 +1,183 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router'; +import { Button, Card, Input, message, Popconfirm, Space, Table, Tag, Typography } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { listProducts, deleteProduct, copyProduct, ProductListItem } from '@/services/product'; +import { apiErrorMessage } from '@/services/api'; + +const { Title } = Typography; + +export const STAGE_LABEL: Record = { + collected: '待编辑', + editing: '编辑中', + ready: '待发布', + publishing: '发布中', + published: '已发布', + failed: '发布失败', + archived: '已归档', +}; + +export const STAGE_COLOR: Record = { + collected: 'blue', + editing: 'gold', + ready: 'purple', + publishing: 'processing', + published: 'green', + failed: 'red', + archived: 'default', +}; + +export default function CollectionPage() { + const navigate = useNavigate(); + const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [stage, setStage] = useState(); + const [q, setQ] = useState(''); + const [page, setPage] = useState(1); + const pageSize = 20; + + const load = async () => { + setLoading(true); + try { + const res = await listProducts({ stage, q: q || undefined, page, page_size: pageSize }); + setItems(res.items); + setTotal(res.total); + } catch (e) { + message.error(apiErrorMessage(e)); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + load(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [stage, page]); + + const onDelete = async (id: string) => { + try { + await deleteProduct(id, true); + message.success('已删除'); + load(); + } catch (e) { + message.error(apiErrorMessage(e)); + } + }; + + const onCopy = async (id: string) => { + try { + const clone = await copyProduct(id); + message.success('已复制为新商品,请编辑货号/图片/变体属性'); + load(); + navigate(`/product/${clone.id}`); + } catch (e) { + message.error(apiErrorMessage(e)); + } + }; + + const columns: ColumnsType = [ + { + title: '商品名', + dataIndex: 'name', + render: (v, r) => ( + navigate(`/product/${r.id}`)}>{v || '(未命名)'} + ), + }, + { title: '货号', dataIndex: 'offer_id', width: 120, render: (v) => v || '—' }, + { title: '售价', dataIndex: 'price', width: 100, render: (v) => (v != null ? v.toFixed(2) : '—') }, + { title: '来源', dataIndex: 'source_platform', width: 90, render: (v) => v || '—' }, + { + title: '素材', + dataIndex: 'asset_counts', + width: 110, + render: (v) => + v + ? Object.entries(v) + .map(([k, n]) => `${k}:${n}`) + .join(' ') + : '—', + }, + { + title: '状态', + dataIndex: 'stage', + width: 100, + render: (v) => {STAGE_LABEL[v] || v}, + }, + { + title: '更新时间', + dataIndex: 'updated_at', + width: 170, + render: (v) => new Date(v).toLocaleString(), + }, + { + title: '操作', + width: 210, + render: (_, r) => ( + + + + onDelete(r.id)}> + + + + ), + }, + ]; + + return ( +
+ + + 采集箱 + + + { + setPage(1); + setQ(v); + }} + /> + + + + `共 ${t} 个商品`, + }} + /> + + + ); +} diff --git a/studio/src/pages/login/LoginPage.tsx b/studio/src/pages/login/LoginPage.tsx new file mode 100644 index 0000000..9e558b4 --- /dev/null +++ b/studio/src/pages/login/LoginPage.tsx @@ -0,0 +1,46 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router'; +import { Button, Card, Input, message, Typography } from 'antd'; +import { login } from '@/services/auth'; +import { apiErrorMessage } from '@/services/api'; + +const { Title, Text } = Typography; + +export default function LoginPage() { + const navigate = useNavigate(); + const [token, setToken] = useState(''); + const [loading, setLoading] = useState(false); + + const onSubmit = async () => { + if (!token.trim()) return; + setLoading(true); + try { + await login(token.trim()); + message.success('登录成功'); + navigate('/collection', { replace: true }); + } catch (e) { + message.error(apiErrorMessage(e)); + } finally { + setLoading(false); + } + }; + + return ( +
+ + Ozon 发布工作台 + 请输入访问 Token(对应服务端 .env 的 APP_TOKEN) + setToken(e.target.value)} + onPressEnter={onSubmit} + style={{ marginTop: 16 }} + /> + + +
+ ); +} diff --git a/studio/src/pages/product/AttributePanel.tsx b/studio/src/pages/product/AttributePanel.tsx new file mode 100644 index 0000000..501268d --- /dev/null +++ b/studio/src/pages/product/AttributePanel.tsx @@ -0,0 +1,400 @@ +import { useCallback, useEffect, useState } from 'react'; +import { + Alert, + Button, + Card, + Empty, + Input, + Row, + Col, + Select, + Space, + Spin, + Tag, + TreeSelect, + Typography, + message, +} from 'antd'; +import { listShops, ShopItem } from '@/services/shop'; +import { + categoryTree, + categoryAttributes, + attributeValues, + type AttributeItem, + type CategoryNode, +} from '@/services/category'; +import { ProductDetail } from '@/services/product'; +import { apiErrorMessage } from '@/services/api'; + +const { Text } = Typography; + +interface Props { + product: ProductDetail; + onSave: (p: Partial) => Promise; +} + +/** 类目树叶子节点 value 编码为 "category_id:type_id" */ +interface TreeOption { + title: string; + value: string; + selectable?: boolean; + children?: TreeOption[]; +} + +function buildTree(nodes: CategoryNode[], parentCid: number | null): TreeOption[] { + return (nodes || []).map((n) => { + const cid = n.description_category_id ?? parentCid; + const children = n.children || []; + // 叶子 = type 节点(有 type_id 且无子节点) + if (children.length === 0 && n.type_id != null) { + return { title: n.type_name || n.category_name || '?', value: `${cid}:${n.type_id}` }; + } + return { + title: n.category_name || n.type_name || '?', + value: `cat-${cid}`, + selectable: false, + children: buildTree(children, cid), + }; + }); +} + +interface MappedVal { + dictionary_value_id?: number; + value: string; +} + +function normalize(s: string): string { + return s.toLowerCase().trim().replace(/[,,::]/g, '').replace(/\s+/g, ' '); +} + +function bestMatch(paramKey: string, attrs: AttributeItem[]): AttributeItem | null { + const pk = normalize(paramKey); + if (!pk) return null; + let best: AttributeItem | null = null; + let bestScore = 0; + for (const a of attrs) { + const an = normalize(a.name); + if (!an) continue; + if (pk === an) return a; // 完全一致,直接命中 + if (an.includes(pk) || pk.includes(an)) { + if (2 > bestScore) { + best = a; + bestScore = 2; + } + } + } + return best; +} + +/** 字典值下拉(远程搜索) */ +function DictSelect({ + shopId, + categoryId, + typeId, + attributeId, + value, + onChange, +}: { + shopId: string; + categoryId: number; + typeId: number; + attributeId: number; + value?: MappedVal; + onChange: (v: MappedVal) => void; +}) { + const [options, setOptions] = useState>([]); + const [searching, setSearching] = useState(false); + + const fetchOptions = useCallback( + async (q?: string) => { + setSearching(true); + try { + const r = await attributeValues(shopId, attributeId, categoryId, typeId, q || undefined, 50); + setOptions((r.result ?? []).map((v) => ({ value: v.id, label: v.value }))); + } catch { + /* 搜索失败静默,用户可重试 */ + } finally { + setSearching(false); + } + }, + [shopId, attributeId, categoryId, typeId], + ); + + useEffect(() => { + fetchOptions(); + }, [fetchOptions]); + + // 已选值若不在当前选项里,补进去(回显已保存映射) + const opts = [...options]; + if (value?.dictionary_value_id != null && value.value && !opts.some((o) => o.value === value.dictionary_value_id)) { + opts.unshift({ value: value.dictionary_value_id, label: value.value }); + } + + return ( + ({ value: s.id, label: s.name }))} + /> + + + Ozon 类目 + + + + + + {!categoryId || !typeId ? ( + + ) : ( + + 属性映射 + {loading && } + {!loading && ( + + 共 {attributes.length} 个属性,必填 {required.length} 个 + + )} + + } + extra={ + + + + + } + > + {missingRequired.length > 0 && ( + 5 ? '…' : ''}`} + /> + )} + {sorted.length === 0 && !loading ? ( + + ) : ( +
+ {sorted.map((a) => { + const v = mapping[a.id]; + const isDict = !!a.dictionary_id; + return ( + +
+ + {a.is_required && *} + {a.name} + {a.is_collection && 多值} + + + + {isDict ? ( + setMapping((prev) => ({ ...prev, [a.id]: nv }))} + /> + ) : ( + + setMapping((prev) => ({ ...prev, [a.id]: { value: e.target.value } })) + } + /> + )} + + + ); + })} + + )} + + )} + + ); +} diff --git a/studio/src/pages/product/CopyPanel.tsx b/studio/src/pages/product/CopyPanel.tsx new file mode 100644 index 0000000..7471f5c --- /dev/null +++ b/studio/src/pages/product/CopyPanel.tsx @@ -0,0 +1,185 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Button, Card, Col, Input, message, Row, Select, Space, Tag, Typography } from 'antd'; +import { generateCopy, getAiModels, AiModelOption, CopyResponse } from '@/services/ai'; +import { ProductDetail } from '@/services/product'; +import { apiErrorMessage } from '@/services/api'; + +const { Text, Paragraph } = Typography; + +interface Props { + product: ProductDetail; + onSave: (p: Partial) => Promise | void; +} + +/** 左侧采集信息 + 模型/生成按钮,右侧推荐标题/简介/标签 */ +export default function CopyPanel({ product, onSave }: Props) { + const [models, setModels] = useState([]); + const [model, setModel] = useState(''); + const [sourceText, setSourceText] = useState(''); + const [generating, setGenerating] = useState(false); + const [result, setResult] = useState(null); + + const raw = useMemo(() => (product.raw ?? {}) as Record, [product.raw]); + + const defaultSource = useMemo(() => { + const parts: string[] = []; + const zhTitle = (raw.title_zh as string) || (raw.title as string); + if (zhTitle) parts.push(`商品名:${zhTitle}`); + const params = raw.params as Array<{ key: string; value: string }> | undefined; + if (Array.isArray(params)) { + parts.push(params.map((p) => `${p.key}: ${p.value}`).join('\n')); + } + if (typeof raw.desc === 'string' && raw.desc) parts.push(`详情:${raw.desc}`); + if (typeof raw.sellingPoints === 'string' && raw.sellingPoints) parts.push(`卖点:${raw.sellingPoints}`); + return parts.join('\n\n'); + }, [raw]); + + useEffect(() => { + if (!sourceText && defaultSource) setSourceText(defaultSource); + getAiModels() + .then((r) => { + setModels(r.models); + setModel(r.default || r.models[0]?.id || ''); + }) + .catch(() => { /* 模型列表失败不阻断 */ }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const onGenerate = async () => { + if (sourceText.trim().length < 10) { + message.warning('请先粘贴至少 10 字的商品资料'); + return; + } + setGenerating(true); + try { + const r = await generateCopy({ source_text: sourceText, model }); + setResult(r); + // 自动回填俄文简介 + if (r.description_ru) { + await onSave({ description: r.description_ru }); + message.success('已生成并自动回填俄文简介'); + } + } catch (e) { + message.error(apiErrorMessage(e)); + } finally { + setGenerating(false); + } + }; + + /** 回填标题:同时写入俄文 name 和中文 raw.title_zh */ + const applyTitle = async (titleRu: string, titleZh?: string) => { + const patch: Partial = { name: titleRu }; + if (titleZh) { + patch.raw = { ...raw, title_zh: titleZh }; + } + await onSave(patch); + message.success('标题已回填(中俄双语)'); + }; + + const applyDesc = async () => { + if (!result) return; + await onSave({ description: result.description_ru }); + message.success('已回填俄文简介'); + }; + + return ( + + + + {/* 显示中文原标题作为参考 */} + {((raw.title_zh as string) || (raw.title as string)) && ( +
+ 采集标题: + + {(raw.title_zh as string) || (raw.title as string)} + +
+ )} + setSourceText(e.target.value)} + placeholder="采集的商品资料(可编辑后生成)" + /> +
+ ({ value: s.id, label: s.name }))} + /> + +
+ Ozon 类目 + + + + + {/* 行 2:标题(中俄双栏,AI 生成) */} +
+ +
+ 标题 + + + + + + + + + 中文(采集/参考) + + setTitleZh(e.target.value)} + onBlur={(e) => saveTitleZh(e.target.value)} + placeholder="中文商品标题(参考)" + /> + + + + 俄文(发布用) + + onSave({ name: e.target.value })} + placeholder="Название товара на русском" + /> + + + + + {/* 行 3:型号 + 货号 */} + + + 型号 (model) + onModelChange(e.target.value)} + placeholder="如 PD-001" + suffix={ + + copyToClipboard(modelCode)} + /> + + } + /> + + + + 货号 offer_id + {prefix && ( + + 前缀:{prefix} + + )} + + + {prefix && ( + + )} + onSkuSuffixChange(e.target.value)} + placeholder="后缀" + /> + + + 包装重量 + + onSave({ weight: v ?? null })} + placeholder="数值" + min={0} + /> + onSave({ dimension_unit: v })} + style={{ marginLeft: 8 }} + options={[ + { value: 'mm', label: 'mm' }, + { value: 'cm', label: 'cm' }, + ]} + /> + + + onSave({ depth: v ?? null })} + placeholder="长" + min={0} + /> + onSave({ width: v ?? null })} + placeholder="宽" + min={0} + /> + onSave({ height: v ?? null })} + placeholder="高" + min={0} + /> + + + + + ); +} diff --git a/studio/src/pages/product/PriceInfoPanel.tsx b/studio/src/pages/product/PriceInfoPanel.tsx new file mode 100644 index 0000000..bc31c5f --- /dev/null +++ b/studio/src/pages/product/PriceInfoPanel.tsx @@ -0,0 +1,242 @@ +import { useEffect, useState } from 'react'; +import { Col, InputNumber, message, Radio, Row, Space, Tag, Typography } from 'antd'; +import { calculatePricing, LogisticsLevel, validateLogisticsLevel } from '@/pricing/pricing'; +import { getFxRate } from '@/services/fx'; +import { ProductDetail } from '@/services/product'; +import { apiErrorMessage } from '@/services/api'; +import FieldLabel, { fieldRowStyle } from './FieldLabel'; + +const { Text } = Typography; + +interface Props { + product: ProductDetail; + onSave: (p: Partial) => void; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type PricingPayload = Record; + +/** 包装信息 → 计价输入:重量统一 g */ +function weightGrams(product: ProductDetail): number { + const w = product.weight ?? 0; + return product.weight_unit === 'kg' ? w * 1000 : w; +} + +/** 包装信息 → 计价输入:尺寸统一 cm */ +function dimsCm(product: ProductDetail): { l: number; w: number; h: number } { + const scale = product.dimension_unit === 'cm' ? 1 : 0.1; // mm → cm + return { + l: (product.depth ?? 0) * scale, + w: (product.width ?? 0) * scale, + h: (product.height ?? 0) * scale, + }; +} + +/** + * 售价信息:售价/划线价(CNY)+ 定价参数(进货价/净利率/物流等级/汇率/预留折扣)。 + * 重量、尺寸直接读「主要信息」的包装字段,不重复填写。 + * 任一定价参数变化即重算并回填售价(= 销售价)与划线价(= 预留折扣前价格)。 + */ +export default function PriceInfoPanel({ product, onSave }: Props) { + const pricing = (product.pricing ?? {}) as PricingPayload; + const [purchasePrice, setPurchasePrice] = useState(pricing.purchasePrice ?? 30); + const [profitRate, setProfitRate] = useState(pricing.profitRate ?? 100); + const [level, setLevel] = useState((pricing.logisticsLevel as LogisticsLevel) ?? 'low'); + const [reserve, setReserve] = useState(pricing.discountReserve ?? 50); + const [fxRate, setFxRate] = useState(product.fx_rate ?? pricing.fxRate ?? 0); + + // 币种固定人民币:历史数据(默认 RUB)打开编辑页时纠正一次 + useEffect(() => { + if (product.currency_code !== 'CNY') onSave({ currency_code: 'CNY' }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // 汇率:未快照时拉取;已有计价记录的则跟随重算 + useEffect(() => { + if (!fxRate) { + getFxRate() + .then((r) => { + setFxRate(r.rate); + if (pricing.calculatedAt) recalc({ fxRate: r.rate }); + }) + .catch((e) => message.error(apiErrorMessage(e))); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const tdPrice = pricing.tdPrice ?? 3; + + /** 用当前参数(可被 patch 覆盖)实时计算,不落库 */ + const compute = (patch: Partial<{ purchasePrice: number; profitRate: number; level: LogisticsLevel; reserve: number; fxRate: number }> = {}) => + calculatePricing({ + purchasePrice: patch.purchasePrice ?? purchasePrice, + profitRate: patch.profitRate ?? profitRate, + logisticsLevel: patch.level ?? level, + weightG: weightGrams(product), + dims: dimsCm(product), + tdPrice, + discountReserve: patch.reserve ?? reserve, + fxRate: patch.fxRate ?? fxRate, + }); + + /** 重算并落库:定价参数 + 售价/划线价一并保存 */ + const recalc = (patch: Partial<{ purchasePrice: number; profitRate: number; level: LogisticsLevel; reserve: number; fxRate: number }> = {}) => { + const p = { + purchasePrice: patch.purchasePrice ?? purchasePrice, + profitRate: patch.profitRate ?? profitRate, + level: patch.level ?? level, + reserve: patch.reserve ?? reserve, + fxRate: patch.fxRate ?? fxRate, + }; + const r = compute(patch); + onSave({ + pricing: { + ...pricing, + purchasePrice: p.purchasePrice, + profitRate: p.profitRate, + logisticsLevel: p.level, + weightG: weightGrams(product), + dims: dimsCm(product), + tdPrice, + discountReserve: p.reserve, + fxRate: p.fxRate, + logisticsFee: r.logisticsFee, + fullCommission: r.fullCommission, + totalCost: r.totalCost, + sellingPriceCny: r.sellingPriceCny, + sellingPriceRub: r.sellingPriceRub, + calculatedAt: new Date().toISOString(), + }, + fx_rate: p.fxRate, + price: r.sellingPriceCny, + old_price: r.reservedPriceCny, + currency_code: 'CNY', + }); + }; + + const preview = fxRate ? compute() : null; + const hint = preview ? validateLogisticsLevel(preview.sellingPriceRub, level) : ''; + + return ( +
+ {/* 售价 / 划线价 / 币种 */} + +
+ 售价 ¥ + onSave({ price: v ?? null, currency_code: 'CNY' })} + /> + + + 划线价 ¥ + onSave({ old_price: v ?? null })} + /> + + + 币种 + 人民币(CNY) + + + + 售价/划线价随定价参数自动回填,也可手动微调 + + + + + {/* 定价参数 */} + + + 进货价 ¥ + { + setPurchasePrice(v ?? 0); + recalc({ purchasePrice: v ?? 0 }); + }} + /> + + + 净利率 % + { + setProfitRate(v ?? 0); + recalc({ profitRate: v ?? 0 }); + }} + /> + + + 汇率 ¥→₽ + { + setFxRate(v ?? 0); + recalc({ fxRate: v ?? 0 }); + }} + /> + + + 预留折扣 % + { + setReserve(v ?? 0); + recalc({ reserve: v ?? 0 }); + }} + /> + + + + 重量/尺寸取自包装信息 + + + + + {/* 物流等级 */} +
+ 物流等级 + { + setLevel(e.target.value); + recalc({ level: e.target.value }); + }} + options={[ + { value: 'low', label: '低 (low)' }, + { value: 'high', label: '高 (high)' }, + { value: 'high2', label: 'Premium (high2)' }, + ]} + /> +
+ + {/* 实时结果摘要 */} + {preview && ( + + 完全成本 ¥ {preview.totalCost.toFixed(2)} + 物流费 ¥ {preview.logisticsFee.toFixed(2)} + 销售价 ₽ {preview.sellingPriceRub.toFixed(0)}(参考) + {hint && {hint}} + + )} + + ); +} diff --git a/studio/src/pages/product/ProductAttributesPanel.tsx b/studio/src/pages/product/ProductAttributesPanel.tsx new file mode 100644 index 0000000..5115253 --- /dev/null +++ b/studio/src/pages/product/ProductAttributesPanel.tsx @@ -0,0 +1,161 @@ +import { useState } from 'react'; +import { Col, Divider, Input, Row, Tag, Typography } from 'antd'; +import { ProductDetail } from '@/services/product'; +import CopyPanel from './CopyPanel'; + +const { Text } = Typography; + +interface Props { + product: ProductDetail; + onSave: (p: Partial) => Promise | void; +} + +/** 产品属性:采集参数只读展示 / 条形码 / 品牌 / 简介 / AI 文案 */ +export default function ProductAttributesPanel({ product, onSave }: Props) { + const raw = (product.raw ?? {}) as Record; + const params = raw.params as Array<{ key: string; value: string }> | undefined; + + const [descExpanded, setDescExpanded] = useState(false); + + const updateRaw = (patch: Record) => { + onSave({ raw: { ...raw, ...patch } }); + }; + + const sellingPoints = typeof raw.sellingPoints === 'string' ? raw.sellingPoints : ''; + const desc = typeof raw.desc === 'string' ? raw.desc : ''; + + return ( +
+ {/* 基础字段:品牌 + 条形码 */} + +
+ + 品牌(Brand) + + updateRaw({ brand: e.target.value })} + placeholder="采集到的品牌名" + /> + + + + 条形码(Barcode) + + onSave({ barcode: e.target.value })} + placeholder="EAN / UPC / 留空" + /> + + + + 主题标签(内部备注) + + updateRaw({ tags: e.target.value })} + placeholder="逗号分隔,内部用" + /> + + + + {/* 俄文简介(来自 AI 生成或手动) */} +
+ + 俄文简介(description) + + onSave({ description: e.target.value })} + placeholder="Описание товара на русском — можно сгенерировать через AI ниже" + /> +
+ + {/* 采集参数展示(只读,参考用) */} + {Array.isArray(params) && params.length > 0 && ( +
+
+ + 采集参数({params.length} 项,只读参考) + + {params.length > 8 && ( + setDescExpanded(!descExpanded)}> + {descExpanded ? '收起' : `展开全部 ${params.length} 项`} + + )} +
+ + {(descExpanded ? params : params.slice(0, 8)).map((p, i) => ( +
+
+ + {p.key} + + + {p.value} + +
+ + ))} + + {!descExpanded && params.length > 8 && ( + + 还有 {params.length - 8} 项未显示 + + )} + + )} + + {/* 采集卖点 / 描述(只读参考) */} + {(sellingPoints || desc) && ( +
+ {sellingPoints && ( + <> + + 采集卖点(参考) + + + {sellingPoints} + + + )} + {desc && ( + <> + + 采集描述(参考) + + + {desc.slice(0, 300)} + {desc.length > 300 && '…'} + + + )} +
+ )} + + + + + + ); +} diff --git a/studio/src/pages/product/ProductEditPage.tsx b/studio/src/pages/product/ProductEditPage.tsx new file mode 100644 index 0000000..11f4771 --- /dev/null +++ b/studio/src/pages/product/ProductEditPage.tsx @@ -0,0 +1,162 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useParams } from 'react-router'; +import { Card, Menu, Space, Spin, Typography, message } from 'antd'; +import { getProduct, updateProduct, listAssets, ProductDetail, ProductAsset } from '@/services/product'; +import { apiErrorMessage } from '@/services/api'; +import { STAGE_LABEL, STAGE_COLOR } from '../collection/CollectionPage'; +import MainInfoPanel from './MainInfoPanel'; +import ProductAttributesPanel from './ProductAttributesPanel'; +import PriceInfoPanel from './PriceInfoPanel'; +import ImagePanel from './ImagePanel'; +import PublishPanel from './PublishPanel'; +import AttributePanel from './AttributePanel'; + +const { Title, Text } = Typography; + +const SECTIONS = [ + { id: 'section-main', label: '主要信息' }, + { id: 'section-sales', label: '售价信息' }, + { id: 'section-attrs', label: '产品属性' }, + { id: 'section-images', label: '图片素材' }, + { id: 'section-mapping', label: '属性映射' }, + { id: 'section-publish', label: '发布' }, +]; + +export default function ProductEditPage() { + const { id } = useParams(); + const [product, setProduct] = useState(null); + const [assets, setAssets] = useState([]); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [active, setActive] = useState('section-main'); + + const load = useCallback(async () => { + if (!id) return; + setLoading(true); + try { + const [p, a] = await Promise.all([getProduct(id), listAssets(id)]); + setProduct(p); + setAssets(a); + } catch (e) { + message.error(apiErrorMessage(e)); + } finally { + setLoading(false); + } + }, [id]); + + useEffect(() => { + load(); + }, [load]); + + // 滚动监听:高亮当前区域 + useEffect(() => { + const onScroll = () => { + let current = SECTIONS[0].id; + for (const s of SECTIONS) { + const el = document.getElementById(s.id); + if (el && el.getBoundingClientRect().top <= 90) current = s.id; + } + setActive(current); + }; + window.addEventListener('scroll', onScroll, { passive: true }); + return () => window.removeEventListener('scroll', onScroll); + }, []); + + const save = useCallback( + async (partial: Partial) => { + if (!id || !product) return; + setSaving(true); + try { + const updated = await updateProduct(id, partial); + setProduct(updated); + } catch (e) { + message.error(apiErrorMessage(e)); + } finally { + setSaving(false); + } + }, + [id, product], + ); + + if (loading || !product) { + return ( +
+ +
+ ); + } + + const raw = (product.raw ?? {}) as Record; + const titleZh = ((raw.title_zh as string) ?? (raw.title as string) ?? '').trim(); + + return ( +
+
+
+
+ + + {titleZh || product.name || '(未命名商品)'} + + + {STAGE_LABEL[product.stage] || product.stage} + + + {saving ? '保存中…' : '已自动保存'} +
+ {product.name && product.name !== titleZh && ( + + 俄文:{product.name} + + )} +
+ +
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + {/* 右侧区域导航 */} +
+
+ ({ key: s.id, label: s.label }))} + onClick={({ key }) => { + document.getElementById(key)?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }} + /> +
+
+
+ ); +} diff --git a/studio/src/pages/product/PublishPanel.tsx b/studio/src/pages/product/PublishPanel.tsx new file mode 100644 index 0000000..6c30408 --- /dev/null +++ b/studio/src/pages/product/PublishPanel.tsx @@ -0,0 +1,153 @@ +import { useEffect, useState } from 'react'; +import { Alert, Button, Card, Descriptions, Empty, List, message, Select, Space, Tag, Typography } from 'antd'; +import { listShops, ShopItem } from '@/services/shop'; +import { publishProduct, getPublishTask, publishHistory, PublishTask } from '@/services/publish'; +import { ProductDetail } from '@/services/product'; +import { apiErrorMessage } from '@/services/api'; + +const { Text } = Typography; + +const STATUS_TAG: Record = { + pending: { color: 'default', text: '等待中' }, + processing: { color: 'processing', text: '处理中' }, + moderation: { color: 'orange', text: '审核中' }, + imported: { color: 'green', text: '发布成功' }, + failed: { color: 'red', text: '失败' }, +}; + +interface Props { + product: ProductDetail; + onSave: (p: Partial) => void; +} + +export default function PublishPanel({ product, onSave }: Props) { + const [shops, setShops] = useState([]); + const [shopId, setShopId] = useState(); + const [publishing, setPublishing] = useState(false); + const [task, setTask] = useState(null); + const [history, setHistory] = useState>>([]); + + const loadShops = async () => { + try { + const s = await listShops(); + setShops(s); + if (s.length && !shopId) setShopId(s[0].id); + } catch (e) { + message.error(apiErrorMessage(e)); + } + }; + + const loadHistory = async () => { + try { + setHistory(await publishHistory(product.id)); + } catch { + /* 忽略 */ + } + }; + + useEffect(() => { + loadShops(); + loadHistory(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [product.id]); + + const doPublish = async () => { + if (!shopId) { + message.warning('请先选择店铺(需在「店铺管理」中添加)'); + return; + } + setPublishing(true); + setTask(null); + try { + const r = await publishProduct(product.id, shopId); + // 轮询直到终态 + for (let i = 0; i < 10; i++) { + await new Promise((res) => setTimeout(res, 3000)); + const t = await getPublishTask(r.task_id); + setTask(t); + if (t.status === 'imported' || t.status === 'failed') { + if (t.status === 'imported') { + onSave({}); + message.success('发布成功'); + } + break; + } + } + loadHistory(); + } catch (e) { + message.error(apiErrorMessage(e)); + } finally { + setPublishing(false); + } + }; + + const errors = (task?.errors as Array<{ description?: string; code?: string }> | null) ?? []; + + return ( +
+ {product.ozon_product_id && ( + + )} + + + 目标店铺: +
+ + setOpen(false)} + destroyOnClose + > +
+ + + + + + + + + + +