Files
ozon-seller-kit/docs/ozon-seller-api/03-category-attributes.md
T

18 KiB
Raw Blame History

类目属性与字典值 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>
  );
}

相关文档