Metadata-Version: 2.4
Name: biliemoji
Version: 2.0.0
Summary: Unofficial Bilibili emoji / dress (收藏集) SDK with typed models, a bounded concurrent downloader, structured errors, and stdlib logging.
Author: gcnanmu
Author-email: 
Maintainer: gcnanmu
License: MIT
Keywords: python,bilibili,emoji,dress,SDK
Classifier: Development Status :: 4 - Beta
Classifier: License :: OSI Approved :: MIT License
Classifier: Intended Audience :: Developers
Classifier: Natural Language :: Chinese (Simplified)
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Operating System :: Unix
Classifier: Operating System :: MacOS :: MacOS X
Classifier: Operating System :: Microsoft :: Windows
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: requests>=2.31
Dynamic: author
Dynamic: classifier
Dynamic: description
Dynamic: description-content-type
Dynamic: keywords
Dynamic: license
Dynamic: license-file
Dynamic: maintainer
Dynamic: requires-dist
Dynamic: requires-python
Dynamic: summary


# biliemoji

非官方 B 站表情包 / 装扮（收藏集）Python SDK。typed 数据模型、有界并发下载器、结构化错误、stdlib `logging` 中文日志。

> 所有接口来自 B 站公开 API，可能随官方更新失效。失效时停止使用并等待更新。

## 安装

```bash
pip install biliemoji
```

需要 Python >= 3.11，依赖 `requests>=2.31`。

## 快速开始

### 表情包

```python
from pathlib import Path
from biliemoji import Emoji

e = Emoji(cookie="SESSDATA=...; bili_jct=...")

# 旧兼容：按 ID 获取，返回原始 dict（自行判断 res["code"]）
res = e.certain_emoji(ids=53)
print(res)

# typed：解析为模型，失败抛 typed 异常
pkg = e.certain_emoji_typed(ids=53)
print(pkg.text, pkg.is_gif, len(pkg.emote))

# 一步下载到 out/测试包/
e.download_package(ids=53, dest=Path("out"))
```

### 全部表情包（需登录）

```python
e = Emoji(cookie="SESSDATA=...")
for pkg in e.all_packages():
    print(pkg.id, pkg.text)
```

### 装扮 / 收藏集

```python
from biliemoji import Dress

d = Dress()
# 旧兼容：搜索并打印前 3 个结果
res = d.search_dress(3, keyword="2233")

# typed
hits = d.search_dress_typed(3, keyword="2233")
for h in hits:
    if h.is_collection:
        print(h.name, h.sale_bp_forever)

# 一步下载收藏集图片（mode: image / video / both）
d.download_collection(hits[0].dlc_act_id, hits[0].dlc_lottery_id, Path("out"), mode="both")
```

## Cookie 环境变量

SDK **只读 `os.environ["BILIEMOJI_COOKIE"]`**，绝不隐式加载或扫描 `.env` 文件、不调用 `dotenv`。

```bash
# 设置 cookie（Windows: set / Linux / macOS: export）
export BILIEMOJI_COOKIE="SESSDATA=...; bili_jct=..."
```

```python
from biliemoji import BiliClient, load_cookie_from_env

cookie = load_cookie_from_env()            # 缺失/空白 -> ""
cookie = load_cookie_from_env(required=True)  # 缺失/空白 -> 抛 AuthRequired
client = BiliClient.from_env()             # 构建带 env cookie 的客户端
```

**cookie 优先级**（高→低）：

1. 单次请求显式 cookie 参数（`get_json(cookie=...)`、`all_emoji(cookie=...)`）
2. `Emoji` / `Dress` / `BiliClient` 构造时传入的 cookie
3. `BiliClient.from_env()` / `load_cookie_from_env()` 得到的 cookie
4. 无 cookie

cookie 只按请求临时构造 `Cookie` 头，绝不写入 `Session.headers`、公开 header 字典、日志、异常、repr 或结果对象。

### 交互工具中的 .env

`search_emoji.py`（CLI）启动时**显式**调用 `dotenv.load_dotenv()` 加载 `.env`，再经 SDK 读取。`.env` 由 CLI 入口负责，SDK 侧永远只读已存在的环境变量：

```dotenv
BILIEMOJI_COOKIE="SESSDATA=...; bili_jct=..."
```

也兼容旧 `.env` 的 `cookie=` 键（`BILIEMOJI_COOKIE` 优先，旧键兜底）。

## 下载

`Downloader`：有界并发（默认 8 worker，worker-local `requests.Session`）、逐文件重试、`.part` 临时文件 + `os.replace` 原子落盘、三层校验（HTTP 状态 / HTML 页面拦截 / 魔数签名）。

```python
from biliemoji import Downloader, DownloadTask
from pathlib import Path

tasks = [
    DownloadTask(url="https://example.com/a.png", target=Path("a.png"), expected_ext=".png"),
    DownloadTask(url="https://example.com/b.gif", target=Path("b.gif"), expected_ext=".gif"),
]
batch = Downloader().download_many(tasks)
print(batch.ok, batch.failed, batch.skipped)
batch.raise_for_failures()  # 有失败时抛 DownloadError
```

- 目标已有确定扩展名时，下载内容必须匹配该格式，不匹配抛 `DownloadError`，绝不静默改后缀。
- 目标无扩展名时，才按 URL / Content-Type / 魔数推断补全。
- 支持的格式：PNG / GIF / JPEG / WebP / MP4（MP4 仅校验 ISO BMFF 文件头，不保证视频完整性）。
- 认证信息（cookie / headers）作为每次请求的局部 header 传入，不污染 Session 状态。

## 错误分类

所有异常继承 `BiliError`，带属性 `status_code` / `api_code` / `message` / `url`（URL 已脱敏去 query）。

```
BiliError
├── BiliAPIError        # HTTP 2xx 但 code != 0
│   ├── AuthRequired    # -101 未登录 / 认证失效 / 显式要求认证但无 cookie（同时继承 ValueError）
│   └── NotFoundError   # -404 不存在
│       ├── EmojiNotFound
│       └── DressNotFound
├── NetworkError        # 超时 / 连接错误 / 非 2xx / 非法 JSON
├── DownloadError       # 下载失败：非 2xx / HTML 页面 / 魔数不匹配 / 空 body
└── ValidationError     # 输入或 payload 结构错误（同时继承 ValueError）
```

旧兼容方法（`certain_emoji` / `all_emoji` / `search_dress` / `certain_lottery`）走 `check_code=False`：HTTP 2xx 时**原样返回原始 dict**（含 `{"code": -101}`），由调用方判断。新 typed 方法走 `check_code=True`，严格抛 typed 异常。仅当调用方**显式要求认证**（`all_emoji` / `all_packages`）且本地无 cookie 时，才在请求前抛 `AuthRequired`。

## 日志

默认 stdlib `logging`，中文消息。库导入时只挂 `NullHandler`，不抢 stderr、不改全局配置：

```python
from biliemoji._logging import configure_logging

configure_logging(level="INFO")  # 显式 opt-in 才输出日志
```

也可用环境变量 `BILIEMOJI_LOG_LEVEL=DEBUG` 控制级别。日志与异常消息**绝不包含 cookie 或完整 header**。

## 从 1.x 升级（破坏性变更）

| 变更 | 说明 |
|---|---|
| `MultiTDownload.download()` 失败抛 `DownloadError` | 原静默把 404 页面写成文件；成功返回 `DownloadResult`（原返回 `None`） |
| `MultiTDownload.start()` / `*_download()` 阻塞至完成 | 原 fire-and-forget；现在返回 `DownloadBatchResult` |
| `all_emoji` 无 cookie 抛 `AuthRequired` | 原抛 `ValueError("cookie为空不合法")`；`AuthRequired` 同时继承 `ValueError`，旧 `except` 仍可捕获 |
| `set_header()` 不再打印 cookie；`all_emoji` 不再变异 `self.header` | 副作用消除；cookie 改按请求构造 |
| `sanitize_filename` 统一用 `_` 替换非法字符 | 原替换为空格；字符集合并（含中文标点） |
| 依赖：移除 loguru | 改用 stdlib `logging` |

**不变的兼容面**：`from biliemoji.download import MultiTDownload, PartID`、`from biliemoji.dress import Dress`、`from biliemoji.emoji import Business, Emoji` 及各自公开方法签名保持可用；旧方法仍返回原始 dict。

## 更新日志

`2.0.0` SDK 重构：typed 模型、有界并发下载器（原子落盘 / 校验 / 重试）、结构化错误、stdlib 中文日志、`BILIEMOJI_COOKIE` 环境变量、`download_package` / `download_collection` 一步下载。修复：线程不 join、下载不查状态、`proxies` 被忽略、死代码、`setup.py` 缺依赖。

`1.2.0` 增加动图与视频下载。

`1.1.0` 修复装扮下载路径导致图片标题错误；`save_json` 拆分 auto / simple。

`1.0.0` 首次发布。
