# 类目属性与字典值 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 (