Skill: kuangzhanzhiwang/bilibili-garb (version 1.0.1)

--- name: bilibili-garb description: Bilibili garb (个性装扮) data collection and management. Search garb items, query suit/collection details, scan benefit data for owned items (including discontinued), and determine scarcity tiers. Use when working with Bilibili's personalization system (装扮), digital card collections (收藏集), suit items (套装子项), or any task involving bilibili.com/garb or bilibili.com/h5/mall/digital-card endpoints. Triggers on "bilibili garb", "B站装扮", "装扮搜索", "套装查询", "收藏集", "benefit扫描", "品级判别". --- # Bilibili Garb (B站个性装扮) Collect and manage Bilibili personalization items: garb suits, digital card collections, and benefit sub-items. ## Setup All authentication credentials are read from environment variables or a config file. Create `configs/bili-api-creds.json` in your workspace: ```json { "appkey": "27eb53fc9058f8c3", "appsecret": "<obtain from Bilibili mobile client>", "access_key": "<your access_key>", "csrf": "<your bili_jct>", "DedeUserID": "<your uid>", "SESSDATA": "<your SESSDATA>" } ``` Or export environment variables: ```bash export BILI_SESSDATA="<your SESSDATA>" export BILI_ACCESS_KEY="<your access_key>" export BILI_CSRF="<your bili_jct>" export BILI_UID="<your uid>" ``` > **How to obtain credentials**: Capture from Bilibili mobile app HTTP traffic (e.g., mitmproxy, Charles). The `access_key` expires periodically and must be refreshed. ## Commands ### Search Garb Items ```bash bash scripts/bilibili-garb-search.sh "关键词" ``` Searches both the official API and a local gallery database. Outputs Markdown with: - Collection items (收藏集) with `biz_id` - Suit items (套装) with `item_id` - Discontinued items from local gallery marked `[藏馆-绝版]` ### Query Collection/Suit Details ```bash bash scripts/bilibili-garb-collection.sh -i <ID> ``` - ID ≤ 6 digits → collection (收藏集) mode - ID > 6 digits → suit (套装) mode - Falls back to local gallery database for discontinued items ### Scan Benefit Data ```bash python3 scripts/garb-benefit-scan.py [options] ``` Scans owned garb items from `data/decorations-database.json`, calls benefit API for each, and appends results to `data/garb-benefit-results.ndjson`. Options: - `--limit N` — process only N items - `--dry-run` — show what would be scanned without making API calls - `--force` — rescan items that already have benefit data - `--debug` — output full API responses Supports resume (Ctrl+C safe) and deduplication. ## Key API Knowledge See [references/bilibili-garb-api-reference.md](references/bilibili-garb-api-reference.md) for full API documentation. Critical points: 1. **Benefit API** (`/x/garb/v2/user/suit/benefit`) is the only way to get data for discontinued items. Requires sign authentication. 2. **DIY suits**: When `item_id` contains a hyphen (e.g., `1775103232001-0`), pass `biz_id` as the `item_id` parameter instead — the original `item_id` returns `-400`. 3. **`part` parameter**: Only one call with `part=space_bg` returns all 9 sub-item types. No need to iterate. 4. **Scarcity tiers**: Use `item_list` API's `scarcity` field as the primary source. When `scarcity_rate=2` and `rate2_count==1`, default to small-hidden (30), do not auto-upgrade to large-hidden. 5. **DLC avatar frames**: Must come from `lottery_home_detail`, never from collection's own `frame`/`frame_image`. ## Standard Operating Procedure See [references/bilibili-garb-sop.md](references/bilibili-garb-sop.md) for step-by-step workflows.

path: _meta.json
content: ownerId: kn7ek19rvqf67de6rzpyx4mfvh82afj3
slug: bilibili-garb
version: 1.0.0
license: Apache-2.0
publishedAt: 1775939715217

path: references/bilibili-garb-api-reference.md
content: # B站个性装扮 API 参考文档

## 目录

1. [认证与签名](#认证与签名)
2. [公开接口](#公开接口)
3. [认证接口](#认证接口)
4. [数据获取流程](#数据获取流程)
5. [注意事项](#注意事项)

---

## 认证与签名

### 凭证参数

| 参数 | 说明 | 获取方式 |
|------|------|----------|
| `appkey` | 应用密钥ID | B站移动端内置，公开值 `27eb53fc9058f8c3` |
| `appsecret` | 应用密钥 | 需从B站客户端获取 |
| `access_key` | 用户授权令牌 | 从B站移动端HTTP流量抓包获取，会过期 |
| `csrf` (bili_jct) | CSRF令牌 | 从Cookie中获取 |
| `SESSDATA` | 会话凭证 | 从Cookie中获取 |
| `DedeUserID` | 用户UID | 从Cookie中获取 |

### Sign签名算法

用于需要认证的接口（benefit、asset等）。

1. 将所有请求参数（含 `appkey`, `access_key`, `csrf`, `ts`, `mobi_app`, `platform`）按**键名字典序**排序
2. 使用 `urllib.parse.urlencode(sorted_params)` 拼接为查询字符串
3. 追加 `APPSECRET`：`sign_str = query + APPSECRET`
4. 计算 MD5：`sign = md5(sign_str.encode('utf-8')).hexdigest()`
5. 将 `sign` 追加到请求参数中

### 请求示例

```python
import hashlib
import urllib.parse
import time

def calc_sign(params, appkey, access_key, csrf, appsecret):
    ts = str(int(time.time()))
    all_params = dict(params)
    all_params.update({
        "access_key": access_key,
        "appkey": appkey,
        "csrf": csrf,
        "mobi_app": "iphone",
        "platform": "ios",
        "ts": ts,
    })
    sorted_items = sorted(all_params.items())
    query = urllib.parse.urlencode(sorted_items)
    sign_str = query + appsecret
    sign = hashlib.md5(sign_str.encode("utf-8")).hexdigest()
    all_params["sign"] = sign
    return all_params
```

---

## 公开接口

以下接口无需认证（不需要access_key/sign/cookie）。

### 1. 装扮搜索

```
GET https://api.bilibili.com/x/garb/v2/mall/home/search
```

| 参数 | 类型 | 说明 |
|------|------|------|
| `key_word` | string | 搜索关键词（需URL编码） |
| `pn` | int | 页码，默认1 |
| `ps` | int | 每页数量，默认20 |
| `mobi_app` | string | `iphone` |
| `platform` | string | `ios` |
| `appkey` | string | `27eb53fc9058f8c3` |

**返回**：`data.list[]`，每项含：
- `item_id` — 商品ID
- `name` — 名称
- `state` — 状态：`active`/`ended`/`pending`
- `sale_count_desc` — 销量描述
- `properties.dlc_act_id` — 收藏集活动ID（有此项则为收藏集）
- `properties.dlc_lottery_sale_quantity` — 收藏集实际销量
- `properties.image_cover` — 封面图URL
- `properties.sale_quantity` — 套装总量
- `properties.item_stock_surplus` — 套装剩余库存

### 2. 装扮详情

```
GET https://api.bilibili.com/x/garb/v2/mall/suit/detail
```

| 参数 | 类型 | 说明 |
|------|------|------|
| `item_id` | int/string | 套装ID |
| `part` | string | 子项类型：`suit`/`emoji`/`skin`/`pendant`/`space_bg`/`loading`/`play_icon`/`thumbup`/`card_bg` |

**返回**：`data.suit_items` → 各子类型列表

**⚠️ 注意**：绝版（已下架）装扮的 `suit_items` 为空！需用 benefit 接口。

### 3. 收藏集基础信息

```
GET https://api.bilibili.com/x/vas/dlc_act/act/basic
```

| 参数 | 类型 | 说明 |
|------|------|------|
| `act_id` | int | 收藏集活动ID |
| `mobi_app` | string | `iphone` |
| `platform` | string | `ios` |
| `appkey` | string | `27eb53fc9058f8c3` |

**返回**：`data.act_title`（名称）、`data.start_time`/`end_time`、`data.lottery_list[]`、`data.total_buy_cnt`

即使收藏集已下架，此接口通常仍能返回基础信息。

### 4. DLC卡池信息

```
GET https://api.bilibili.com/x/vas/dlc_act/lottery_home_detail
```

| 参数 | 类型 | 说明 |
|------|------|------|
| `act_id` | int | 收藏集活动ID |
| `lottery_id` | int | 抽奖池ID |

**返回**：
- `data.item_list[]` — 卡池配置，每项含 `card_info`（`card_name`, `card_scarcity`）
- `data.collect_list.collect_infos[]` — 收集奖励列表
  - `redeem_item_type==3` → 头像框（`redeem_item_name`, `redeem_item_image`）

**⚠️ 关键**：DLC头像框必须从此接口获取，不能用收藏集自带的 `frame`/`frame_image` 字段。

---

## 认证接口

以下接口需要 sign 签名 + Cookie。

### 5. ⭐ 装扮权益详情（Benefit）

**绝版装扮的唯一数据源。**

```
GET https://api.bilibili.com/x/garb/v2/user/suit/benefit
```

| 参数 | 类型 | 说明 |
|------|------|------|
| `item_id` | int/string | 套装ID（DIY套装必须传biz_id） |
| `part` | string | 推荐固定传 `space_bg`（一次返回全部子项） |
| `is_diy` | string | `0` 或 `1`（DIY套装检测：item_id含横杠或非纯数字） |
| `vmid` | string | 用户UID |
| + 签名参数 | | `appkey`, `access_key`, `csrf`, `ts`, `mobi_app`, `platform`, `sign` |

**Header**：`Cookie: SESSDATA=...; bili_jct=...; DedeUserID=...`

**返回**：`data.suit_items` → 9种子项完整数据：
- `card` (0-2项)、`card_bg` (1项)、`emoji_package` (1项)
- `loading` (1项)、`pendant` (1项)、`play_icon` (1项)
- `skin` (1项)、`space_bg` (1项)、`thumbup` (1项)

每项含：`item_id`, `name`, `properties`（横竖图/视频/粉丝牌/主题色/表情列表等）

**DIY套装关键**：
- 当 `item_id` 格式为 `1775103232001-0`（含横杠）时，API的 `item_id` 参数必须传 `biz_id`（如 `409236701`），否则返回 `code=-400`
- `is_diy` 参数传 `0` 或 `1` 都可以成功（只要 `item_id` 用 `biz_id`）

**part参数优化**：
- `part` 只决定返回数据的"视角"，`suit_items` 始终包含所有9种子项
- 只需 `part=space_bg` 一次调用即可获取全部数据，无需遍历

### 6. 用户资产列表

```
GET https://api.bilibili.com/x/garb/user/asset
```

| 参数 | 类型 | 说明 |
|------|------|------|
| `part` | string | `suit` 或 `emoji_package` |
| `state` | string | `active` |
| `pn` | int | 页码 |
| `ps` | int | 每页数量 |
| + 签名参数 | | 同上 |

### 7. 用户装扮资产详情

```
GET https://api.bilibili.com/x/garb/v2/user/suit/asset
```

| 参数 | 类型 | 说明 |
|------|------|------|
| `item_id` | int | 套装ID |
| `part` | string | `suit` |
| `trial` | string | `0` |
| + 签名参数 | | 同上 |

### 8. 收藏集列表

```
GET https://api.bilibili.com/x/garb/right/collection/list
```

| 参数 | 类型 | 说明 |
|------|------|------|
| `vmid` | string | 用户UID |
| `ps` | int | 每页数量 |
| `source` | string | `collection_all_sort` |
| `order_type` | string | `1` |
| `hidden_filter` | string | `0` |
| + 签名参数 | | `access_key`, `appkey`, `ts`, `mobi_app`, `platform` |

### 9. 用户持有卡片

```
GET https://api.bilibili.com/x/vas/user/dlc/card/list
```

| 参数 | 类型 | 说明 |
|------|------|------|
| `vmid` | string | 用户UID |
| `act_id` | int | 收藏集活动ID |
| `page` | int | 页码 |
| `page_size` | int | 每页数量 |

**Header**：`Cookie: SESSDATA=...`

**返回**：`card_list[]`，每项含 `card_item.card_name`、`card_no`

### 10. 转让记录

```
GET https://api.bilibili.com/x/vas/dlc_act/transfer/listV2
```

| 参数 | 类型 | 说明 |
|------|------|------|
| `scene` | int | `1`=收到的 / `2`=送出的 |
| `transfer_status` | int | `3`=已接收 / `4`=待领取 |
| + 签名参数 | | 同上 |

---

## 数据获取流程

### 已拥有装扮(含绝版)完整信息

```
benefit API (item_id=套装biz_id, part=space_bg)
  → suit_items → 获取9种各子项的 item_id + properties
  → 如需更详细：suit/detail (子项item_id, part=对应类型)
```

### 收藏集卡片信息

```
act/basic (公开) → lottery_id 列表
lottery_home_detail (公开) → 卡池配置、稀缺度、头像框
card/list (Cookie认证) → card_no 编号
```

### 品级判别流程

1. **公开API优先**：`item_list` 的 `scarcity` 字段最可靠
   - `10` = 普通(N)
   - `20` = 稀有(R)
   - `30` = 小隐藏(SR)
   - `40` = 大隐藏(SSR)
2. **NDJSON次之**：从本地数据库的 `scarcity` 字段
3. **保守兜底**：当背包API `scarcity_rate=2` 且 `rate2_count==1` 时，**默认小隐藏(30)**，不自动升大隐藏，需用户确认

---

## 注意事项

1. **Benefit API** 是绝版装扮的唯一数据源，需 sign 签名
2. **access_key 会过期**，需定期从移动端流量抓包刷新
3. **请求间隔** 0.5-1 秒防风控
4. **DIY套装**：benefit API 的 `item_id` 参数必须传 `biz_id`，否则报 `-400`
5. **DLC头像框**：必须从 `lottery_home_detail` 获取，不能用收藏集自身的 `frame`/`frame_image`
6. **part参数**：只需 `space_bg` 一次调用返回全部9种子项，无需遍历
7. 服务器IP可能被B站部分接口封禁，benefit/sign签名接口通常可用


path: references/bilibili-garb-sop.md
content: # B站个性装扮标准操作流程 (SOP)

## 目录

1. [日常查询流程](#日常查询流程)
2. [Benefit扫描流程](#benefit扫描流程)
3. [绝版装扮数据恢复](#绝版装扮数据恢复)
4. [收藏集卡片查询](#收藏集卡片查询)
5. [品级判别流程](#品级判别流程)
6. [凭证更新](#凭证更新)

---

## 日常查询流程

### 搜索装扮

```bash
bash scripts/bilibili-garb-search.sh "关键词"
```

输出分为三类：
1. **`[官方]`** — B站搜索API返回的在售商品
2. **`[藏馆-绝版]`** — 本地数据库中的下架商品（官方API已查不到）

搜索结果中：
- 收藏集使用 `biz_id`（≤6位数字）
- 套装使用 `item_id`（>6位数字）

### 查看详情

```bash
bash scripts/bilibili-garb-collection.sh -i <ID>
```

脚本自动判断类型：
- ID ≤ 6位 → 收藏集模式
- ID > 6位 → 套装模式

查询策略（按优先级）：
1. 官方搜索API
2. `act/basic` API（即使下架也能获取基础信息）
3. 本地藏馆数据库回退

---

## Benefit扫描流程

### 前置准备

1. 确认 `data/decorations-database.json` 存在（用户装扮资产数据库）
2. 确认凭证有效（`configs/bili-api-creds.json` 或环境变量）

### 执行扫描

```bash
# 全量扫描
python3 scripts/garb-benefit-scan.py

# 限制数量（测试用）
python3 scripts/garb-benefit-scan.py --limit 5

# 预览模式（不实际请求）
python3 scripts/garb-benefit-scan.py --dry-run

# 强制重扫已有数据
python3 scripts/garb-benefit-scan.py --force

# 调试模式
python3 scripts/garb-benefit-scan.py --debug --limit 3
```

### 扫描逻辑

1. 读取 `decorations-database.json`
2. 过滤：只保留 `biz_type=1`（装扮套装）且 `owned>=1` 的记录
3. 去重：跳过已有 benefit 数据的记录（除非 `--force`）
4. DIY检测：`item_id` 含横杠或非纯数字 → `is_diy=1`
5. 调用 benefit API（DIY套装用 `biz_id` 作为 `item_id` 参数）
6. 完整提取 `properties` 所有非空字段
7. 追加写入 `data/garb-benefit-results.ndjson`

### 中断恢复

- `Ctrl+C` 安全退出，进度自动保存
- 重新运行自动从断点续传
- 进度文件：`data/garb-benefit-scan.progress.json`

### 结果解读

```json
{
  "biz_id": 59673,
  "item_id": 59672,
  "is_diy": false,
  "benefit_items": {
    "card": [{"item_id": 123, "name": "...", "image": "..."}],
    "space_bg": [{"item_id": 456, "image": "...", "image_vertical": "..."}],
    "emoji_package": [{"item_id": 789, "emoji_list": [...]}]
  }
}
```

---

## 绝版装扮数据恢复

绝版（已下架）装扮无法通过 `suit/detail` 获取数据。恢复流程：

1. **确认拥有**：从 `decorations-database.json` 确认 `owned=1`
2. **调用 benefit**：使用 `part=space_bg`，DIY套装传 `biz_id`
3. **提取子项**：从 `suit_items` 获取9种子项的 `item_id` + `properties`
4. **如需详情**：用子项 `item_id` 调 `suit/detail?item_id=X&part=对应类型`

---

## 收藏集卡片查询

### 步骤

1. 获取收藏集基础信息：
   ```
   GET /x/vas/dlc_act/act/basic?act_id=<ID>
   → 获取 lottery_id
   ```

2. 获取卡池配置和头像框：
   ```
   GET /x/vas/dlc_act/lottery_home_detail?act_id=<ID>&lottery_id=<LOTTERY_ID>
   → item_list: 卡片名称、稀缺度
   → collect_list.collect_infos: 收集奖励（含头像框）
   ```

3. 获取持有卡片编号（需Cookie认证）：
   ```
   GET /x/vas/user/dlc/card/list?vmid=<UID>&act_id=<ID>
   → card_list: card_no 编号
   ```

---

## 品级判别流程

### 判别优先级

1. **公开API `item_list` 的 `scarcity`** — 最可靠
   - `10` = 普通(N)
   - `20` = 稀有(R)
   - `30` = 小隐藏(SR)
   - `40` = 大隐藏(SSR)

2. **本地NDJSON数据库** — 次之

3. **保守兜底** — 当 `scarcity_rate=2` 且 `rate2_count==1` 时：
   - **默认小隐藏(30)**
   - **不自动升大隐藏(SSR)**
   - 需用户确认后方可调整

### ⚠️ 易错点

- DLC头像框 ≠ 常驻头像框：播报DLC时头像框必须从 `lottery_home_detail` 取
- 收藏集自带的 `frame`/`frame_image` 不是DLC头像框

---

## 凭证更新

### 何时更新

- `access_key` 过期（API返回错误码）
- Cookie失效（接口返回未登录）

### 更新方法

1. 使用抓包工具（mitmproxy/Charles）捕获B站移动端HTTPS流量
2. 找到任意API请求中的 `access_key` 参数
3. 找到Cookie中的 `SESSDATA`、`bili_jct`、`DedeUserID`
4. 更新 `configs/bili-api-creds.json`：

```json
{
  "appkey": "27eb53fc9058f8c3",
  "appsecret": "<from mobile client>",
  "access_key": "<new access_key>",
  "csrf": "<new bili_jct>",
  "DedeUserID": "<uid>",
  "SESSDATA": "<new SESSDATA>"
}
```

### 验证

```bash
# 测试benefit API是否可用
curl -s "https://api.bilibili.com/x/garb/v2/user/suit/benefit?item_id=59672&part=space_bg&is_diy=0&vmid=<UID>&access_key=<ACCESS_KEY>&appkey=27eb53fc9058f8c3&csrf=<CSRF>&mobi_app=iphone&platform=ios&ts=<TIMESTAMP>&sign=<SIGN>" \
  -H "Cookie: SESSDATA=<SESSDATA>; bili_jct=<CSRF>; DedeUserID=<UID>"
```


path: scripts/bilibili-garb-collection.sh
content: #!/bin/bash
# Bilibili Garb Collection Query v7.5 (sanitized)
# 查询收藏集/套装详情 - 数据来源：B站官方API + 藏馆数据库回退
# 输出格式：Markdown

ID=""
while getopts "i:" opt; do
    case $opt in
        i) ID="$OPTARG" ;;
    esac
done

[ -z "$ID" ] && ID="$1"
[ -z "$ID" ] && echo "用法: $0 -i <ID>" && exit 1
[[ ! "$ID" =~ ^[0-9]+$ ]] && echo "错误：ID必须为纯数字" && exit 1

# Configurable paths (set via environment or defaults)
DB_PATH="${BILI_GARB_DB:-}"
CARD_DB="${BILI_CARD_DB:-}"
SUIT_DB="${BILI_SUIT_DB:-}"
ACCESS_KEY="${BILI_ACCESS_KEY:-}"

APPKEY="27eb53fc9058f8c3"
API_URL="https://api.bilibili.com/x/garb/v2/mall/home/search"

echo ""

# 从藏馆数据库获取基本信息（支持 biz_id 和 item_id 匹配）
get_gallery_info() {
    local ID="$1"
    [ -z "$CARD_DB" ] && return
    # 先匹配 biz_id（第4列）
    local RESULT=$(grep "|.*|.*| ${ID} |" "$CARD_DB" 2>/dev/null | head -1)
    if [ -z "$RESULT" ]; then
        # 再匹配 item_id（第5列）
        RESULT=$(awk -F'|' -v id="$ID" '{
            gsub(/^[ \t]+|[ \t]+$/, "", $5)
            if ($5 == id) print $0
        }' "$CARD_DB" 2>/dev/null | head -1)
    fi
    echo "$RESULT"
}

# 从藏馆数据库的 item_id 获取对应的 biz_id
get_biz_id_from_item_id() {
    local ITEM_ID="$1"
    [ -z "$CARD_DB" ] && return
    awk -F'|' -v item_id="$ITEM_ID" '{
        gsub(/^[ \t]+|[ \t]+$/, "", $5)
        gsub(/^[ \t]+|[ \t]+$/, "", $4)
        if ($5 == item_id && $4 != "" && $4 != "null") print $4
    }' "$CARD_DB" 2>/dev/null | head -1
}

# 从藏馆装扮数据库获取信息
get_suit_info() {
    local ID="$1"
    [ -z "$SUIT_DB" ] && return
    grep "|.*|.*| ${ID} |" "$SUIT_DB" 2>/dev/null | head -1
}

# 先检查装扮数据库（无论ID长度）
SUIT_INFO=$(get_suit_info "$ID")
if [ -n "$SUIT_INFO" ]; then
    echo "📌 检测到藏馆装扮数据库记录"
    FORCE_SUIT=1
fi

# 如果没有找到装扮，检查收藏集数据库的 item_id
if [ -z "$SUIT_INFO" ] && [ ${#ID} -gt 6 ]; then
    BIZ_ID=$(get_biz_id_from_item_id "$ID")
    if [ -n "$BIZ_ID" ]; then
        ID="$BIZ_ID"
        echo "📌 检测到收藏集 item_id，转换为 biz_id: ${ID}"
    fi
fi

# 判断类型：先检查强制套装，再按位数判断
if [ "$FORCE_SUIT" = "1" ] || [ ${#ID} -gt 6 ]; then
    # ========== 套装模式 ==========
    RESPONSE=$(curl -s "https://api.bilibili.com/x/garb/v2/mall/suit/detail?item_id=${ID}&part=suit")
    
    NAME=$(echo "$RESPONSE" | jq -r '.data.name // "未知"')
    
    # 如果官方API找不到，回退到藏馆装扮数据库
    if [ "$NAME" = "未知" ] || [ -z "$NAME" ]; then
        if [ -n "$SUIT_INFO" ]; then
            SUIT_NAME=$(echo "$SUIT_INFO" | awk -F'|' '{gsub(/^[ \t]+|[ \t]+$/, "", $3); print $3}')
            SUIT_TYPE=$(echo "$SUIT_INFO" | awk -F'|' '{gsub(/^[ \t]+|[ \t]+$/, "", $5); print $5}')
            
            echo "## ${SUIT_NAME}"
            echo ""
            echo "| 属性 | 值 |"
            echo "|------|------|"
            echo "| **装扮ID** | [${ID}](https://www.bilibili.com/h5/mall/suit/detail?item_id=${ID}) |"
            echo "| **类型** | ${SUIT_TYPE} |"
            echo "| **来源** | 藏馆数据库（官方API无数据） |"
            echo "| **状态** | 可能绝版 |"
            echo ""
            
            # 尝试从B站搜索API获取图片
            ENCODED=$(printf '%s' "$SUIT_NAME" | jq -sRr @uri)
            SEARCH_IMG=$(curl -s "${API_URL}?key_word=${ENCODED}&pn=1&ps=5&mobi_app=iphone&platform=ios&appkey=${APPKEY}" 2>/dev/null | jq -r '.data.list[0].properties.image_cover // empty' 2>/dev/null)
            
            if [ -n "$SEARCH_IMG" ]; then
                echo "**封面图**："
                echo "<qqimg>${SEARCH_IMG}</qqimg>"
            else
                echo "⚠️ 此装扮已下架，无法获取图片"
            fi
        else
            echo "❌ 未找到套装 ID: ${ID}"
            echo ""
            echo "提示：此装扮可能已下架或ID错误"
        fi
        exit 0
    fi
    
    # 所有数据来自官方API
    PRICE=$(echo "$RESPONSE" | jq -r '.data.properties.sale_bp_forever_raw // "未知"')
    TOTAL=$(echo "$RESPONSE" | jq -r '.data.properties.sale_quantity // "未知"')
    STOCK=$(echo "$RESPONSE" | jq -r '.data.properties.item_stock_surplus // "0"')
    SOLD=$((TOTAL - STOCK))
    DESC=$(echo "$RESPONSE" | jq -r '.data.properties.desc // "无描述"')
    IMAGE=$(echo "$RESPONSE" | jq -r '.data.properties.image_cover // ""')
    PORTRAIT_IMAGE=$(echo "$RESPONSE" | jq -r '.data.suit_items.space_bg[0].properties.image1_portrait // ""')
    START_TIME=$(echo "$RESPONSE" | jq -r '.data.properties.sale_time_begin // ""')
    STATUS=$(echo "$RESPONSE" | jq -r '.data.state // ""')
    
    START_DATE=""
    [ -n "$START_TIME" ] && [ "$START_TIME" != "null" ] && START_DATE=$(date -d "@$START_TIME" +"%Y-%m-%d %H:%M" 2>/dev/null)
    
    STATUS_CN=""
    [ "$STATUS" = "active" ] && STATUS_CN="销售中"
    [ "$STATUS" = "ended" ] && STATUS_CN="已结束"
    [ "$STATUS" = "pending" ] && STATUS_CN="即将开售"
    
    PRICE_YUAN="未知"
    VIP_PRICE_YUAN="未知"
    if [ "$PRICE" != "未知" ] && [ -n "$PRICE" ]; then
        PRICE_YUAN=$(awk "BEGIN {printf \"%.1f\", $PRICE/100}")
        VIP_PRICE_YUAN=$(awk "BEGIN {printf \"%.1f\", $PRICE*0.8/100}")
    fi
    
    echo "## ${NAME}"
    echo ""
    echo "| 属性 | 值 |"
    echo "|------|------|"
    echo "| **套装ID** | [${ID}](https://www.bilibili.com/h5/mall/suit/detail?item_id=${ID}) |"
    echo "| **销量** | ${SOLD} / ${TOTAL} |"
    echo "| **价格** | ${VIP_PRICE_YUAN}/${PRICE_YUAN}元 |"
    [ -n "$START_DATE" ] && echo "| **开售** | ${START_DATE} |"
    [ -n "$STATUS_CN" ] && echo "| **状态** | ${STATUS_CN} |"
    echo ""
    [ -n "$IMAGE" ] && echo "<qqimg>${IMAGE}</qqimg>"
    [ -n "$PORTRAIT_IMAGE" ] && echo "<qqimg>${PORTRAIT_IMAGE}</qqimg>"
else
    # ========== 收藏集模式 ==========
    
    FOUND=0
    KEYWORD=""
    
    # 从监控数据库获取名称
    [ -n "$DB_PATH" ] && [ -f "$DB_PATH" ] && KEYWORD=$(jq -r ".collections.\"${ID}\".name // empty" "$DB_PATH" 2>/dev/null)
    
    # 从藏馆数据库获取名称
    if [ -z "$KEYWORD" ] && [ -n "$CARD_DB" ] && [ -f "$CARD_DB" ]; then
        KEYWORD=$(grep "|.*|.*| ${ID} |" "$CARD_DB" | awk -F'|' '{gsub(/^[ \t]+|[ \t]+$/, "", $3); print $3}' | head -1)
    fi
    
    # 提取简短关键词
    if [ -n "$KEYWORD" ]; then
        KEYWORD=$(echo "$KEYWORD" | sed 's/收藏集.*//;s/·.*//;s/-.*//' | head -c 20 | sed 's/^[ \t]*//;s/[ \t]*$//')
    fi
    
    [ -z "$KEYWORD" ] && KEYWORD="$ID"
    
    # 调用搜索API
    ENCODED=$(printf '%s' "$KEYWORD" | jq -sRr @uri)
    RESPONSE=$(curl -s "${API_URL}?key_word=${ENCODED}&pn=1&ps=50&mobi_app=iphone&platform=ios&appkey=${APPKEY}")
    
    # 查找匹配ID
    for i in $(seq 0 49); do
        DLC_ACT_ID=$(echo "$RESPONSE" | jq -r ".data.list[$i].properties.dlc_act_id // empty")
        if [ "$DLC_ACT_ID" = "$ID" ]; then
            FOUND=1
            FULL_NAME=$(echo "$RESPONSE" | jq -r ".data.list[$i].name")
            SALE=$(echo "$RESPONSE" | jq -r ".data.list[$i].properties.dlc_lottery_sale_quantity")
            [ "$SALE" = "null" ] && SALE=$(echo "$RESPONSE" | jq -r ".data.list[$i].sale_count_desc")
            PRICE=$(echo "$RESPONSE" | jq -r ".data.list[$i].properties.sale_bp_forever_raw // \"未知\"")
            START_TIME=$(echo "$RESPONSE" | jq -r ".data.list[$i].properties.dlc_sale_start_time // \"\"")
            END_TIME=$(echo "$RESPONSE" | jq -r ".data.list[$i].properties.dlc_sale_end_time // \"\"")
            LOTTERY_ID=$(echo "$RESPONSE" | jq -r ".data.list[$i].properties.dlc_lottery_id // \"\"")
            IMAGE=$(echo "$RESPONSE" | jq -r ".data.list[$i].properties.image_cover // \"\"")
            STATUS=$(echo "$RESPONSE" | jq -r ".data.list[$i].state // \"\"")
            
            START_DATE=""
            END_DATE=""
            [ -n "$START_TIME" ] && [ "$START_TIME" != "null" ] && START_DATE=$(date -d "@$START_TIME" +"%Y-%m-%d %H:%M" 2>/dev/null)
            [ -n "$END_TIME" ] && [ "$END_TIME" != "null" ] && END_DATE=$(date -d "@$END_TIME" +"%Y-%m-%d" 2>/dev/null)
            
            STATUS_CN=""
            [ "$STATUS" = "active" ] && STATUS_CN="销售中"
            [ "$STATUS" = "ended" ] && STATUS_CN="已结束"
            [ "$STATUS" = "pending" ] && STATUS_CN="即将开售"
            
            PRICE_YUAN="未知"
            [ "$PRICE" != "未知" ] && [ -n "$PRICE" ] && PRICE_YUAN=$(awk "BEGIN {printf \"%.1f\", $PRICE/100}")
            
            # 获取头像框（需要 access_key）
            FRAME_IMAGE=""
            FRAME_NAME=""
            if [ -n "$LOTTERY_ID" ] && [ -n "$ACCESS_KEY" ]; then
                LOTTERY_RESPONSE=$(curl -s "https://api.bilibili.com/x/vas/dlc_act/lottery_home_detail?act_id=${ID}&lottery_id=${LOTTERY_ID}&mobi_app=iphone&platform=ios&appkey=${APPKEY}&access_key=${ACCESS_KEY}" 2>/dev/null)
                FRAME_IMAGE=$(echo "$LOTTERY_RESPONSE" | jq -r '.data.collect_list.collect_infos[] | select(.redeem_item_type==3) | .redeem_item_image' 2>/dev/null | head -1)
                FRAME_NAME=$(echo "$LOTTERY_RESPONSE" | jq -r '.data.collect_list.collect_infos[] | select(.redeem_item_type==3) | .redeem_item_name' 2>/dev/null | head -1)
            fi
            
            echo "## ${FULL_NAME}"
            echo ""
            echo "| 属性 | 值 |"
            echo "|------|------|"
            echo "| **收藏集ID** | [${ID}](https://www.bilibili.com/h5/mall/digital-card/home?act_id=${ID}) |"
            echo "| **销量** | ${SALE} |"
            echo "| **价格** | ${PRICE_YUAN}元/抽 |"
            [ -n "$START_DATE" ] && echo "| **开售** | ${START_DATE} |"
            [ -n "$END_DATE" ] && echo "| **结束** | ${END_DATE} |"
            [ -n "$STATUS_CN" ] && echo "| **状态** | ${STATUS_CN} |"
            [ -n "$FRAME_NAME" ] && echo "| **头像框** | ${FRAME_NAME} |"
            echo ""
            [ -n "$IMAGE" ] && echo "<qqimg>${IMAGE}</qqimg>"
            [ -n "$FRAME_IMAGE" ] && echo "<qqimg>${FRAME_IMAGE}</qqimg>"
            break
        fi
    done
    
    # 如果官方搜索API找不到，尝试 act/basic API
    if [ $FOUND -eq 0 ]; then
        BASIC_RESPONSE=$(curl -s "https://api.bilibili.com/x/vas/dlc_act/act/basic?act_id=${ID}&mobi_app=iphone&platform=ios&appkey=${APPKEY}" 2>/dev/null)
        ACT_TITLE=$(echo "$BASIC_RESPONSE" | jq -r '.data.act_title // empty' 2>/dev/null)
        
        if [ -n "$ACT_TITLE" ] && [ "$ACT_TITLE" != "null" ]; then
            FOUND=1
            FULL_NAME="$ACT_TITLE"
            START_TIME=$(echo "$BASIC_RESPONSE" | jq -r '.data.start_time // 0' 2>/dev/null)
            END_TIME=$(echo "$BASIC_RESPONSE" | jq -r '.data.end_time // 0' 2>/dev/null)
            PRICE=$(echo "$BASIC_RESPONSE" | jq -r '.data.lottery_list[0].price // 0' 2>/dev/null)
            SALE=$(echo "$BASIC_RESPONSE" | jq -r '.data.total_buy_cnt // 0' 2>/dev/null)
            IMAGE=$(echo "$BASIC_RESPONSE" | jq -r '.data.lottery_list[0].lottery_image // ""' 2>/dev/null)
            LOTTERY_ID=$(echo "$BASIC_RESPONSE" | jq -r '.data.lottery_list[0].lottery_id // ""' 2>/dev/null)
            STATUS="active"
            
            START_DATE=""
            END_DATE=""
            [ "$START_TIME" != "0" ] && [ -n "$START_TIME" ] && START_DATE=$(date -d "@$START_TIME" +"%Y-%m-%d %H:%M" 2>/dev/null)
            [ "$END_TIME" != "0" ] && [ -n "$END_TIME" ] && END_DATE=$(date -d "@$END_TIME" +"%Y-%m-%d" 2>/dev/null)
            
            PRICE_YUAN="未知"
            [ "$PRICE" != "0" ] && [ -n "$PRICE" ] && PRICE_YUAN=$(awk "BEGIN {printf \"%.1f\", $PRICE/1000}")
            
            # 获取头像框
            FRAME_IMAGE=""
            FRAME_NAME=""
            if [ -n "$LOTTERY_ID" ]; then
                LOTTERY_RESPONSE=$(curl -s "https://api.bilibili.com/x/vas/dlc_act/lottery_home_detail?act_id=${ID}&lottery_id=${LOTTERY_ID}&mobi_app=iphone&platform=ios&appkey=${APPKEY}" 2>/dev/null)
                FRAME_IMAGE=$(echo "$LOTTERY_RESPONSE" | jq -r '.data.collect_list.collect_infos[] | select(.redeem_item_type==3) | .redeem_item_image' 2>/dev/null | head -1)
                FRAME_NAME=$(echo "$LOTTERY_RESPONSE" | jq -r '.data.collect_list.collect_infos[] | select(.redeem_item_type==3) | .redeem_item_name' 2>/dev/null | head -1)
            fi
            
            echo "## ${FULL_NAME}"
            echo ""
            echo "| 属性 | 值 |"
            echo "|------|------|"
            echo "| **收藏集ID** | [${ID}](https://www.bilibili.com/h5/mall/digital-card/home?act_id=${ID}) |"
            echo "| **销量** | ${SALE} |"
            echo "| **价格** | ${PRICE_YUAN}元/抽 |"
            [ -n "$START_DATE" ] && echo "| **开售** | ${START_DATE} |"
            [ -n "$END_DATE" ] && echo "| **结束** | ${END_DATE} |"
            [ -n "$FRAME_NAME" ] && echo "| **头像框** | ${FRAME_NAME} |"
            echo ""
            [ -n "$IMAGE" ] && echo "<qqimg>${IMAGE}</qqimg>"
            [ -n "$FRAME_IMAGE" ] && echo "<qqimg>${FRAME_IMAGE}</qqimg>"
        fi
    fi
    
    # 如果仍然找不到，回退到藏馆数据库
    if [ $FOUND -eq 0 ] && [ -n "$CARD_DB" ] && [ -f "$CARD_DB" ]; then
        GALLERY_INFO=$(get_gallery_info "$ID")
        if [ -n "$GALLERY_INFO" ]; then
            NAME=$(echo "$GALLERY_INFO" | awk -F'|' '{print $3}' | sed 's/^[ \t]*//;s/[ \t]*$//')
            ITEM_ID=$(echo "$GALLERY_INFO" | awk -F'|' '{print $5}' | sed 's/^[ \t]*//;s/[ \t]*$//')
            HOLD=$(echo "$GALLERY_INFO" | awk -F'|' '{print $6}' | sed 's/^[ \t]*//;s/[ \t]*$//')
            STATUS=$(echo "$GALLERY_INFO" | awk -F'|' '{print $7}' | sed 's/^[ \t]*//;s/[ \t]*$//')
            
            echo "## ${NAME}"
            echo ""
            echo "| 属性 | 值 |"
            echo "|------|------|"
            echo "| **收藏集ID** | ${ID} |"
            echo "| **来源** | 藏馆数据库（官方API无数据） |"
            [ -n "$ITEM_ID" ] && [ "$ITEM_ID" != "null" ] && echo "| **商品ID** | ${ITEM_ID} |"
            [ -n "$HOLD" ] && echo "| **持有** | ${HOLD} |"
            [ -n "$STATUS" ] && echo "| **状态** | ${STATUS} |"
            echo ""
            echo "⚠️ 此收藏集可能已下架，无法获取更多信息"
        else
            echo "❌ 未找到收藏集 ID: ${ID}"
            echo ""
            echo "提示：此收藏集可能已下架或ID错误"
        fi
    fi
fi


path: scripts/bilibili-garb-search.sh
content: #!/bin/bash
# Bilibili Garb Search v8.3 (sanitized)
# 搜索收藏集/套装 - 官方API优先，藏馆补充说明
# 输出格式：收藏集输出biz_id，套装输出item_id

API_URL="https://api.bilibili.com/x/garb/v2/mall/home/search"
APPKEY="27eb53fc9058f8c3"

# Gallery databases (optional, set these to your local data paths)
CARD_DB="${BILI_CARD_DB:-}"
SUIT_DB="${BILI_SUIT_DB:-}"

KEYWORD=$2
[ -z "$KEYWORD" ] && KEYWORD=$1
[ -z "$KEYWORD" ] && echo "用法: $0 关键词" && exit 1

echo ""
echo "## 搜索「${KEYWORD}」"
echo ""

# 用于去重的ID集合
declare -A SEEN_IDS

# 从藏馆获取补充信息（持有数、状态等）
get_gallery_hold() {
    local ID="$1"
    [ -z "$CARD_DB" ] && return
    grep "|.*|.*| ${ID} |" "$CARD_DB" 2>/dev/null | awk -F'|' '{gsub(/^[ \t]+|[ \t]+$/, "", $6); print $6}' | head -1
}

get_suit_type() {
    local ID="$1"
    [ -z "$SUIT_DB" ] && return
    grep "|.*|.*| ${ID} |" "$SUIT_DB" 2>/dev/null | awk -F'|' '{gsub(/^[ \t]+|[ \t]+$/, "", $5); print $5}' | head -1
}

# 1. 先搜索官方API（优先）
ENCODED=$(printf '%s' "$KEYWORD" | jq -sRr @uri)
RESPONSE=$(curl -s "${API_URL}?key_word=${ENCODED}&pn=1&ps=20&mobi_app=iphone&platform=ios&appkey=${APPKEY}")
TOTAL=$(echo "$RESPONSE" | jq -r '.data.list | length')

API_COUNT=0
for i in $(seq 0 $((TOTAL-1))); do
    NAME=$(echo "$RESPONSE" | jq -r ".data.list[$i].name")
    ITEM_ID=$(echo "$RESPONSE" | jq -r ".data.list[$i].item_id")
    DLC_ACT_ID=$(echo "$RESPONSE" | jq -r ".data.list[$i].properties.dlc_act_id")
    SALE=$(echo "$RESPONSE" | jq -r ".data.list[$i].sale_count_desc")
    STATUS=$(echo "$RESPONSE" | jq -r ".data.list[$i].state")
    
    # 收藏集：有dlc_act_id
    if [ "$DLC_ACT_ID" != "null" ] && [ -n "$DLC_ACT_ID" ]; then
        REAL_SALE=$(echo "$RESPONSE" | jq -r ".data.list[$i].properties.dlc_lottery_sale_quantity")
        [ "$REAL_SALE" != "null" ] && [ -n "$REAL_SALE" ] && SALE="$REAL_SALE"
        
        QUERY_ID="${DLC_ACT_ID}"
        SEEN_IDS["$QUERY_ID"]=1
        
        # 藏馆补充信息
        HOLD_INFO=$(get_gallery_hold "$QUERY_ID")
        
        STATUS_CN=""
        [ "$STATUS" = "active" ] && STATUS_CN="销售中"
        [ "$STATUS" = "ended" ] && STATUS_CN="已结束"
        [ "$STATUS" = "pending" ] && STATUS_CN="即将开售"
        
        echo "### ${NAME} \`[官方]\`"
        echo "- **类型**: 收藏集"
        echo "- **ID**: [${QUERY_ID}](https://www.bilibili.com/h5/mall/digital-card/home?act_id=${QUERY_ID})"
        [ -n "$ITEM_ID" ] && [ "$ITEM_ID" != "0" ] && [ "$ITEM_ID" != "null" ] && echo "  (商品item_id: ${ITEM_ID})"
        echo "- **销量**: ${SALE}"
        [ -n "$STATUS_CN" ] && echo "- **状态**: ${STATUS_CN}"
        [ -n "$HOLD_INFO" ] && echo "- **持有**: ${HOLD_INFO} \`[藏馆补充]\`"
        echo ""
        ((API_COUNT++))
    # 套装：有item_id且无dlc_act_id
    elif [ "$ITEM_ID" != "0" ] && [ -n "$ITEM_ID" ]; then
        TOTAL_QTY=$(echo "$RESPONSE" | jq -r ".data.list[$i].properties.sale_quantity")
        STOCK=$(echo "$RESPONSE" | jq -r ".data.list[$i].properties.item_stock_surplus")
        if [ "$TOTAL_QTY" != "null" ] && [ "$STOCK" != "null" ]; then
            SALE="$((TOTAL_QTY - STOCK)) / ${TOTAL_QTY}"
        fi
        
        QUERY_ID="${ITEM_ID}"
        SEEN_IDS["$QUERY_ID"]=1
        
        STATUS_CN=""
        [ "$STATUS" = "active" ] && STATUS_CN="销售中"
        [ "$STATUS" = "ended" ] && STATUS_CN="已结束"
        [ "$STATUS" = "pending" ] && STATUS_CN="即将开售"
        
        echo "### ${NAME} \`[官方]\`"
        echo "- **类型**: 套装"
        echo "- **ID**: [${QUERY_ID}](https://www.bilibili.com/h5/mall/suit/detail?item_id=${QUERY_ID})"
        echo "- **销量**: ${SALE}"
        [ -n "$STATUS_CN" ] && echo "- **状态**: ${STATUS_CN}"
        echo ""
        ((API_COUNT++))
    fi
done

# 2. 藏馆独有的（官方API查不到的绝版）
GALLERY_ONLY=0
if [ -n "$CARD_DB" ] && [ -f "$CARD_DB" ]; then
    while IFS= read -r line; do
        [[ "$line" =~ ^\|.*\|$ ]] || continue
        [[ "$line" =~ ^\|[[:space:]]*# ]] && continue
        [[ "$line" =~ ^\|[-[:space:]]+\| ]] && continue
        
        NAME=$(echo "$line" | awk -F'|' '{print $3}' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
        BIZ_ID=$(echo "$line" | awk -F'|' '{print $4}' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
        ITEM_ID=$(echo "$line" | awk -F'|' '{print $5}' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
        HOLD=$(echo "$line" | awk -F'|' '{print $6}' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
        STATUS=$(echo "$line" | awk -F'|' '{print $7}' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
        
        [ -z "$NAME" ] || [ -z "$BIZ_ID" ] && continue
        
        QUERY_ID="${BIZ_ID}"
        
        # 只显示官方没有的（绝版）
        if [ "${SEEN_IDS[$QUERY_ID]}" = "1" ]; then
            continue
        fi
        
        SEEN_IDS["$QUERY_ID"]=1
        
        echo "### ${NAME} \`[藏馆-绝版]\`"
        echo "- **类型**: 收藏集"
        echo "- **ID**: [${QUERY_ID}](https://www.bilibili.com/h5/mall/digital-card/home?act_id=${QUERY_ID})"
        [ -n "$ITEM_ID" ] && [ "$ITEM_ID" != "null" ] && echo "  (商品item_id: ${ITEM_ID})"
        echo "- **持有**: ${HOLD}"
        [ -n "$STATUS" ] && echo "- **状态**: ${STATUS}"
        echo ""
        ((GALLERY_ONLY++))
    done < <(grep -Fi "$KEYWORD" "$CARD_DB")
fi

# 3. 藏馆绝版装扮（官方没有的）
SUIT_ONLY=0
if [ -n "$SUIT_DB" ] && [ -f "$SUIT_DB" ]; then
    while IFS= read -r line; do
        [[ "$line" =~ ^\|.*\|$ ]] || continue
        [[ "$line" =~ ^\|[[:space:]]*# ]] && continue
        [[ "$line" =~ ^\|[-[:space:]]+\| ]] && continue
        
        NAME=$(echo "$line" | awk -F'|' '{print $3}' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
        ITEM_ID=$(echo "$line" | awk -F'|' '{print $4}' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
        TYPE=$(echo "$line" | awk -F'|' '{print $5}' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
        
        [ -z "$NAME" ] || [ -z "$ITEM_ID" ] && continue
        
        QUERY_ID="${ITEM_ID}"
        
        # 只显示官方没有的
        if [ "${SEEN_IDS[$QUERY_ID]}" = "1" ]; then
            continue
        fi
        
        SEEN_IDS["$QUERY_ID"]=1
        
        echo "### ${NAME} \`[藏馆-绝版]\`"
        echo "- **类型**: ${TYPE}"
        echo "- **ID**: [${QUERY_ID}](https://www.bilibili.com/h5/mall/suit/detail?item_id=${QUERY_ID})"
        echo "- **状态**: 绝版"
        echo ""
        ((SUIT_ONLY++))
    done < <(grep -Fi "$KEYWORD" "$SUIT_DB")
fi

# 4. 汇总
echo "---"
echo "📊 **搜索结果**: 官方 ${API_COUNT} 个 | 藏馆绝版收藏集 ${GALLERY_ONLY} 个 | 藏馆绝版装扮 ${SUIT_ONLY} 个"
echo ""
echo "**提示**: 用查询命令查看详情"
echo "- 收藏集ID（≤6位）查询收藏集详情"
echo "- 套装ID（>6位）查询套装详情"


path: scripts/garb-benefit-scan.py
content: #!/usr/bin/env python3
"""
B站个性装扮 benefit 子项批量采集脚本 (v2, sanitized)

从 decorations-database.json 读取装扮列表，逐条调用 benefit API
获取 suit_items 子项数据，结果追加写入 garb-benefit-results.ndjson。

所有认证信息从 configs/bili-api-creds.json 或环境变量读取。

v2 优化点：
- 只扫描 biz_type=1 装扮套装（跳过收藏集卡片/装扮/关联收藏集，省70%调用）
- 跳过 owned!=1（未拥有）和已有benefit数据的记录
- DIY套装自动检测 is_diy=1（item_id含横杠或非纯数字）
- 完整提取 properties 所有非空字段（横竖图/视频/粉丝牌/主题色/表情列表等）
- access_key 从 configs/bili-api-creds.json 动态读取

关键知识：
- part 参数只需 space_bg 一次，即可返回全部 9 种子项
- DIY套装：item_id参数必须传biz_id（原始item_id含横杠会报-400）
- 支持断点续传、--limit、--dry-run、Ctrl+C 优雅退出
"""

import json
import time
import hashlib
import urllib.parse
import urllib.request
import ssl
import sys
import signal
import argparse
import os
from datetime import datetime, timezone, timedelta
from pathlib import Path

# ── 常量 ──────────────────────────────────────────────────────────────
APPKEY = os.environ.get("BILI_APPKEY", "27eb53fc9058f8c3")
APPSECRET = os.environ.get("BILI_APPSECRET", "")  # Must be set via config or env var

BENEFIT_URL = "https://api.bilibili.com/x/garb/v2/user/suit/benefit"
PART = "space_bg"  # 一次即可返回全部子项

BASE_DIR = Path(os.environ.get("BILI_WORKSPACE", Path(__file__).resolve().parent.parent))
DATA_DIR = BASE_DIR / "data"
CREDS_FILE = BASE_DIR / "configs" / "bili-api-creds.json"
DB_FILE = DATA_DIR / "decorations-database.json"
PROGRESS_FILE = DATA_DIR / "garb-benefit-scan.progress.json"
RESULTS_FILE = DATA_DIR / "garb-benefit-results.ndjson"

REQ_INTERVAL = 1.0  # 秒
REQ_TIMEOUT = 15     # 秒
MAX_RETRIES = 1      # 超时重试次数

TZ_SHANGHAI = timezone(timedelta(hours=8))

# ── 凭证加载 ──────────────────────────────────────────────────────────
def load_creds():
    """从 configs/bili-api-creds.json 或环境变量加载凭证"""
    creds = {}
    
    # Try config file first
    if CREDS_FILE.exists():
        with open(CREDS_FILE, "r", encoding="utf-8") as f:
            creds = json.load(f)
    else:
        # Fall back to environment variables
        creds = {
            "access_key": os.environ.get("BILI_ACCESS_KEY", ""),
            "csrf": os.environ.get("BILI_CSRF", ""),
            "DedeUserID": os.environ.get("BILI_UID", ""),
            "SESSDATA": os.environ.get("BILI_SESSDATA", ""),
        }
    
    if not creds.get("access_key"):
        print(f"[ERROR] No access_key found. Set BILI_ACCESS_KEY env var or create {CREDS_FILE}")
        sys.exit(1)
    if not creds.get("SESSDATA"):
        print(f"[ERROR] No SESSDATA found. Set BILI_SESSDATA env var or create {CREDS_FILE}")
        sys.exit(1)
    
    appsecret = creds.get("appsecret", APPSECRET)
    if not appsecret:
        print(f"[ERROR] No appsecret found. Set BILI_APPSECRET env var or add to {CREDS_FILE}")
        print("[HINT] APPSECRET can be obtained from Bilibili mobile client")
        sys.exit(1)
    
    return {
        "appkey": creds.get("appkey", APPKEY),
        "appsecret": appsecret,
        "access_key": creds["access_key"],
        "csrf": creds["csrf"],
        "uid": str(creds.get("DedeUserID", "")),
        "cookie": f'SESSDATA={creds["SESSDATA"]}; bili_jct={creds["csrf"]}; DedeUserID={creds.get("DedeUserID", "")}',
    }

# ── SSL ────────────────────────────────────────────────────────────────
ctx = ssl.create_default_context()

# ── 签名 ──────────────────────────────────────────────────────────────
def calc_sign(params_dict, creds):
    """B站移动端 API 签名：参数排序 → urlencode → 追加 APPSECRET → MD5"""
    ts = str(int(time.time()))
    all_params = dict(params_dict)
    all_params.update({
        "access_key": creds["access_key"],
        "appkey": creds["appkey"],
        "csrf": creds["csrf"],
        "mobi_app": "iphone",
        "platform": "ios",
        "ts": ts,
    })
    sorted_items = sorted(all_params.items())
    query = urllib.parse.urlencode(sorted_items)
    sign_str = query + creds["appsecret"]
    sign = hashlib.md5(sign_str.encode("utf-8")).hexdigest()
    all_params["sign"] = sign
    return all_params

# ── HTTP 请求 ─────────────────────────────────────────────────────────
def api_get(params, creds, retry=MAX_RETRIES):
    """GET benefit API，签名 + Cookie，自动重试"""
    signed = calc_sign(params, creds)
    query = urllib.parse.urlencode(signed)
    full_url = f"{BENEFIT_URL}?{query}"

    req = urllib.request.Request(full_url)
    req.add_header(
        "User-Agent",
        "Mozilla/5.0 BiliDroid/7.37.0 ([REDACTED_SECRET]) "
        "os/android model/M2012K11AC mobi_app/android build/7370300 "
        "channel/bili innerVer/7370300 osVer/12 network/2",
    )
    req.add_header("Referer", "https://www.bilibili.com/")
    req.add_header("Cookie", creds["cookie"])

    for attempt in range(retry + 1):
        try:
            with urllib.request.urlopen(req, context=ctx, timeout=REQ_TIMEOUT) as resp:
                return json.loads(resp.read().decode("utf-8"))
        except urllib.error.HTTPError as e:
            body = e.read().decode("utf-8", errors="replace")[:200]
            return {"code": -e.code, "message": f"HTTP {e.code}: {body}"}
        except Exception as e:
            if attempt < retry:
                time.sleep(1)
                continue
            return {"code": -1, "message": str(e)}

# ── 完整子项提取 ──────────────────────────────────────────────────────
def extract_properties_value(val):
    """递归提取 properties 中的值，跳过空值和空容器"""
    if val is None:
        return None
    if isinstance(val, (str, int, float, bool)):
        if isinstance(val, str) and val.strip() == "":
            return None
        return val
    if isinstance(val, list):
        result = []
        for item in val:
            extracted = extract_properties_value(item)
            if extracted is not None:
                result.append(extracted)
        return result if result else None
    if isinstance(val, dict):
        result = {}
        for k, v in val.items():
            extracted = extract_properties_value(v)
            if extracted is not None:
                result[k] = extracted
        return result if result else None
    return None


def extract_benefit_items(suit_items):
    """从 suit_items 完整提取各子项数据，保留 item_id/name + properties 全部非空字段"""
    if not suit_items or not isinstance(suit_items, dict):
        return None

    result = {}
    for part_name, part_data in suit_items.items():
        if not part_data:
            continue
        items = part_data if isinstance(part_data, list) else [part_data]
        extracted = []
        for item in items:
            if not isinstance(item, dict):
                continue
            entry = {
                "item_id": item.get("item_id"),
                "name": item.get("name", ""),
            }
            props = item.get("properties")
            if props and isinstance(props, dict):
                for pk, pv in props.items():
                    extracted_val = extract_properties_value(pv)
                    if extracted_val is not None:
                        entry[pk] = extracted_val
            extracted.append(entry)
        if extracted:
            result[part_name] = extracted
    return result if result else None


def summarize_benefit(benefit_items):
    """生成简短的 benefit 摘要用于日志输出"""
    if not benefit_items:
        return "none"
    parts = []
    for part_name, items in benefit_items.items():
        extra_fields = set()
        for item in items:
            extra_fields.update(k for k in item if k not in ("item_id", "name"))
        if extra_fields:
            parts.append(f"{part_name}({len(items)}项,{len(extra_fields)}字段)")
        else:
            parts.append(f"{part_name}({len(items)}项)")
    return " ".join(parts)

# ── 已有结果加载（去重） ──────────────────────────────────────────────
def load_existing_biz_ids():
    """从已有的 ndjson 结果文件中加载已成功获取 benefit 的 biz_id 集合"""
    existing = set()
    if not RESULTS_FILE.exists():
        return existing
    try:
        with open(RESULTS_FILE, "r", encoding="utf-8") as f:
            for line in f:
                line = line.strip()
                if not line:
                    continue
                try:
                    rec = json.loads(line)
                    if rec.get("benefit_items"):
                        biz_id = rec.get("biz_id")
                        if biz_id is not None:
                            existing.add(biz_id)
                except (json.JSONDecodeError, KeyError):
                    continue
    except Exception:
        pass
    return existing

# ── 进度管理 ──────────────────────────────────────────────────────────
def load_progress():
    if PROGRESS_FILE.exists():
        try:
            return set(json.loads(PROGRESS_FILE.read_text("utf-8")))
        except Exception:
            return set()
    return set()


def save_progress(done_set):
    PROGRESS_FILE.write_text(
        json.dumps(sorted(done_set), ensure_ascii=False) + "\n", encoding="utf-8"
    )

# ── DIY 检测 ──────────────────────────────────────────────────────────
def is_diy_item(item_id):
    """判断是否为 DIY 套装：item_id 含横杠或非纯数字 → is_diy=1"""
    s = str(item_id)
    return "-" in s or not s.isdigit()

# ── 主逻辑 ────────────────────────────────────────────────────────────
def main():
    parser = argparse.ArgumentParser(description="B站装扮 benefit 子项批量采集 v2")
    parser.add_argument("--limit", type=int, default=0, help="限制处理数量（0=全部）")
    parser.add_argument("--dry-run", action="store_true", help="只打印不请求")
    parser.add_argument("--force", action="store_true", help="强制重新扫描已有benefit的记录")
    parser.add_argument("--debug", action="store_true", help="输出完整API响应（调试用）")
    args = parser.parse_args()

    # 1. 加载凭证
    creds = load_creds()
    print(f"[INFO] 凭证已加载: uid={creds['uid']}, access_key={creds['access_key'][:8]}...")

    # 2. 加载数据库
    if not DB_FILE.exists():
        print(f"[ERROR] 数据库文件不存在: {DB_FILE}")
        print(f"[HINT] Set BILI_WORKSPACE env var or ensure data/decorations-database.json exists")
        sys.exit(1)

    records = []
    with open(DB_FILE, "r", encoding="utf-8") as f:
        for line in f:
            line = line.strip()
            if line:
                records.append(json.loads(line))
    print(f"[INFO] 数据库共 {len(records)} 条记录")

    # 3. 过滤：只保留装扮套装(biz_type=1)且 owned>=1
    filtered = []
    skipped_stats = {"biz_type": 0, "not_owned": 0}
    for rec in records:
        biz_type = rec.get("biz_type")
        if biz_type != 1:
            skipped_stats["biz_type"] += 1
            continue
        owned = rec.get("owned", 0)
        if not owned or owned < 1:
            skipped_stats["not_owned"] += 1
            continue
        filtered.append(rec)
    print(f"[INFO] 过滤后: {len(filtered)} 条装扮套装 (跳过: {skipped_stats['biz_type']}非套装, {skipped_stats['not_owned']}未拥有)")

    # 4. 去重
    existing_biz_ids = set() if args.force else load_existing_biz_ids()
    if existing_biz_ids:
        print(f"[INFO] 已有 benefit 数据: {len(existing_biz_ids)} 条，将跳过 (用 --force 强制重扫)")
    else:
        print(f"[INFO] 无已有 benefit 数据，将全量扫描")

    # 5. 加载进度（断点续传）
    done = load_progress()
    if done:
        print(f"[INFO] 断点续传: 已处理 {len(done)} 条")

    # 6. Ctrl+C 优雅退出
    interrupted = False

    def on_signal(sig, frame):
        nonlocal interrupted
        print("\n[INFO] 收到中断信号，正在保存进度...")
        interrupted = True

    signal.signal(signal.SIGINT, on_signal)

    # 7. 逐条处理
    processed = 0
    skipped_dedup = 0
    skipped_progress = 0
    ok_count = 0
    err_count = 0
    diy_count = 0
    results_fh = open(RESULTS_FILE, "a", encoding="utf-8") if not args.dry_run else None

    try:
        for rec in filtered:
            if interrupted:
                break

            biz_id = rec.get("biz_id")
            item_id = rec.get("item_id")
            title = rec.get("title", "")
            deco_type = rec.get("deco_type", "")

            # 去重
            if biz_id in existing_biz_ids:
                skipped_dedup += 1
                continue

            # 断点续传
            progress_key = f"{biz_id}"
            if progress_key in done:
                skipped_progress += 1
                continue

            if args.limit and processed >= args.limit:
                print(f"[INFO] 已达 --limit={args.limit}，停止")
                break

            # DIY 检测
            diy_flag = is_diy_item(item_id)
            is_diy_str = "1" if diy_flag else "0"
            diy_tag = " [DIY]" if diy_flag else ""
            if diy_flag:
                diy_count += 1

            print(
                f"[{processed+1}] {title}{diy_tag} "
                f"(biz_id={biz_id}, item_id={item_id}, is_diy={is_diy_str}) ... ",
                end="", flush=True,
            )

            if args.dry_run:
                print("DRY-RUN")
                processed += 1
                continue

            # 调用 benefit API
            # DIY套装必须用biz_id作为item_id参数（原始item_id含横杠会报-400）
            api_item_id = str(biz_id) if diy_flag else str(item_id)
            params = {
                "item_id": api_item_id,
                "part": PART,
                "is_diy": is_diy_str,
                "vmid": creds["uid"],
            }
            resp = api_get(params, creds)

            now = datetime.now(TZ_SHANGHAI).isoformat(timespec="seconds")
            result_record = {
                "biz_id": biz_id,
                "biz_type": rec.get("biz_type"),
                "title": title,
                "item_id": item_id,
                "is_diy": diy_flag,
                "deco_type": deco_type,
                "benefit_items": None,
                "scan_time": now,
            }

            if resp.get("code") != 0:
                msg = resp.get("message", str(resp))
                result_record["error"] = msg
                print(f"ERROR code={resp.get('code')} msg={msg[:80]}")
                err_count += 1
                if args.debug:
                    print(f"  [DEBUG] params={params}")
                    print(f"  [DEBUG] resp={json.dumps(resp, ensure_ascii=False)[:500]}")
            else:
                suit_items = (resp.get("data") or {}).get("suit_items")
                result_record["benefit_items"] = extract_benefit_items(suit_items)
                summary = summarize_benefit(result_record["benefit_items"])
                print(f"OK → {summary}")
                ok_count += 1

            # 追加写入结果
            results_fh.write(json.dumps(result_record, ensure_ascii=False) + "\n")
            results_fh.flush()

            # 更新进度
            done.add(progress_key)
            save_progress(done)
            processed += 1

            # 请求间隔
            time.sleep(REQ_INTERVAL)

    finally:
        if results_fh:
            results_fh.close()
        save_progress(done)
        print(f"\n{'='*60}")
        print(f"[DONE] 本次扫描: {processed} 条")
        print(f"  成功: {ok_count} | 失败: {err_count} | DIY套装: {diy_count}")
        print(f"  跳过(已有数据): {skipped_dedup} | 跳过(断点续传): {skipped_progress}")
        print(f"  累计已处理: {len(done)} 条")


if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)