Metadata-Version: 2.4
Name: weibo-cli
Version: 1.0.6
Summary: 微博 API 的纯异步客户端
Author-email: Birkhoff <admin@maikebuke.com>
License-Expression: AGPL-3.0-only
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: httpx>=0.24.0
Requires-Dist: pydantic>=2.0.0
Requires-Dist: esprima>=4.0.1
Requires-Dist: lxml>=4.9.0
Requires-Dist: playwright==1.52.0
Requires-Dist: huggingface-hub>=1.8.0
Provides-Extra: dev
Requires-Dist: build>=1.2.2.post1; extra == "dev"
Requires-Dist: black>=23.0.0; extra == "dev"
Requires-Dist: isort>=5.12.0; extra == "dev"
Requires-Dist: mypy>=1.0.0; extra == "dev"
Requires-Dist: pre-commit>=3.0.0; extra == "dev"
Requires-Dist: pytest>=8.3.5; extra == "dev"
Requires-Dist: pytest-asyncio>=0.24.0; extra == "dev"
Requires-Dist: pytest-cov>=5.0.0; extra == "dev"
Dynamic: license-file

# weibo-cli

简洁的微博 API 异步客户端。类型安全，基于本地 Playwright profile 复用登录态。

## 安装

```bash
uv pip install -e .
uv run weibo-cli install-browser
```

`install-browser` **不再自动安装**任何东西，只会打印手动安装提示。你需要自己执行：

```bash
uv pip install playwright
PLAYWRIGHT_DOWNLOAD_HOST=https://npmmirror.com/mirrors/playwright uv run playwright install chromium
uv run playwright install-deps
```

## 快速开始

首次使用前，先准备本地浏览器会话：

```bash
uv run weibo-cli login --cache-dir ~/.weibo-api/cache
```

登录成功后，正常 API 只读取本地导出的 Cookie：

```python
import asyncio
from weibo_cli import WeiboClient

async def main():
    async with WeiboClient(cache_dir="~/.weibo-api/cache") as client:
        # 获取用户信息
        user = await client.get_user("1749127163")
        print(f"用户: {user.screen_name}")

        # 获取用户微博
        posts = await client.get_user_posts("1749127163", page=1)
        print(f"微博数: {len(posts)}")

        # 获取微博详情
        post = await client.get_post("5226761046462968")
        print(f"内容: {post.text}")

        # 获取评论
        comments = await client.get_post_comments("5226761046462968")
        print(f"评论数: {len(comments)}")

        # 获取好友圈时间线（每次都会先查 allGroups 再解析分组）
        timeline = await client.get_group_timeline("互相关注", count=25)
        print(f"好友圈微博数: {len(timeline)}")

asyncio.run(main())
```

## 浏览器会话与缓存目录

- 默认缓存目录是 `~/.weibo-api/cache`
- 这个目录是单账号、单 profile 的本地真源
- 调用者如果想手动管理多个账号，直接传不同的 `cache_dir` 即可
- 浏览器 profile 存在 `cache_dir/profile/`
- 导出的 HTTP Cookie 存在 `cache_dir/export/cookie_header.txt`
- 库运行状态和刷新调度存在 `cache_dir/state.json`
- 库自己的元数据存在 `cache_dir/meta.json`

## 会话生命周期

- 认证方式改为 `Playwright persistent context`
- Cookie 来源是**单向的**：只从本地 Playwright profile 导出，不接受手工 Cookie 注入
- 用户手动在浏览器里登录微博，库只负责保存和复用浏览器状态
- 普通 API 请求只读取本地导出的 Cookie，不会在主请求路径里隐式访问 Hugging Face
- `cookie_ttl` 是成功登录/刷新后的固定刷新间隔；普通 API 使用只更新 `last_access_at`，不会顺延 `next_refresh_at`
- 如果 `state.json` 的 `next_refresh_at` 到期，请求前会触发一次 Playwright 刷新：打开 `weibo.com`、等待页面加载、再探测微博 timeline 是否可访问
- 如果请求过程中遇到登录失效/验证码拦截，读接口会再触发一次 Playwright 刷新探测；如果 profile 已失效，则直接报错，不做自动登录
- 写接口不会在鉴权失败后自动重放，避免重复写入
- `backup push/pull` 会同步完整会话缓存：`profile/`、`export/`、`meta.json`、`state.json`
- `lock/` 和 `archive/` 不会同步

## 常用命令

```bash
# 打印 Playwright/Chromium 手工安装提示
uv run weibo-cli install-browser

# 首次手动登录，初始化本地 profile
uv run weibo-cli login --cache-dir ~/.weibo-api/cache

# 复用现有 profile 刷新 Cookie
uv run weibo-cli refresh --cache-dir ~/.weibo-api/cache

# 查看本地缓存状态
uv run weibo-cli status --cache-dir ~/.weibo-api/cache

# 手动备份完整缓存到 Hugging Face Bucket
uv run weibo-cli backup push --cache-dir ~/.weibo-api/cache --token <token>

# 手动从 Hugging Face Bucket 恢复完整缓存
uv run weibo-cli backup pull --cache-dir ~/.weibo-api/cache --token <token>
```

## API

### WeiboClient

**异步上下文管理器，自动处理连接生命周期。**

```python
async with WeiboClient(
    config=None,
    logger=None,
    max_concurrent_requests=4,
    requests_per_interval=10,
    rate_interval_seconds=1.0,
    cache_dir=None,
) as client:
    ...
```

#### 参数
- `config`: `WeiboConfig` 实例，默认使用标准配置
- `logger`: 自定义 logger，默认使用模块 logger
- `max_concurrent_requests`: 并发请求上限
- `requests_per_interval`: 每时间窗口的请求数上限
- `rate_interval_seconds`: 速率限制窗口（秒）
- `rate_limiter`: 传入自定义 `RateLimiter`，覆盖默认策略
- `cache_dir`: 浏览器会话缓存目录，默认是 `~/.weibo-api/cache`

#### 会话方法

- `await get_cookies() -> str`：返回当前导出的 `Cookie` 头，必要时会触发刷新
- `await refresh_cookies() -> str`：强制触发一次 Playwright 刷新并重新导出 Cookie
- `await validate_cookies() -> bool`：验证当前导出的浏览器会话是否仍可访问微博 API
- `get_cached_cookies() -> str | None`：只读返回当前缓存的 Cookie 头，不触发刷新
- `await get_cookie_snapshot() -> dict[str, float | str | None]`：返回当前会话快照信息

#### 业务方法与实际接口

| 方法 | HTTP 接口 | 返回 | 说明 |
| --- | --- | --- | --- |
| `get_basic_info()` | `GET /ajax/setting/getBasicInfo` | `BasicInfo` | 获取当前账号资料设置 |
| `get_user(user_id)` | `GET /ajax/profile/info?uid=<user_id>` | `User` | 获取用户资料 |
| `get_user_posts(user_id, page=1)` | `GET /ajax/statuses/mymblog?uid=<user_id>&page=<page>` | `list[Post]` | 获取用户微博时间线 |
| `get_groups()` | `GET /ajax/feed/allGroups` | `list[Group]` | 获取好友圈/分组列表 |
| `get_group_timeline(group, count=25, refresh=4, fast_refresh=1)` | `GET /ajax/feed/groupstimeline` | `list[Post]` | 获取指定分组时间线 |
| `get_post(post_id)` | `GET /ajax/statuses/show?id=<post_id>` | `Post` | 获取微博详情 |
| `get_post_comments(post_id)` | `GET /ajax/statuses/buildComments?...&id=<post_id>` | `list[Comment]` | 获取微博评论 |
| `get_mentions(page=1)` | `GET /ajax/statuses/mentions?page=<page>` | `list[Post]` | 获取 `@我` 的微博列表 |
| `get_received_comments(page=1)` | `GET /ajax/message/cmt?page=<page>` | `list[Comment]` | 获取收到的评论 |
| `upload_comment_image(image_path)` | `POST https://picupload.weibo.com/interface/upload.php` | `str` | 上传评论图片并返回 `pic_id` |
| `reply_comment(post_id, comment_id, content, image_path=None, pic_id=None)` | `POST /ajax/comments/reply` | `CommentActionResult` | 回复评论，可附图 |
| `post_comment(post_id, content, image_path=None, pic_id=None)` | `POST /ajax/comments/create` | `CommentActionResult` | 发表评论，可附图 |

#### Playwright 刷新探针

- Playwright 刷新会先访问桌面首页（`config.api.home_url`，默认是 `https://weibo.com/`）
- 页面加载后，再探测 `GET /ajax/statuses/mymblog?uid=1642909335&page=1`
- 只有探针返回 `ok=1` 且 timeline 非空，才认为当前 profile 仍然有效

### WeiboConfig

**配置类，控制 HTTP、认证、API 行为。**

```python
from weibo_cli import WeiboConfig

# 默认配置
config = WeiboConfig()

# 快速配置（低延迟，低重试）
config = WeiboConfig.create_fast()

# 保守配置（高超时，高重试）
config = WeiboConfig.create_conservative()
```

#### 配置字段

```python
@dataclass
class WeiboConfig:
    http: HttpConfig       # HTTP 配置
    auth: AuthConfig       # 认证配置
    api: ApiConfig         # API 端点配置
```

**HttpConfig**
```python
timeout: float = 10.0                    # 请求超时（秒）
max_retries: int = 3                     # 最大重试次数
base_delay: float = 1.0                  # 基础延迟（秒）
max_delay: float = 60.0                  # 最大延迟（秒）
max_connections: int = 20                # 最大连接数
max_keepalive_connections: int = 5       # 保持活跃连接数
```

**AuthConfig**
```python
cookie_ttl: float = 1800.0               # 固定 Cookie 刷新间隔（秒），默认 30 分钟
```

**ApiConfig**
```python
base_url: str = "https://weibo.com"
mobile_url: str = "https://m.weibo.cn"
user_agent: str = "Mozilla/5.0 ..."
```

### 数据模型

**所有模型都基于 [Pydantic v2](https://docs.pydantic.dev/) 的 `BaseModel`，自动完成类型转换、校验与序列化。**

**User**
```python
from weibo_cli.models import User

user = User(
    id=123,
    screen_name="Test User",
    profile_image_url="https://example.com/avatar.jpg",
    followers_count=100,
)

# Pydantic API 同样可用
user_dict = user.model_dump()
```

**Post**
```python
from datetime import datetime

from weibo_cli.models import Post, Image, Video

post = Post(
    id=1,
    created_at=datetime.utcnow(),
    text="Hello Weibo",
    user=user,
    images=[Image(id="pic1", thumbnail_url="...", large_url="...", original_url="...")],
    video=Video(duration=10.5, play_count=999),
    reposts_count=1,
    comments_count=2,
    attitudes_count=3,
)

# 每个模型都带有 `raw` 字段，保存原始结构化 payload 的深拷贝快照
print(post.raw)
```

**BasicInfo / Group / CommentActionResult**
```python
from weibo_cli import CommentActionResult, Group, WeiboClient

async with WeiboClient(cache_dir="~/.weibo-api/cache") as client:
    info = await client.get_basic_info()
    print(info.screen_name)

    groups: list[Group] = await client.get_groups()
    print(groups[0].name, groups[0].list_id)

    result: CommentActionResult = await client.post_comment("5226761046462968", "Hello")
    if result.comment is not None:
        print(result.comment.id)

    pic_id = await client.upload_comment_image("./comment.gif")
    await client.post_comment("5226761046462968", "Hello with image", pic_id=pic_id)
    await client.post_comment("5226761046462968", "Hello with image", image_path="./comment.gif")
```

`upload_comment_image` 使用微博网页端评论图上传接口。实测必需参数是
`ent=miniblog` 和按图片原始 bytes 计算的 `cs=crc32(image_bytes)`；
`ori=1` 会保留，用于请求原图上传语义。

### 异常

```python
from weibo_cli.exceptions import (
    WeiboError,      # 基础异常
    AuthError,       # 认证失败
    NetworkError,    # 网络错误
    ParseError,      # 解析错误
)
```

## 测试

```bash
uv run pytest -v
```

## 发布前清理

发布/打包前运行 `scripts/prep_release.sh`，脚本会：

- 执行 `python -m compileall -q src tests`，提前暴露语法错误
- 清理 `__pycache__` 目录，确保工作区干净

```bash
bash scripts/prep_release.sh
```

## 验证会话导出

执行 `weibo-cli refresh` 后，可以直接检查本地导出文件：

```bash
uv run weibo-cli refresh --cache-dir ~/.weibo-api/cache
cat ~/.weibo-api/cache/export/cookie_header.txt
cat ~/.weibo-api/cache/state.json
```

如果导出成功，`cookie_header.txt` 会包含 `SUB` / `SUBP` 等微博登录 Cookie，`state.json` 会记录最近一次刷新、下次刷新和访问时间戳。

```bash
# 运行所有测试
uv run pytest -v

# 带覆盖率
uv run pytest --cov -v
```

## 架构

```
WeiboClient              # Facade 入口
├── HttpClient           # HTTP 请求
├── AuthProvider         # 认证材料抽象
├── BrowserSessionManager# Playwright 持久浏览器会话
├── LocalCache           # 本地 cache_dir 真源
├── RetryStrategy        # 重试策略（指数退避）
├── UserParser           # 用户数据解析
├── PostParser           # 微博数据解析
└── CommentParser        # 评论数据解析
```

**设计原则**：
- 单一职责：每个类只做一件事
- 组合优于堆叠：`WeiboClient` 只是把 HTTP、认证、解析串起来
- 无全局状态：线程安全
- 显式优于隐式：所有配置都可见

## 许可证

GNU Affero General Public License v3.0
