18 KiB
18 KiB
类目属性与字典值 API
官方文档:https://docs.ozon.ru/api/seller/zh/#operation/DescriptionCategoryAPI_GetAttributes
1. 获取类目属性
接口信息
| 项 | 值 |
|---|---|
| 方法 | POST |
| 路径 | /v1/description-category/attribute |
| 鉴权 | 需要 Client-Id + Api-Key |
| 用途 | 获取指定类目的所有属性(发布商品时需填写) |
请求
{
"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 |
响应
{
"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 的属性) |
请求
{
"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 |
响应
{
"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 继续分页) |
分页示例
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 |
| 用途 | 模糊搜索字典值(避免拉全量字典) |
请求
{
"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 |
响应
{
"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:归一化 + 模糊匹配
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:关键词映射表
# 预定义常见映射(中文采集 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. 必填项校验
发布前校验
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. 缓存策略
属性列表缓存
# 按 (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
字典值缓存(按需)
# 字典值可能很大(数万条),不全量缓存
# 策略:用户映射到某属性时,才拉该属性的字典(且优先用 /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:
{
"id": 9048,
"values": [{ "value": "500" }] // 容量,数值类型
}
Q4: 如何处理多值属性?
A: is_collection=true 时传数组:
{
"id": 10096,
"values": [
{ "value": "家用" },
{ "value": "办公" }
]
}
10. V2 项目集成
API 层(已实现)
# 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}
前端(待实现)
// 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 (
<div>
{autoMatch().map((item, i) => (
<Form.Item
key={i}
label={item.key}
required={item.attr?.is_required}
>
{item.attr?.dictionary_id > 0 ? (
<Select
showSearch
placeholder="选择字典值"
onSearch={(q) => fetchValues(item.attr.id, q)}
/>
) : (
<Input defaultValue={item.value} />
)}
</Form.Item>
))}
</div>
);
}
相关文档
- 02-category-tree.md —— 获取类目树
- 04-product-import.md —— 发布商品(使用属性)
- docs/v2/database.md §2.7 —— 属性缓存表结构
- docs/v2/ozon-publish.md §3 —— 属性映射策略