
Weibo Cli
- 148 installs
- 78 repo stars
- Updated March 14, 2026
- jackwener/weibo-cli
Operate Weibo from the terminal: post updates, read timelines, search topics, and automate social monitoring pipelines for Chinese microblogging without browser-only workflows.
About
Skill for jackwener/weibo-cli: command-line access to Weibo for posting, reading feeds, searching users and topics, managing sessions, and building automated social monitoring or publishing workflows from the terminal.
- Terminal posting and timeline reading commands
- Search, hashtag, and user lookup operations
- Authentication and session persistence guidance
- JSON or table output for downstream automation
- Safe rate limiting and retry patterns for API calls
Weibo Cli by the numbers
- 148 all-time installs (skills.sh)
- +5 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #219 of 550 CLI & Terminal skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/jackwener/weibo-cli --skill weibo-cliAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 148 |
|---|---|
| repo stars | ★ 78 |
| Last updated | March 14, 2026 |
| Repository | jackwener/weibo-cli ↗ |
What it does
Operate Weibo from the terminal: post updates, read timelines, search topics, and automate social monitoring pipelines for Chinese microblogging without browser-only workflows.
Files
weibo-cli — Weibo CLI Tool
Binary: weibo Credentials: browser cookies (auto-extracted) or QR code login
Setup
# Install (requires Python 3.10+)
git clone git@github.com:jackwener/weibo-cli.git
cd weibo-cli && uv syncAuthentication
IMPORTANT FOR AGENTS: Before executing ANY weibo command, check if credentials exist first. Do NOT assume cookies are configured.
Step 0: Check if already authenticated
weibo status 2>/dev/null && echo "AUTH_OK" || echo "AUTH_NEEDED"If AUTH_OK, skip to Command Reference. If AUTH_NEEDED, proceed to Step 1.
Step 1: Guide user to authenticate
Method A: Browser cookie extraction (recommended)
Ensure user is logged into weibo.com in any supported browser (Chrome, Arc, Edge, Firefox, Brave, Chromium, Opera, Vivaldi, Safari, LibreWolf). weibo-cli auto-extracts cookies.
weibo login
weibo login --qrcode # QR code login directly (skip browser cookies)
weibo statusMethod B: QR code login
weibo login
# → Renders QR in terminal using Unicode half-blocks
# → Scan with Weibo App (我的 → 扫一扫) → confirmStep 2: Handle common auth issues
| Symptom | Agent action |
|---|---|
⚠️ 未登录 | Guide user to login to weibo.com in browser, then run weibo login |
会话已过期 | Run weibo logout && weibo login |
| Cookie extraction hangs | Browser may be running; close browser and retry |
Output Format
Default: Rich table (human-readable)
weibo hot # Pretty table outputJSON / YAML: structured output
weibo hot --json # JSON to stdout
weibo hot --yaml # YAML output
weibo hot --json | jq '.realtime[:3]' # Filter with jqNon-TTY stdout defaults to YAML automatically.
Command Reference
Reading
| Command | Description | Example |
|---|---|---|
weibo hot | Hot search list (50+ topics) | weibo hot --count 10 --json |
weibo trending | Real-time search trends | weibo trending --count 10 --yaml |
weibo search <keyword> | Search weibos by keyword | weibo search "科技" --count 5 --json |
weibo feed | Hot timeline | weibo feed --count 5 --json |
weibo home | Following timeline | weibo home --count 10 --json |
weibo detail <mblogid> | View weibo with stats | weibo detail Qw06Kd98p --json |
weibo comments <mblogid> | View comments | weibo comments Qw06Kd98p --count 10 |
weibo reposts <mblogid> | View reposts/forwards | weibo reposts Qw06Kd98p --count 5 |
weibo profile <uid> | User profile | weibo profile 1699432410 --json |
weibo weibos <uid> | User's published weibos | weibo weibos 1699432410 --count 5 |
weibo following <uid> | User's following list | weibo following 1699432410 |
weibo followers <uid> | User's follower list | weibo followers 1699432410 |
Account
| Command | Description |
|---|---|
weibo login | Extract cookies from browser / QR login |
weibo login --qrcode | QR code login directly (skip browser) |
weibo login --cookie-source <browser> | Extract from specific browser |
weibo logout | Clear saved credentials |
weibo status | Check authentication status |
weibo me | Show current user profile |
Agent Workflow Examples
Browse hot topics and read details
# Get hot search topics
MBLOG=$(weibo hot --json | jq -r '.realtime[0].mblog_id // empty')
# Read a specific weibo
weibo detail Qw06Kd98p --json | jq '{text: .text_raw, likes: .attitudes_count, comments: .comments_count}'Analyze user profile
weibo profile 1699432410 --json | jq '.user | {name: .screen_name, followers: .followers_count, posts: .statuses_count}'
weibo weibos 1699432410 --count 3 --jsonRead comments on a weibo
weibo comments Qw06Kd98p --json | jq '.data[:5] | .[].text_raw'Daily monitoring workflow
# Top 10 hot topics
weibo hot --json | jq '.realtime[:10] | .[] | {rank, word, num}'
# Trending sidebar
weibo trending --yaml
# Hot feed
weibo feed --count 5 --jsonError Codes
Structured error codes returned in CLI output:
not_authenticated— cookies expired or missingrate_limited— too many requestsinvalid_params— missing or invalid parametersqr_expired— QR code has expiredapi_error— upstream Weibo API error
Limitations
- Read-only — no posting, liking, or retweeting
- No DMs — cannot access private messages
- Single account — one set of credentials at a time
- Rate limited — built-in Gaussian jitter delay (~1s) between requests
Anti-Detection Notes for Agents
- Do NOT parallelize requests — the built-in rate-limit delay exists for account safety
- Batch operations: when doing bulk work (e.g., reading many profiles), add delays between CLI calls
- Session stability: all requests share consistent Chrome 145 headers per session
Safety Notes
- Do not ask users to share raw cookie values in chat logs.
- Prefer local browser cookie extraction over manual secret copy/paste.
- If auth fails, ask the user to re-login via
weibo login. - Agent should treat cookie values as secrets (do not echo to stdout unnecessarily).
- Built-in rate-limit delay protects accounts; do not bypass it.
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_call:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.12"]
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
- name: Set up Python ${{ matrix.python-version }}
run: uv python install ${{ matrix.python-version }}
- name: Install dependencies
run: uv sync --extra dev --extra yaml
- name: Lint
run: uv run ruff check .
- name: Test
run: uv run pytest tests/ -v --tb=short
name: Publish to PyPI
on:
push:
tags:
- "v*"
workflow_dispatch:
jobs:
verify:
uses: ./.github/workflows/ci.yml
publish:
needs: verify
runs-on: ubuntu-latest
environment: pypi
permissions:
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Setup uv
uses: astral-sh/setup-uv@v6
- name: Build package
run: uv build
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
.venv/
*.egg-info/
__pycache__/
dist/
uv.lock
[project]
name = "kabi-weibo-cli"
version = "0.2.1"
description = "A CLI for Weibo (微博) — search, browse hot topics, read timelines, explore user profiles from the terminal"
readme = "README.md"
license = "Apache-2.0"
requires-python = ">=3.10"
authors = [{ name = "jackwener", email = "jakevingoo@gmail.com" }]
keywords = ["weibo", "sina", "cli", "terminal", "social-media", "microblog"]
classifiers = [
"Development Status :: 3 - Alpha",
"Environment :: Console",
"License :: OSI Approved :: Apache Software License",
"Programming Language :: Python :: 3",
"Topic :: Communications",
]
dependencies = [
"click>=8.0",
"rich>=13.0",
"httpx>=0.27",
"browser-cookie3>=0.19",
"qrcode>=7.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"ruff>=0.11.0",
]
yaml = [
"pyyaml>=6.0",
]
[project.urls]
Homepage = "https://github.com/jackwener/weibo-cli"
Repository = "https://github.com/jackwener/weibo-cli"
Issues = "https://github.com/jackwener/weibo-cli/issues"
[project.scripts]
weibo = "weibo_cli.cli:cli"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["weibo_cli"]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-m 'not smoke'"
markers = [
"smoke: end-to-end tests requiring live cookies (run with: pytest -m smoke)",
]
[tool.ruff]
target-version = "py310"
line-length = 140
weibo-cli
  
A CLI for Weibo (微博) — search, browse hot topics, read timelines, and explore user profiles from the terminal 🐦
More Tools
- twitter-cli — Twitter/X CLI for timelines, bookmarks, and posting
- xiaohongshu-cli — Xiaohongshu (小红书) CLI for notes and account workflows
- bilibili-cli — Bilibili CLI for videos, users, search, and feeds
- discord-cli — Discord CLI for local-first sync, search, and export
- tg-cli — Telegram CLI for local-first sync, search, and export
English
Features
Read:
- Hot search: browse real-time trending topics and hashtags
- Hot timeline: browse the trending feed
- Home feed: browse your following timeline
- Search: find weibos by keyword
- Search trends: real-time trending sidebar data
- Weibo detail: view a weibo with full text, media, and stats
- Comments: read comments on any weibo
- Reposts: view forwards/reposts of any weibo
- User profiles: view user info, stats, and bio
- User weibos: browse a user's published weibos
- Following: view a user's following list
- Followers: view a user's follower list
- Structured output: export any data as JSON or YAML for scripting and AI agent integration
AI Agent Tip: Prefer--yamlfor structured output unless strict JSON is required. Non-TTY stdout defaults to YAML automatically. Use--countto limit results.
Auth & Anti-Detection:
- Cookie auth: auto-extract from Arc/Chrome/Edge/Firefox/Brave/Chromium/Opera/Vivaldi
- QR code login: terminal-rendered QR code for Weibo App scan
- Credential persistence: auto-save to
~/.config/weibo-cli/credential.jsonwith 7-day TTL - Anti-detection: Chrome 145 User-Agent, Gaussian jitter, exponential backoff
- Session auto-refresh: stale credentials trigger browser cookie re-extraction
Installation
# Recommended: uv tool (fast, isolated)
uv tool install kabi-weibo-cli
# Alternative: pipx
pipx install kabi-weibo-cliUpgrade to the latest version:
uv tool upgrade kabi-weibo-cliInstall from source:
git clone git@github.com:jackwener/weibo-cli.git
cd weibo-cli
uv syncQuick Start
# Login (auto-extract browser cookies or QR scan)
weibo login
# Browse hot search
weibo hot
# Search weibos by keyword
weibo search "科技"
# View hot timeline
weibo feed
# Check a weibo
weibo detail Qw06Kd98pUsage
# ─── Auth ─────────────────────────────────────────
weibo login # Extract cookies from browser / QR login
weibo login --qrcode # QR code login directly (skip browser)
weibo login --cookie-source chrome # Extract from specific browser
weibo logout # Clear saved credentials
weibo status # Check login status
weibo me # Show current user profile
# ─── Hot & Trending ────────────────────────────
weibo hot # Hot search list (50+ topics)
weibo hot --count 10 # Limit results
weibo hot --json # JSON output
weibo trending # Real-time search trends
weibo trending --count 10 # Limit results
weibo trending --yaml # YAML output
# ─── Search ─────────────────────────────────────
weibo search <keyword> # Search weibos by keyword
weibo search "科技" --count 5 # Limit results
weibo search "科技" --page 2 --json # Paginate + JSON output
# ─── Feed ───────────────────────────────────────
weibo feed # Hot timeline
weibo feed --count 5 # Limit results
weibo feed --json # JSON output
weibo home # Following timeline
weibo home --count 10 # Limit count
# ─── Weibo Detail ───────────────────────────────
weibo detail <mblogid> # View weibo with full stats
weibo detail Qw06Kd98p --json # JSON output
# ─── Comments & Reposts ─────────────────────────
weibo comments <mblogid> # View comments
weibo comments Qw06Kd98p --count 10 # Limit count
weibo comments Qw06Kd98p --json # JSON output
weibo reposts <mblogid> # View reposts/forwards
weibo reposts Qw06Kd98p --count 5 # Limit count
# ─── User ───────────────────────────────────────
weibo profile <uid> # User profile
weibo profile 1699432410 --json # JSON output
weibo weibos <uid> # User's weibos
weibo weibos 1699432410 --count 5 # Limit count
weibo following <uid> # User's following list
weibo followers <uid> # User's follower listAuthentication
weibo-cli uses this auth priority:
1. Saved credentials — loads from ~/.config/weibo-cli/credential.json 2. Browser cookies (recommended) — auto-extract from Arc/Chrome/Edge/Firefox/Brave/Chromium/Opera/Vivaldi/Safari/LibreWolf 3. QR code login — terminal QR code, scan with Weibo App
Browser extraction is recommended — it forwards ALL Weibo cookies and is closest to normal browser traffic.
Cookie TTL is 7 days by default. After expiry, the client automatically attempts browser re-extraction.
Troubleshooting
⚠️ 未登录— Runweibo loginto authenticate会话已过期— Cookie expired, runweibo logout && weibo loginUnable to get key for cookie decryption(macOS Keychain):- SSH sessions:
security unlock-keychain ~/Library/Keychains/login.keychain-db - Local terminal: Open Keychain Access → search "Chrome Safe Storage" → Access Control → add Terminal → Save
- Requests are slow — intentional Gaussian jitter delay (~1s) to avoid triggering Weibo's risk control
Best Practices (Avoiding Bans)
- Keep request volumes low — use
--count 10instead of--count 100 - Don't run too frequently — the built-in rate limiter adds randomized delays
- Use browser cookie extraction — provides full cookie fingerprint
- Cookie values are stored locally and never uploaded
Output Modes
- Default Rich table for interactive terminal reading
--jsonfor scripts and agent pipelines--yamlfor structured output (auto-detected when stdout is not a TTY)
Development
# Install dev dependencies
uv sync --extra dev --extra yaml
# Lint + tests
uv run ruff check .
uv run pytest tests/ -v
# Smoke tests (require browser cookies)
uv run pytest tests/ -v -m smokeProject Structure
weibo_cli/
├── __init__.py
├── cli.py # Click entry point (16 commands)
├── client.py # WeiboClient (17 API methods, rate-limit, retry)
├── auth.py # QR login + browser-cookie3 + credential persistence
├── constants.py # API endpoints, headers, Chrome 145 UA
├── exceptions.py # WeiboApiError hierarchy (6 error types)
└── commands/
├── _common.py # structured_output_options, handle_command, strip_html, format_count
├── auth.py # login/logout/status/me
├── search.py # hot/feed/detail/comments/trending/search
└── personal.py # profile/weibos/following/followers/reposts/homeUse as AI Agent Skill
weibo-cli ships with a `SKILL.md` so AI agents can execute common Weibo workflows.
Skills CLI (Recommended)
npx skills add jackwener/weibo-cli| Flag | Description |
|---|---|
-g | Install globally (user-level, shared across projects) |
-a claude-code | Target a specific agent |
-y | Non-interactive mode |
Manual Install
mkdir -p .agents/skills
git clone git@github.com:jackwener/weibo-cli.git .agents/skills/weibo-cli~~OpenClaw / ClawHub~~ (Deprecated)
⚠️ ClawHub install method is deprecated and no longer supported. Use Skills CLI or Manual Install above.
---
中文
功能特性
阅读:
- 🔥 热搜:实时热门话题和标签
- 📰 热门 Feed:热门时间线
- 🏠 关注者 Feed:关注用户的时间线
- 🔍 搜索:按关键词搜索微博
- 📈 搜索趋势:实时搜索趋势侧边栏
- 📝 微博详情:查看完整正文、媒体和统计数据
- 💬 评论:查看微博评论
- 🔁 转发:查看微博转发
- 👤 用户资料:用户信息和统计
- 📋 用户微博:浏览用户已发布的微博列表
- 👥 关注列表:查看用户的关注列表
- 👥 粉丝列表:查看用户的粉丝列表
- 📊 结构化输出:支持 JSON 和 YAML,便于脚本和 AI Agent 集成
AI Agent 提示: 需要结构化输出时优先使用 --yaml,除非下游必须是 JSON。stdout 不是 TTY 时默认输出 YAML。认证与反风控:
- Cookie 认证:支持 Arc/Chrome/Edge/Firefox/Brave 等 10+ 浏览器自动提取
- 二维码登录:终端渲染二维码,用微博 APP 扫码
- 凭证持久化:自动保存到
~/.config/weibo-cli/credential.json,7 天 TTL - 反检测:Chrome 145 User-Agent、高斯抖动延迟、指数退避重试
- 会话自动刷新:过期凭证自动触发浏览器 Cookie 重提取
安装
# 推荐:uv tool(快速、隔离环境)
uv tool install kabi-weibo-cli
# 或者:pipx
pipx install kabi-weibo-cli升级到最新版本:
uv tool upgrade kabi-weibo-cli从源码安装:
git clone git@github.com:jackwener/weibo-cli.git
cd weibo-cli
uv sync使用示例
# 认证
weibo login # 从浏览器提取 Cookie / 二维码扫码
weibo login --qrcode # 直接二维码扫码登录
weibo login --cookie-source chrome # 指定浏览器提取
weibo logout # 清除已保存凭证
weibo status # 检查登录状态
weibo me # 查看当前用户信息
# 热搜
weibo hot # 热搜列表(50+ 条)
weibo hot --count 10 # 限制数量
weibo hot --json # JSON 输出
weibo trending # 搜索趋势
# 搜索
weibo search "科技" # 按关键词搜索微博
weibo search "科技" --count 5 # 限制数量
weibo search "科技" --page 2 # 翻页
# Feed
weibo feed # 热门时间线
weibo feed --count 5 # 限制数量
weibo home # 关注者时间线
# 微博详情与评论
weibo detail Qw06Kd98p # 查看微博
weibo comments Qw06Kd98p # 查看评论
weibo reposts Qw06Kd98p # 查看转发
# 用户
weibo profile 1699432410 # 用户资料
weibo weibos 1699432410 # 用户微博列表
weibo following 1699432410 # 用户关注列表
weibo followers 1699432410 # 用户粉丝列表常见问题
⚠️ 未登录— 执行weibo login认证会话已过期— Cookie 过期,执行weibo logout && weibo login- 请求较慢是正常的 — 内置高斯随机延迟(~1s)是为了模拟人类浏览行为,避免触发风控
作为 AI Agent Skill 使用
Skills CLI(推荐)
npx skills add jackwener/weibo-cli| 参数 | 说明 |
|---|---|
-g | 全局安装(用户级别,跨项目共享) |
-a claude-code | 指定目标 Agent |
-y | 非交互模式 |
手动安装
mkdir -p .agents/skills
git clone git@github.com:jackwener/weibo-cli.git .agents/skills/weibo-cliLicense
Apache-2.0
"""Shared test fixtures for Weibo CLI tests."""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
from weibo_cli.auth import Credential
@pytest.fixture
def mock_credential():
"""A valid fake credential for testing."""
return Credential(cookies={"SUB": "test_sub", "SUBP": "test_subp", "X-CSRF-TOKEN": "test_csrf"})
@pytest.fixture
def empty_credential():
"""An empty credential for testing."""
return Credential(cookies={})
@pytest.fixture
def mock_client(mock_credential):
"""A WeiboClient with a mocked httpx.Client — no real network calls."""
from weibo_cli.client import WeiboClient
client = WeiboClient.__new__(WeiboClient)
client.credential = mock_credential
client._timeout = 30.0
client._request_delay = 0 # No delay in tests
client._base_request_delay = 0
client._max_retries = 1
client._last_request_time = 0.0
client._request_count = 0
client._rate_limit_count = 0
client._http = MagicMock()
return client
@pytest.fixture
def hot_search_response():
"""Minimal hot search API response."""
return {
"ok": 1,
"data": {
"realtime": [
{
"word": "省考",
"num": 1160335,
"icon_desc": "沸",
"rank": 0,
"topic_flag": 0,
},
{
"word": "申论",
"num": 814728,
"icon_desc": "",
"rank": 1,
"topic_flag": 0,
},
],
"hotgov": {
"word": "#共赴新程之约#",
"icon_desc": "热",
},
},
}
@pytest.fixture
def profile_response():
"""Minimal profile API response."""
return {
"ok": 1,
"data": {
"user": {
"id": 1699432410,
"idstr": "1699432410",
"screen_name": "新华社",
"verified": True,
"verified_reason": "新华社官方微博",
"followers_count": 113614253,
"friends_count": 3089,
"statuses_count": 195607,
"description": "新华社官方微博,重大新闻权威首发平台。",
},
"tabList": [{"name": "微博", "tabName": "weibo"}],
},
}
@pytest.fixture
def weibo_detail_response():
"""Minimal weibo detail API response."""
return {
"ok": 1,
"visible": {"type": 0, "list_id": 0},
"created_at": "Sat Mar 14 07:20:55 +0800 2026",
"id": 5276269143133381,
"idstr": "5276269143133381",
"mid": "5276269143133381",
"mblogid": "Qw06Kd98p",
"user": {
"id": 1699432410,
"screen_name": "新华社",
"verified": True,
},
"text_raw": "测试微博正文",
"source": "微博视频号",
"reposts_count": 57,
"comments_count": 114,
"attitudes_count": 500,
"reads_count": 1595668,
"pic_ids": [],
}
"""Unit tests for auth module — credential persistence, browser extraction, QR flow."""
from __future__ import annotations
import json
import time
from unittest.mock import MagicMock
from weibo_cli.auth import (
Credential,
_render_qr_half_blocks,
clear_credential,
extract_browser_credential,
get_credential,
load_credential,
save_credential,
)
# ── Credential class ────────────────────────────────────────────────
class TestCredential:
def test_valid_credential(self):
cred = Credential(cookies={"SUB": "abc", "SUBP": "xyz"})
assert cred.is_valid
def test_empty_credential_invalid(self):
cred = Credential(cookies={})
assert not cred.is_valid
def test_to_dict_includes_saved_at(self):
cred = Credential(cookies={"SUB": "abc"})
d = cred.to_dict()
assert "cookies" in d
assert "saved_at" in d
assert isinstance(d["saved_at"], float)
def test_from_dict(self):
cred = Credential.from_dict({"cookies": {"SUB": "abc"}, "saved_at": 0})
assert cred.cookies == {"SUB": "abc"}
def test_from_dict_missing_cookies(self):
cred = Credential.from_dict({})
assert cred.cookies == {}
assert not cred.is_valid
def test_cookie_header_format(self):
cred = Credential(cookies={"A": "1", "B": "2"})
header = cred.as_cookie_header()
assert "A=1" in header
assert "B=2" in header
assert "; " in header
def test_roundtrip(self):
original = Credential(cookies={"SUB": "abc", "SUBP": "xyz", "X-CSRF-TOKEN": "csrf"})
d = original.to_dict()
restored = Credential.from_dict(d)
assert restored.cookies == original.cookies
# ── Credential persistence ──────────────────────────────────────────
class TestCredentialPersistence:
def test_save_and_load(self, tmp_path, monkeypatch):
monkeypatch.setattr("weibo_cli.auth.CONFIG_DIR", tmp_path)
monkeypatch.setattr("weibo_cli.auth.CREDENTIAL_FILE", tmp_path / "credential.json")
cred = Credential(cookies={"SUB": "test_sub"})
save_credential(cred)
loaded = load_credential()
assert loaded is not None
assert loaded.cookies == {"SUB": "test_sub"}
def test_load_nonexistent(self, tmp_path, monkeypatch):
monkeypatch.setattr("weibo_cli.auth.CREDENTIAL_FILE", tmp_path / "nonexistent.json")
assert load_credential() is None
def test_load_invalid_json(self, tmp_path, monkeypatch):
cred_file = tmp_path / "credential.json"
cred_file.write_text("not valid json!!!")
monkeypatch.setattr("weibo_cli.auth.CREDENTIAL_FILE", cred_file)
assert load_credential() is None
def test_load_empty_cookies(self, tmp_path, monkeypatch):
cred_file = tmp_path / "credential.json"
cred_file.write_text(json.dumps({"cookies": {}, "saved_at": time.time()}))
monkeypatch.setattr("weibo_cli.auth.CREDENTIAL_FILE", cred_file)
assert load_credential() is None
def test_clear_credential(self, tmp_path, monkeypatch):
cred_file = tmp_path / "credential.json"
cred_file.write_text("{}")
monkeypatch.setattr("weibo_cli.auth.CREDENTIAL_FILE", cred_file)
clear_credential()
assert not cred_file.exists()
def test_clear_nonexistent(self, tmp_path, monkeypatch):
monkeypatch.setattr("weibo_cli.auth.CREDENTIAL_FILE", tmp_path / "nonexistent.json")
# Should not raise
clear_credential()
def test_load_triggers_refresh_when_stale(self, tmp_path, monkeypatch):
cred_file = tmp_path / "credential.json"
old_time = time.time() - (8 * 86400) # 8 days ago
cred_file.write_text(json.dumps({"cookies": {"SUB": "old"}, "saved_at": old_time}))
monkeypatch.setattr("weibo_cli.auth.CONFIG_DIR", tmp_path)
monkeypatch.setattr("weibo_cli.auth.CREDENTIAL_FILE", cred_file)
fresh_cred = Credential(cookies={"SUB": "fresh"})
monkeypatch.setattr("weibo_cli.auth.extract_browser_credential", lambda: fresh_cred)
loaded = load_credential()
assert loaded is not None
assert loaded.cookies["SUB"] == "fresh"
def test_load_uses_old_when_refresh_fails(self, tmp_path, monkeypatch):
cred_file = tmp_path / "credential.json"
old_time = time.time() - (8 * 86400)
cred_file.write_text(json.dumps({"cookies": {"SUB": "old"}, "saved_at": old_time}))
monkeypatch.setattr("weibo_cli.auth.CONFIG_DIR", tmp_path)
monkeypatch.setattr("weibo_cli.auth.CREDENTIAL_FILE", cred_file)
monkeypatch.setattr("weibo_cli.auth.extract_browser_credential", lambda: None)
loaded = load_credential()
assert loaded is not None
assert loaded.cookies["SUB"] == "old"
def test_file_permissions(self, tmp_path, monkeypatch):
monkeypatch.setattr("weibo_cli.auth.CONFIG_DIR", tmp_path)
monkeypatch.setattr("weibo_cli.auth.CREDENTIAL_FILE", tmp_path / "credential.json")
save_credential(Credential(cookies={"SUB": "test"}))
perms = (tmp_path / "credential.json").stat().st_mode & 0o777
assert perms == 0o600
# ── Browser cookie extraction ───────────────────────────────────────
class TestBrowserExtraction:
def test_extraction_success(self, monkeypatch, tmp_path):
monkeypatch.setattr("weibo_cli.auth.CONFIG_DIR", tmp_path)
monkeypatch.setattr("weibo_cli.auth.CREDENTIAL_FILE", tmp_path / "credential.json")
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stdout = json.dumps({"browser": "Chrome", "cookies": {"SUB": "extracted"}})
monkeypatch.setattr("subprocess.run", lambda *a, **kw: mock_result)
cred = extract_browser_credential()
assert cred is not None
assert cred.cookies["SUB"] == "extracted"
def test_extraction_no_cookies(self, monkeypatch):
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stdout = json.dumps({"error": "no_cookies"})
monkeypatch.setattr("subprocess.run", lambda *a, **kw: mock_result)
assert extract_browser_credential() is None
def test_extraction_not_installed(self, monkeypatch):
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stdout = json.dumps({"error": "not_installed"})
monkeypatch.setattr("subprocess.run", lambda *a, **kw: mock_result)
assert extract_browser_credential() is None
def test_extraction_subprocess_failure(self, monkeypatch):
mock_result = MagicMock()
mock_result.returncode = 1
mock_result.stderr = "error"
monkeypatch.setattr("subprocess.run", lambda *a, **kw: mock_result)
assert extract_browser_credential() is None
def test_extraction_timeout(self, monkeypatch):
import subprocess
monkeypatch.setattr("subprocess.run", lambda *a, **kw: (_ for _ in ()).throw(subprocess.TimeoutExpired("cmd", 15)))
assert extract_browser_credential() is None
def test_extraction_invalid_json(self, monkeypatch):
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stdout = "not json"
monkeypatch.setattr("subprocess.run", lambda *a, **kw: mock_result)
assert extract_browser_credential() is None
def test_extraction_with_cookie_source(self, monkeypatch, tmp_path):
monkeypatch.setattr("weibo_cli.auth.CONFIG_DIR", tmp_path)
monkeypatch.setattr("weibo_cli.auth.CREDENTIAL_FILE", tmp_path / "credential.json")
captured_cmd = {}
def fake_run(cmd, **kw):
captured_cmd["args"] = cmd
result = MagicMock()
result.returncode = 0
result.stdout = json.dumps({"browser": "Firefox", "cookies": {"SUB": "fx"}})
return result
monkeypatch.setattr("subprocess.run", fake_run)
cred = extract_browser_credential(cookie_source="Firefox")
assert cred is not None
assert "Firefox" in captured_cmd["args"]
# ── get_credential chain ────────────────────────────────────────────
class TestGetCredential:
def test_returns_saved_first(self, monkeypatch):
saved = Credential(cookies={"SUB": "saved"})
monkeypatch.setattr("weibo_cli.auth.load_credential", lambda: saved)
monkeypatch.setattr("weibo_cli.auth.extract_browser_credential", lambda: None)
result = get_credential()
assert result.cookies["SUB"] == "saved"
def test_falls_back_to_browser(self, monkeypatch):
browser_cred = Credential(cookies={"SUB": "browser"})
monkeypatch.setattr("weibo_cli.auth.load_credential", lambda: None)
monkeypatch.setattr("weibo_cli.auth.extract_browser_credential", lambda: browser_cred)
result = get_credential()
assert result.cookies["SUB"] == "browser"
def test_returns_none_when_all_fail(self, monkeypatch):
monkeypatch.setattr("weibo_cli.auth.load_credential", lambda: None)
monkeypatch.setattr("weibo_cli.auth.extract_browser_credential", lambda: None)
assert get_credential() is None
# ── QR rendering ────────────────────────────────────────────────────
class TestQRRendering:
def test_render_empty_matrix(self):
assert _render_qr_half_blocks([]) == ""
def test_render_small_matrix(self):
matrix = [
[True, False],
[False, True],
]
result = _render_qr_half_blocks(matrix)
assert isinstance(result, str)
assert len(result) > 0
def test_render_all_true(self):
# 4x4 matrix ensures full blocks survive the quiet zone padding
matrix = [[True]*4 for _ in range(4)]
result = _render_qr_half_blocks(matrix)
assert "█" in result
def test_render_all_false(self):
matrix = [[False, False], [False, False]]
result = _render_qr_half_blocks(matrix)
# Should produce spaces (with quiet zone)
assert isinstance(result, str)
"""Tests for Weibo CLI — importability, command registration, and output format."""
from __future__ import annotations
import pytest
from click.testing import CliRunner
from weibo_cli.cli import cli
# ── Import & registration tests ─────────────────────────────────────
def test_import_cli():
"""CLI module is importable."""
from weibo_cli import cli as cli_mod
assert cli_mod is not None
def test_help():
runner = CliRunner()
result = runner.invoke(cli, ["--help"])
assert result.exit_code == 0
assert "Weibo CLI" in result.output
def test_version():
runner = CliRunner()
result = runner.invoke(cli, ["--version"])
assert result.exit_code == 0
assert "0.2.1" in result.output
EXPECTED_COMMANDS = [
"login", "logout", "status", "me",
"hot", "feed", "detail", "comments", "trending", "search",
"profile", "weibos", "following", "followers", "reposts", "home",
]
@pytest.mark.parametrize("cmd", EXPECTED_COMMANDS)
def test_command_registered(cmd):
"""All expected commands are registered."""
runner = CliRunner()
result = runner.invoke(cli, [cmd, "--help"])
assert result.exit_code == 0, f"{cmd} --help failed: {result.output}"
def test_command_count():
"""Ensure we have exactly the expected number of commands."""
assert len(cli.commands) == len(EXPECTED_COMMANDS)
# ── Constants tests ─────────────────────────────────────────────────
def test_constants_urls():
from weibo_cli.constants import BASE_URL, PASSPORT_URL, HOT_SEARCH_URL
assert BASE_URL == "https://weibo.com"
assert PASSPORT_URL == "https://passport.weibo.com"
assert HOT_SEARCH_URL.startswith("/ajax/")
def test_constants_headers():
from weibo_cli.constants import HEADERS
assert "User-Agent" in HEADERS
assert "Chrome" in HEADERS["User-Agent"]
# ── Exception tests ─────────────────────────────────────────────────
def test_exception_hierarchy():
from weibo_cli.exceptions import WeiboApiError, SessionExpiredError, QRExpiredError, error_code_for_exception
assert issubclass(SessionExpiredError, WeiboApiError)
assert issubclass(QRExpiredError, WeiboApiError)
assert error_code_for_exception(SessionExpiredError()) == "not_authenticated"
assert error_code_for_exception(QRExpiredError()) == "qr_expired"
def test_all_error_codes():
from weibo_cli.exceptions import (
AuthRequiredError, ParamError, RateLimitError, error_code_for_exception
)
assert error_code_for_exception(AuthRequiredError()) == "not_authenticated"
assert error_code_for_exception(RateLimitError()) == "rate_limited"
assert error_code_for_exception(ParamError("test")) == "invalid_params"
assert error_code_for_exception(ValueError("test")) == "unknown_error"
# ── Command help text ───────────────────────────────────────────────
@pytest.mark.parametrize("cmd,expected_text", [
("hot", "热搜"),
("feed", "Feed"),
("detail", "详情"),
("comments", "评论"),
("trending", "趋势"),
("search", "搜索"),
("profile", "用户资料"),
("weibos", "微博列表"),
("following", "关注列表"),
("followers", "粉丝列表"),
("reposts", "转发"),
("home", "关注者"),
])
def test_command_help_text(cmd, expected_text):
"""Each command has appropriate help description."""
runner = CliRunner()
result = runner.invoke(cli, [cmd, "--help"])
assert expected_text in result.output
@pytest.mark.parametrize("cmd", ["hot", "feed", "detail", "comments", "trending", "search", "profile", "weibos", "following", "followers", "reposts", "home"])
def test_json_option_available(cmd):
"""All data commands support --json flag."""
runner = CliRunner()
result = runner.invoke(cli, [cmd, "--help"])
assert "--json" in result.output
"""Unit tests for WeiboClient — mock all API methods, verify URL/params/response handling."""
from __future__ import annotations
import json
from unittest.mock import MagicMock
import httpx
import pytest
from weibo_cli.client import WeiboClient
from weibo_cli.exceptions import SessionExpiredError, WeiboApiError
# ── Response handling ────────────────────────────────────────────────
class TestHandleResponse:
def test_ok_1_with_data_key(self, mock_client):
raw = {"ok": 1, "data": {"realtime": [{"word": "test"}]}}
result = mock_client._handle_response(raw, "test")
assert result == {"realtime": [{"word": "test"}]}
def test_ok_1_without_data_key(self, mock_client):
raw = {"ok": 1, "statuses": []}
result = mock_client._handle_response(raw, "test")
assert result == raw
def test_ok_minus_100_raises_session_expired(self, mock_client):
raw = {"ok": -100, "url": "https://weibo.com/login.php"}
with pytest.raises(SessionExpiredError):
mock_client._handle_response(raw, "test")
def test_ok_0_login_message_raises_session_expired(self, mock_client):
raw = {"ok": 0, "message": "请先登录"}
with pytest.raises(SessionExpiredError):
mock_client._handle_response(raw, "test")
def test_ok_0_login_后使用_raises_session_expired(self, mock_client):
raw = {"ok": 0, "message": "请登录后使用"}
with pytest.raises(SessionExpiredError):
mock_client._handle_response(raw, "test")
def test_ok_0_generic_error(self, mock_client):
raw = {"ok": 0, "message": "参数错误"}
with pytest.raises(WeiboApiError, match="参数错误"):
mock_client._handle_response(raw, "test")
# ── Context manager ─────────────────────────────────────────────────
class TestContextManager:
def test_enter_creates_client(self, mock_credential):
client = WeiboClient(mock_credential, request_delay=0)
with client as c:
assert c.client is not None
assert c._http is not None
def test_exit_closes_client(self, mock_credential):
client = WeiboClient(mock_credential, request_delay=0)
with client:
pass
assert client._http is None
def test_client_without_context_raises(self, mock_credential):
client = WeiboClient(mock_credential, request_delay=0)
with pytest.raises(RuntimeError, match="not initialized"):
_ = client.client
# ── Rate limiting ────────────────────────────────────────────────────
class TestRateLimiting:
def test_mark_request_increments_counter(self, mock_client):
assert mock_client._request_count == 0
mock_client._mark_request()
assert mock_client._request_count == 1
mock_client._mark_request()
assert mock_client._request_count == 2
# ── API method tests (mocked HTTP) ──────────────────────────────────
class TestHotSearchAPI:
def test_get_hot_search_calls_correct_url(self, mock_client, hot_search_response):
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.text = json.dumps(hot_search_response)
mock_resp.json.return_value = hot_search_response
mock_resp.cookies = httpx.Cookies()
mock_client._http.request.return_value = mock_resp
result = mock_client.get_hot_search()
assert "realtime" in result
assert len(result["realtime"]) == 2
assert result["realtime"][0]["word"] == "省考"
# Verify correct URL was called
call_args = mock_client._http.request.call_args
assert call_args[0][0] == "GET"
assert "/ajax/side/hotSearch" in call_args[0][1]
class TestProfileAPI:
def test_get_profile_passes_uid(self, mock_client, profile_response):
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.text = json.dumps(profile_response)
mock_resp.json.return_value = profile_response
mock_resp.cookies = httpx.Cookies()
mock_client._http.request.return_value = mock_resp
result = mock_client.get_profile("1699432410")
assert result["user"]["screen_name"] == "新华社"
call_args = mock_client._http.request.call_args
assert "/ajax/profile/info" in call_args[0][1]
params = call_args[1].get("params", {})
assert params["uid"] == "1699432410"
class TestWeiboDetailAPI:
def test_get_weibo_detail(self, mock_client, weibo_detail_response):
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.text = json.dumps(weibo_detail_response)
mock_resp.json.return_value = weibo_detail_response
mock_resp.cookies = httpx.Cookies()
mock_client._http.request.return_value = mock_resp
result = mock_client.get_weibo_detail("Qw06Kd98p")
assert result["mblogid"] == "Qw06Kd98p"
assert result["user"]["screen_name"] == "新华社"
call_args = mock_client._http.request.call_args
assert "/ajax/statuses/show" in call_args[0][1]
params = call_args[1].get("params", {})
assert params["id"] == "Qw06Kd98p"
class TestHotTimelineAPI:
def test_get_hot_timeline_default_params(self, mock_client):
hot_response = {"ok": 1, "statuses": [], "max_id": "0"}
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.text = json.dumps(hot_response)
mock_resp.json.return_value = hot_response
mock_resp.cookies = httpx.Cookies()
mock_client._http.request.return_value = mock_resp
result = mock_client.get_hot_timeline()
assert "statuses" in result
call_args = mock_client._http.request.call_args
params = call_args[1].get("params", {})
assert params["group_id"] == "102803"
assert params["count"] == "10"
def test_get_hot_timeline_custom_count(self, mock_client):
hot_response = {"ok": 1, "statuses": [], "max_id": "0"}
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.text = json.dumps(hot_response)
mock_resp.json.return_value = hot_response
mock_resp.cookies = httpx.Cookies()
mock_client._http.request.return_value = mock_resp
mock_client.get_hot_timeline(count=5)
params = mock_client._http.request.call_args[1].get("params", {})
assert params["count"] == "5"
class TestCommentsAPI:
def test_get_comments_default_params(self, mock_client):
comments_response = {"ok": 1, "data": [], "max_id": 0}
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.text = json.dumps(comments_response)
mock_resp.json.return_value = comments_response
mock_resp.cookies = httpx.Cookies()
mock_client._http.request.return_value = mock_resp
mock_client.get_comments("12345")
params = mock_client._http.request.call_args[1].get("params", {})
assert params["id"] == "12345"
assert params["count"] == "20"
assert "max_id" not in params
def test_get_comments_with_max_id(self, mock_client):
comments_response = {"ok": 1, "data": [], "max_id": 0}
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.text = json.dumps(comments_response)
mock_resp.json.return_value = comments_response
mock_resp.cookies = httpx.Cookies()
mock_client._http.request.return_value = mock_resp
mock_client.get_comments("12345", max_id=999)
params = mock_client._http.request.call_args[1].get("params", {})
assert params["max_id"] == "999"
class TestRepostsAPI:
def test_get_reposts(self, mock_client):
reposts_response = {"ok": 1, "data": [], "total_number": 0}
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.text = json.dumps(reposts_response)
mock_resp.json.return_value = reposts_response
mock_resp.cookies = httpx.Cookies()
mock_client._http.request.return_value = mock_resp
mock_client.get_reposts("12345", page=2)
params = mock_client._http.request.call_args[1].get("params", {})
assert params["id"] == "12345"
assert params["page"] == "2"
# ── Retry behavior ──────────────────────────────────────────────────
class TestRetryBehavior:
def test_retries_on_timeout(self, mock_client):
mock_client._max_retries = 2
mock_client._http.request.side_effect = httpx.TimeoutException("timeout")
with pytest.raises(WeiboApiError, match="failed after"):
mock_client._request("GET", "/ajax/test")
assert mock_client._http.request.call_count == 2
def test_retries_on_server_error(self, mock_client):
mock_client._max_retries = 2
error_resp = MagicMock()
error_resp.status_code = 502
error_resp.cookies = httpx.Cookies()
mock_client._http.request.return_value = error_resp
success_resp = MagicMock()
success_resp.status_code = 200
success_resp.text = '{"ok": 1}'
success_resp.json.return_value = {"ok": 1}
success_resp.cookies = httpx.Cookies()
mock_client._http.request.side_effect = [error_resp, success_resp]
result = mock_client._request("GET", "/ajax/test")
assert result == {"ok": 1}
def test_html_response_raises_error(self, mock_client):
html_resp = MagicMock()
html_resp.status_code = 200
html_resp.text = "<html>Login Required</html>"
html_resp.cookies = httpx.Cookies()
html_resp.raise_for_status.return_value = None
mock_client._http.request.return_value = html_resp
with pytest.raises(WeiboApiError, match="HTML"):
mock_client._request("GET", "/ajax/test")
# ── Cookie merging ───────────────────────────────────────────────────
class TestCookieMerging:
def test_merge_response_cookies(self, mock_credential):
"""Verify that response cookies are merged back into the session."""
client = WeiboClient(mock_credential, request_delay=0)
with client:
resp = MagicMock()
resp.cookies = httpx.Cookies()
resp.cookies.set("NEW_COOKIE", "new_value")
client._merge_response_cookies(resp)
assert client.client.cookies.get("NEW_COOKIE") == "new_value"
"""Tests for _common.py utilities and new API methods."""
from __future__ import annotations
import json
from unittest.mock import MagicMock, patch
import httpx
import pytest
from weibo_cli.commands._common import format_count, strip_html
from weibo_cli.exceptions import SessionExpiredError, WeiboApiError
# ── strip_html tests ─────────────────────────────────────────────────
class TestStripHtml:
def test_basic_tags(self):
assert strip_html("<b>hello</b>") == "hello"
def test_nested_tags(self):
assert strip_html("<a href='#'><span>link</span></a>") == "link"
def test_empty_string(self):
assert strip_html("") == ""
def test_none_input(self):
assert strip_html(None) == ""
def test_no_tags(self):
assert strip_html("plain text") == "plain text"
def test_self_closing_tags(self):
assert strip_html("hello<br/>world") == "helloworld"
def test_mixed_content(self):
assert strip_html("Hello <b>world</b>! <i>Good</i>") == "Hello world! Good"
# ── format_count tests ──────────────────────────────────────────────
class TestFormatCount:
def test_small_number(self):
assert format_count(1000) == "1000"
def test_exact_10000(self):
assert format_count(10000) == "1.0万"
def test_large_number(self):
assert format_count(113614253) == "11361.4万"
def test_string_input(self):
assert format_count("5000") == "5000"
def test_string_large(self):
assert format_count("50000") == "5.0万"
def test_invalid_string(self):
assert format_count("abc") == "abc"
def test_none_input(self):
assert format_count(None) == "None"
def test_zero(self):
assert format_count(0) == "0"
# ── _handle_response unwrap tests ────────────────────────────────────
class TestHandleResponseUnwrap:
def test_unwrap_true_extracts_data(self, mock_client):
raw = {"ok": 1, "data": {"items": [1, 2, 3]}}
result = mock_client._handle_response(raw, "test", unwrap=True)
assert result == {"items": [1, 2, 3]}
def test_unwrap_false_returns_full(self, mock_client):
raw = {"ok": 1, "data": {"items": [1, 2, 3]}}
result = mock_client._handle_response(raw, "test", unwrap=False)
assert result == raw
def test_unwrap_false_with_raw_api(self, mock_client):
"""APIs like statuses/show return data at top level, not wrapped."""
raw = {"ok": 1, "mblogid": "abc", "text": "hello"}
result = mock_client._handle_response(raw, "test", unwrap=False)
assert result == raw
assert result["mblogid"] == "abc"
def test_session_expired_precise_match(self, mock_client):
"""Only precise keywords should trigger SessionExpiredError."""
raw = {"ok": 0, "message": "请先登录"}
with pytest.raises(SessionExpiredError):
mock_client._handle_response(raw, "test")
def test_login_related_not_falsely_matched(self, mock_client):
"""Messages containing '登录' but not matching keywords should raise WeiboApiError."""
raw = {"ok": 0, "message": "登录设备异常"}
with pytest.raises(WeiboApiError, match="登录设备异常"):
mock_client._handle_response(raw, "test")
# ── New API method tests ─────────────────────────────────────────────
def _mock_response(data):
"""Create a mock httpx response for the given data."""
resp = MagicMock()
resp.status_code = 200
resp.text = json.dumps(data)
resp.json.return_value = data
resp.cookies = httpx.Cookies()
return resp
class TestGetFollowersAPI:
def test_get_followers_passes_params(self, mock_client):
response = {"ok": 1, "users": [{"screen_name": "test"}]}
mock_client._http.request.return_value = _mock_response(response)
result = mock_client.get_followers("12345", page=2)
params = mock_client._http.request.call_args[1].get("params", {})
assert params["uid"] == "12345"
assert params["page"] == "2"
assert params["relate"] == "fans"
assert "users" in result
class TestGetFriendsTimelineAPI:
def test_get_friends_timeline_default(self, mock_client):
response = {"ok": 1, "statuses": [], "max_id": "0"}
mock_client._http.request.return_value = _mock_response(response)
result = mock_client.get_friends_timeline()
params = mock_client._http.request.call_args[1].get("params", {})
assert params["count"] == "20"
assert params["max_id"] == "0"
assert "statuses" in result
class TestGetFollowingAPI:
def test_get_following_passes_uid(self, mock_client):
response = {"ok": 1, "users": []}
mock_client._http.request.return_value = _mock_response(response)
mock_client.get_following("12345")
params = mock_client._http.request.call_args[1].get("params", {})
assert params["uid"] == "12345"
assert params["page"] == "1"
class TestGetConfigAPI:
def test_get_config_returns_data(self, mock_client):
response = {"ok": 1, "data": {"uid": "12345", "login": True}}
mock_client._http.request.return_value = _mock_response(response)
result = mock_client.get_config()
assert result["uid"] == "12345"
class TestSearchWeiboAPI:
def test_search_weibo_uses_mobile_client(self, mock_client):
"""Verify search_weibo creates a separate mobile client."""
search_response = {"ok": 1, "data": {"cards": []}}
mock_mobile = MagicMock()
mock_mobile.__enter__ = MagicMock(return_value=mock_mobile)
mock_mobile.__exit__ = MagicMock(return_value=False)
mock_mobile.request.return_value = _mock_response(search_response)
with patch.object(mock_client, '_build_mobile_client', return_value=mock_mobile):
result = mock_client.search_weibo("test")
assert result == search_response
mock_mobile.request.assert_called_once()
call_args = mock_mobile.request.call_args
assert call_args[1]["params"]["containerid"] == "100103type=1&q=test"
"""Smoke tests for Weibo CLI — require live browser cookies.
Run with: uv run pytest tests/test_smoke.py -v -m smoke
"""
from __future__ import annotations
import json
import pytest
from click.testing import CliRunner
from weibo_cli.cli import cli
runner = CliRunner()
@pytest.mark.smoke
def test_hot_search_live():
"""Hot search returns 50+ items."""
result = runner.invoke(cli, ["hot", "--json"])
assert result.exit_code == 0, f"stdout: {result.output}"
data = json.loads(result.output)
assert "realtime" in data
assert len(data["realtime"]) > 10
@pytest.mark.smoke
def test_trending_live():
"""Trending sidebar returns items."""
result = runner.invoke(cli, ["trending", "--json"])
assert result.exit_code == 0, f"stdout: {result.output}"
data = json.loads(result.output)
assert "realtime" in data
assert len(data["realtime"]) > 0
@pytest.mark.smoke
def test_detail_live():
"""Weibo detail returns full data."""
result = runner.invoke(cli, ["detail", "Qw06Kd98p", "--json"])
assert result.exit_code == 0, f"stdout: {result.output}"
data = json.loads(result.output)
assert "user" in data
assert data["user"]["screen_name"] == "新华社"
assert "text_raw" in data or "text" in data
@pytest.mark.smoke
def test_profile_live():
"""Profile returns user data with stats."""
result = runner.invoke(cli, ["profile", "1699432410", "--json"])
assert result.exit_code == 0, f"stdout: {result.output}"
data = json.loads(result.output)
assert "user" in data
user = data["user"]
assert user["screen_name"] == "新华社"
assert user["followers_count"] > 0
@pytest.mark.smoke
def test_feed_live():
"""Hot feed returns statuses."""
result = runner.invoke(cli, ["feed", "--count", "3", "--json"])
assert result.exit_code == 0, f"stdout: {result.output}"
data = json.loads(result.output)
assert "statuses" in data
@pytest.mark.smoke
def test_status_live():
"""Status command reports login state."""
result = runner.invoke(cli, ["status"])
assert result.exit_code == 0
# Should contain either 已登录 or 未登录
assert "已登录" in result.output or "未登录" in result.output
"""Weibo CLI."""
__version__ = "0.2.1"
"""Allow ``python -m weibo_cli`` to invoke the CLI."""
from weibo_cli.cli import cli
if __name__ == "__main__":
cli()
"""Authentication for Weibo.
Strategy:
1. Try loading saved credential from ~/.config/weibo-cli/credential.json
2. Try extracting cookies from local browsers via browser-cookie3
3. Fallback: QR code login in terminal
QR Login Flow (reverse-engineered from passport.weibo.com):
1. GET /sso/signin → obtain X-CSRF-TOKEN cookie
2. GET /sso/v2/qrcode/image → get qrid + QR image URL
3. Render QR code in terminal (data = scan URL with qrid)
4. Poll GET /sso/v2/qrcode/check every 2s until success
5. On success, follow crossdomain URL to obtain session cookies
"""
from __future__ import annotations
import json
import logging
import shutil
import subprocess
import sys
import time
from typing import Any
from urllib.parse import parse_qs, urlparse
import httpx
import qrcode
from .constants import (
CONFIG_DIR,
CREDENTIAL_FILE,
PASSPORT_HEADERS,
PASSPORT_URL,
QR_CHECK_URL,
QR_ENTRY,
QR_IMAGE_URL,
QR_REDIRECT_URL,
QR_SOURCE,
QR_VERSION,
RETCODE_QR_NOT_SCANNED,
RETCODE_SUCCESS,
SSO_SIGNIN_URL,
)
from .exceptions import QRExpiredError
logger = logging.getLogger(__name__)
# Credential TTL: warn and attempt refresh after 7 days
CREDENTIAL_TTL_DAYS = 7
_CREDENTIAL_TTL_SECONDS = CREDENTIAL_TTL_DAYS * 86400
# QR poll config
POLL_INTERVAL_S = 2
POLL_TIMEOUT_S = 240 # 4 minutes
# ── Credential data class ───────────────────────────────────────────
class Credential:
"""Holds Weibo session cookies."""
def __init__(self, cookies: dict[str, str]):
self.cookies = cookies
@property
def is_valid(self) -> bool:
return bool(self.cookies)
def to_dict(self) -> dict[str, Any]:
return {"cookies": self.cookies, "saved_at": time.time()}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> Credential:
return cls(cookies=data.get("cookies", {}))
def as_cookie_header(self) -> str:
return "; ".join(f"{k}={v}" for k, v in self.cookies.items())
# ── Credential persistence ──────────────────────────────────────────
def save_credential(credential: Credential) -> None:
"""Save credential to config file."""
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
CREDENTIAL_FILE.write_text(json.dumps(credential.to_dict(), indent=2, ensure_ascii=False))
CREDENTIAL_FILE.chmod(0o600)
logger.info("Credential saved to %s", CREDENTIAL_FILE)
def load_credential() -> Credential | None:
"""Load credential from saved file with TTL-based auto-refresh."""
if not CREDENTIAL_FILE.exists():
return None
try:
data = json.loads(CREDENTIAL_FILE.read_text())
cred = Credential.from_dict(data)
if not cred.is_valid:
return None
# Check TTL — auto-refresh if stale
saved_at = data.get("saved_at", 0)
if saved_at and (time.time() - saved_at) > _CREDENTIAL_TTL_SECONDS:
logger.info("Credential older than %d days, attempting browser refresh", CREDENTIAL_TTL_DAYS)
fresh = extract_browser_credential()
if fresh:
logger.info("Auto-refreshed credential from browser")
return fresh
logger.warning("Cookie refresh failed; using existing cookies (age: %d+ days)", CREDENTIAL_TTL_DAYS)
return cred
except (json.JSONDecodeError, KeyError) as e:
logger.warning("Failed to load saved credential: %s", e)
return None
def clear_credential() -> None:
"""Remove saved credential file."""
if CREDENTIAL_FILE.exists():
CREDENTIAL_FILE.unlink()
logger.info("Credential removed: %s", CREDENTIAL_FILE)
# ── Browser cookie extraction ───────────────────────────────────────
def extract_browser_credential(cookie_source: str | None = None) -> Credential | None:
"""Extract Weibo cookies from local browsers via browser-cookie3."""
extract_script = '''
import json, sys
try:
import browser_cookie3 as bc3
except ImportError:
print(json.dumps({"error": "not_installed"}))
sys.exit(0)
target = sys.argv[1] if len(sys.argv) > 1 else None
browsers = [
("Chrome", bc3.chrome),
("Firefox", bc3.firefox),
("Edge", bc3.edge),
("Brave", bc3.brave),
("Chromium", bc3.chromium),
("Opera", bc3.opera),
("Vivaldi", bc3.vivaldi),
]
for name, attr in [("Arc", "arc"), ("Safari", "safari"), ("LibreWolf", "librewolf")]:
fn = getattr(bc3, attr, None)
if fn:
browsers.append((name, fn))
if target:
target_lower = target.lower()
browsers = [(n, fn) for n, fn in browsers if n.lower() == target_lower]
if not browsers:
print(json.dumps({"error": f"unsupported_browser: {target}"}))
sys.exit(0)
for name, loader in browsers:
try:
cj = loader(domain_name=".weibo.com")
cookies = {c.name: c.value for c in cj if "weibo.com" in (c.domain or "") or "sina.com" in (c.domain or "")}
if cookies:
print(json.dumps({"browser": name, "cookies": cookies}))
sys.exit(0)
except Exception:
pass
print(json.dumps({"error": "no_cookies"}))
'''
try:
cmd = [sys.executable, "-c", extract_script]
if cookie_source:
cmd.append(cookie_source)
result = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
if result.returncode != 0:
logger.debug("Cookie extraction subprocess failed: %s", result.stderr)
return None
output = result.stdout.strip()
if not output:
return None
data = json.loads(output)
if "error" in data:
if data["error"] == "not_installed":
logger.debug("browser-cookie3 not installed, skipping")
else:
logger.debug("No valid Weibo cookies found: %s", data["error"])
return None
cookies = data["cookies"]
browser_name = data["browser"]
logger.info("Found cookies in %s (%d cookies)", browser_name, len(cookies))
cred = Credential(cookies=cookies)
save_credential(cred)
return cred
except subprocess.TimeoutExpired:
logger.warning("Cookie extraction timed out (browser may be running)")
return None
except (json.JSONDecodeError, KeyError) as e:
logger.warning("Cookie extraction parse error: %s", e)
return None
# ── QR Code terminal rendering ──────────────────────────────────────
def _render_qr_half_blocks(matrix: list[list[bool]]) -> str:
"""Render QR matrix using Unicode half-block characters (▀▄█ and space)."""
if not matrix:
return ""
# Add 1-module quiet zone
size = len(matrix)
padded = [[False] * (size + 2)]
for row in matrix:
padded.append([False] + list(row) + [False])
padded.append([False] * (size + 2))
matrix = padded
rows = len(matrix)
# Check terminal width
term_cols = shutil.get_terminal_size(fallback=(80, 24)).columns
qr_width = len(matrix[0])
if qr_width > term_cols:
logger.warning("Terminal too narrow (%d) for QR (%d)", term_cols, qr_width)
return ""
lines: list[str] = []
for y in range(0, rows, 2):
line = ""
top_row = matrix[y]
bottom_row = matrix[y + 1] if y + 1 < rows else [False] * len(top_row)
for x in range(len(top_row)):
top = top_row[x]
bottom = bottom_row[x]
if top and bottom:
line += "█"
elif top and not bottom:
line += "▀"
elif not top and bottom:
line += "▄"
else:
line += " "
lines.append(line)
return "\n".join(lines)
def _display_qr_in_terminal(data: str) -> bool:
"""Display *data* as a QR code in the terminal using Unicode half-blocks.
Returns True on success.
"""
qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_L)
qr.add_data(data)
qr.make(fit=True)
modules = qr.get_matrix()
rendered = _render_qr_half_blocks(modules)
if rendered:
print(rendered)
return True
# Fallback to basic ASCII
qr2 = qrcode.QRCode(
error_correction=qrcode.constants.ERROR_CORRECT_L,
box_size=1,
border=1,
)
qr2.add_data(data)
qr2.make(fit=True)
qr2.print_ascii(invert=True)
return True
# ── QR Login flow ───────────────────────────────────────────────────
def qr_login() -> Credential:
"""Full QR code login flow for Weibo.
1. Visit passport.weibo.com/sso/signin to get X-CSRF-TOKEN cookie
2. GET /sso/v2/qrcode/image → qrid + image URL
3. Extract scan URL from image URL, render QR in terminal
4. Poll /sso/v2/qrcode/check every 2s
5. On success, follow crossdomain URL for session cookies
"""
with httpx.Client(
base_url=PASSPORT_URL,
headers=dict(PASSPORT_HEADERS),
follow_redirects=True,
timeout=httpx.Timeout(30),
) as client:
# Step 1: Get CSRF token by visiting login page
logger.info("Getting CSRF token from login page...")
resp = client.get(
SSO_SIGNIN_URL,
params={
"entry": QR_ENTRY,
"source": QR_SOURCE,
"url": QR_REDIRECT_URL,
},
)
resp.raise_for_status()
csrf_token = client.cookies.get("X-CSRF-TOKEN")
if not csrf_token:
raise RuntimeError("Failed to obtain X-CSRF-TOKEN from passport.weibo.com")
logger.info("Got CSRF token: %s...", csrf_token[:20])
# Update headers with CSRF token
client.headers["x-csrf-token"] = csrf_token
# Step 2: Get QR code
logger.info("Requesting QR code...")
resp = client.get(QR_IMAGE_URL, params={"entry": QR_ENTRY, "size": "180"})
resp.raise_for_status()
qr_data = resp.json()
if qr_data.get("retcode") != RETCODE_SUCCESS:
raise RuntimeError(f"Failed to get QR code: {qr_data.get('msg', 'Unknown error')}")
qrid = qr_data["data"]["qrid"]
image_url = qr_data["data"]["image"]
logger.info("Got qrid: %s", qrid)
# Step 3: Extract scan URL from image URL and render QR
# The QR encodes: https://passport.weibo.cn/signin/qrcode/scan?qr={qrid}&...
parsed = urlparse(image_url)
qs = parse_qs(parsed.query)
scan_url = qs.get("data", [f"https://passport.weibo.cn/signin/qrcode/scan?qr={qrid}"])[0]
print("\n📱 请使用 微博APP 扫描以下二维码登录:\n")
print(" 打开微博手机APP → 我的页面 → 扫一扫\n")
_display_qr_in_terminal(scan_url)
print(f"\n⏳ 等待扫码中... (超时: {POLL_TIMEOUT_S // 60} 分钟)")
print(f" (QR ID: {qrid[:20]}...)\n")
# Step 4: Poll for scan status
start_time = time.time()
last_status = None
while (time.time() - start_time) < POLL_TIMEOUT_S:
try:
resp = client.get(
QR_CHECK_URL,
params={
"entry": QR_ENTRY,
"source": QR_SOURCE,
"url": QR_REDIRECT_URL,
"qrid": qrid,
"rid": "",
"ver": QR_VERSION,
},
)
resp.raise_for_status()
check_data = resp.json()
retcode = check_data.get("retcode")
msg = check_data.get("msg", "")
if retcode != last_status:
logger.info("QR check: retcode=%s msg=%s", retcode, msg)
last_status = retcode
if retcode == RETCODE_SUCCESS:
print(" ✅ 扫码成功!正在完成登录...")
# Step 5: Follow crossdomain URL to get session cookies
cross_url = check_data.get("data", {}).get("url", "")
alt = check_data.get("data", {}).get("alt", "")
cookies = {}
# Collect cookies from passport domain
for name, value in client.cookies.items():
cookies[name] = value
if cross_url:
logger.info("Following crossdomain URL...")
try:
# Use a separate client for cross-domain requests
with httpx.Client(
follow_redirects=True,
timeout=httpx.Timeout(30),
headers={"User-Agent": PASSPORT_HEADERS["User-Agent"]},
) as cross_client:
cross_resp = cross_client.get(cross_url)
for name, value in cross_resp.cookies.items():
cookies[name] = value
for name, value in cross_client.cookies.items():
cookies[name] = value
except Exception as e:
logger.warning("Cross-domain follow failed: %s", e)
if alt:
# alt parameter may need to be exchanged for final cookies
try:
alt_url = f"https://login.sina.com.cn/sso/login.php?entry=miniblog&alt={alt}&returntype=TEXT"
with httpx.Client(
follow_redirects=True,
timeout=httpx.Timeout(30),
headers={"User-Agent": PASSPORT_HEADERS["User-Agent"]},
) as alt_client:
alt_resp = alt_client.get(alt_url)
for name, value in alt_resp.cookies.items():
cookies[name] = value
for name, value in alt_client.cookies.items():
cookies[name] = value
except Exception as e:
logger.warning("Alt token exchange failed: %s", e)
if not cookies:
raise RuntimeError("Login succeeded but no cookies were obtained")
credential = Credential(cookies=cookies)
save_credential(credential)
print(" ✅ 登录成功!凭证已保存到", CREDENTIAL_FILE)
return credential
elif retcode == RETCODE_QR_NOT_SCANNED:
# Still waiting for scan
pass
else:
# Could be scanned/confirmed/expired
if "已扫" in msg or "扫描" in msg:
print(" 📲 已扫码,请在手机上确认登录...")
elif "过期" in msg or "expired" in msg.lower():
raise QRExpiredError()
except httpx.TimeoutException:
logger.debug("QR check timeout, retrying...")
except QRExpiredError:
raise
time.sleep(POLL_INTERVAL_S)
raise QRExpiredError()
# ── Unified get_credential ──────────────────────────────────────────
def get_credential() -> Credential | None:
"""Try all auth methods and return credential.
1. Saved credential file
2. Browser cookie extraction
"""
cred = load_credential()
if cred:
logger.info("Loaded credential from %s", CREDENTIAL_FILE)
return cred
cred = extract_browser_credential()
if cred:
logger.info("Extracted credential from browser")
return cred
return None
"""CLI entry point for Weibo CLI.
Usage:
weibo login / status / logout / me
weibo hot / feed / trending / search <keyword>
weibo detail <mblogid> / comments <mblogid> / reposts <mblogid>
weibo profile <uid> / weibos <uid> / following <uid> / followers <uid>
weibo home
"""
from __future__ import annotations
import logging
import click
from . import __version__
from .commands import auth, personal, search
@click.group()
@click.version_option(version=__version__, prog_name="weibo")
@click.option("-v", "--verbose", is_flag=True, help="Enable verbose logging (show request URLs, timing)")
@click.pass_context
def cli(ctx, verbose: bool) -> None:
"""Weibo CLI — 在终端使用微博 🐦"""
ctx.ensure_object(dict)
if verbose:
logging.basicConfig(level=logging.INFO, format="%(name)s %(message)s")
else:
logging.basicConfig(level=logging.WARNING)
# ─── Auth commands ───────────────────────────────────────────────────
cli.add_command(auth.login)
cli.add_command(auth.logout)
cli.add_command(auth.status)
cli.add_command(auth.me)
# ─── Search / Feed commands ──────────────────────────────────────────
cli.add_command(search.hot)
cli.add_command(search.feed)
cli.add_command(search.detail)
cli.add_command(search.comments)
cli.add_command(search.trending)
cli.add_command(search.search)
# ─── Personal / Profile commands ─────────────────────────────────────
cli.add_command(personal.profile)
cli.add_command(personal.weibos)
cli.add_command(personal.following)
cli.add_command(personal.followers)
cli.add_command(personal.reposts)
cli.add_command(personal.home)
if __name__ == "__main__":
cli()
"""API client for Weibo with rate limiting, retry, and anti-detection."""
from __future__ import annotations
import logging
import random
import time
from typing import Any
import httpx
from .auth import Credential
from .constants import (
BASE_URL,
BUILD_COMMENTS_URL,
FEED_GROUPS_URL,
FOLLOWERS_URL,
FRIENDS_TIMELINE_URL,
FRIENDS_URL,
GET_CONFIG_URL,
HEADERS,
HOT_BAND_URL,
HOT_SEARCH_URL,
HOT_TIMELINE_URL,
MOBILE_BASE_URL,
MOBILE_HEADERS,
MOBILE_SEARCH_URL,
MY_MBLOG_URL,
PROFILE_INFO_URL,
REPOST_TIMELINE_URL,
SEARCH_BAND_URL,
STATUSES_SHOW_URL,
)
from .exceptions import WeiboApiError, SessionExpiredError
logger = logging.getLogger(__name__)
class WeiboClient:
"""Weibo API client with Gaussian jitter, exponential backoff, and session-stable identity.
Anti-detection strategy:
- Gaussian jitter delay between requests (~1s mean, σ=0.3)
- 5% chance of a random long pause (2-5s) to mimic reading behavior
- Exponential backoff on HTTP 429/5xx (up to 3 retries)
- Response cookies merged back into session jar
"""
def __init__(
self,
credential: Credential | None = None,
timeout: float = 30.0,
request_delay: float = 1.0,
max_retries: int = 3,
):
self.credential = credential
self._timeout = timeout
self._request_delay = request_delay
self._base_request_delay = request_delay
self._max_retries = max_retries
self._last_request_time = 0.0
self._request_count = 0
self._rate_limit_count = 0
self._http: httpx.Client | None = None
def _build_client(self) -> httpx.Client:
cookies = {}
if self.credential:
cookies = self.credential.cookies
return httpx.Client(
base_url=BASE_URL,
headers=dict(HEADERS),
cookies=cookies,
follow_redirects=True,
timeout=httpx.Timeout(self._timeout),
)
@property
def client(self) -> httpx.Client:
if not self._http:
raise RuntimeError("Client not initialized. Use 'with WeiboClient() as client:'")
return self._http
def __enter__(self) -> WeiboClient:
self._http = self._build_client()
return self
def __exit__(self, *args: Any) -> None:
if self._http:
self._http.close()
self._http = None
# ── Rate limiting ───────────────────────────────────────────────
def _rate_limit_delay(self) -> None:
if self._request_delay <= 0:
return
elapsed = time.time() - self._last_request_time
if elapsed < self._request_delay:
jitter = max(0, random.gauss(0.3, 0.15))
if random.random() < 0.05:
jitter += random.uniform(2.0, 5.0)
sleep_time = self._request_delay - elapsed + jitter
logger.debug("Rate-limit delay: %.2fs", sleep_time)
time.sleep(sleep_time)
def _mark_request(self) -> None:
self._last_request_time = time.time()
self._request_count += 1
# ── Response handling ───────────────────────────────────────────
def _merge_response_cookies(self, resp: httpx.Response) -> None:
for name, value in resp.cookies.items():
if value:
self.client.cookies.set(name, value)
def _handle_response(self, data: dict[str, Any], action: str, *, unwrap: bool = True) -> dict[str, Any]:
"""Validate API response.
Weibo uses {ok: 1, data: {...}} format for most endpoints.
When unwrap=True (default), extract and return data["data"].
When unwrap=False, return the full response dict (for APIs that don't wrap data).
"""
ok = data.get("ok")
if ok == -100:
raise SessionExpiredError()
message = data.get("msg", data.get("message", "Unknown error"))
_SESSION_EXPIRED_KEYWORDS = ("请先登录", "请登录后使用", "请登录", "用户未登录")
if ok == 0:
msg_str = str(message)
if any(kw in msg_str for kw in _SESSION_EXPIRED_KEYWORDS):
raise SessionExpiredError()
raise WeiboApiError(f"{action}: {message} (ok={ok})", code=ok, response=data)
if ok == 1:
return data.get("data", data) if unwrap else data
# ok is some other truthy value (e.g. raw APIs return full dict)
if ok:
return data.get("data", data) if unwrap else data
raise WeiboApiError(f"{action}: {message} (ok={ok})", code=ok, response=data)
# ── Request with retry ──────────────────────────────────────────
def _request(self, method: str, url: str, *, client: httpx.Client | None = None, **kwargs) -> dict[str, Any]:
self._rate_limit_delay()
last_exc: Exception | None = None
http = client or self.client
for attempt in range(self._max_retries):
t0 = time.time()
try:
resp = http.request(method, url, **kwargs)
elapsed = time.time() - t0
if not client: # only merge cookies for the main client
self._merge_response_cookies(resp)
self._mark_request()
logger.info("[#%d] %s %s → %d (%.2fs)", self._request_count, method, url[:60], resp.status_code, elapsed)
if resp.status_code in (429, 500, 502, 503, 504):
wait = (2 ** attempt) + random.uniform(0, 1)
logger.warning("HTTP %d, retrying in %.1fs (%d/%d)", resp.status_code, wait, attempt + 1, self._max_retries)
time.sleep(wait)
continue
resp.raise_for_status()
text = resp.text
if text.startswith("<"):
raise WeiboApiError(f"Received HTML instead of JSON from {url}")
return resp.json()
except (httpx.TimeoutException, httpx.NetworkError) as exc:
last_exc = exc
wait = (2 ** attempt) + random.uniform(0, 1)
time.sleep(wait)
if last_exc:
raise WeiboApiError(f"Request failed after {self._max_retries} retries: {last_exc}") from last_exc
raise WeiboApiError(f"Request failed after {self._max_retries} retries")
def _get(self, url: str, params: dict[str, Any] | None = None, action: str = "", *, unwrap: bool = True) -> dict[str, Any]:
data = self._request("GET", url, params=params)
return self._handle_response(data, action, unwrap=unwrap)
# ── Hot Search / Trending ───────────────────────────────────────
def get_hot_search(self) -> dict[str, Any]:
"""Get hot search list (微博热搜 sidebar, ~52 items)."""
return self._get(HOT_SEARCH_URL, action="热搜")
def get_hot_band(self) -> dict[str, Any]:
"""Get full hot band list (微博热搜榜)."""
return self._get(HOT_BAND_URL, action="热搜榜")
def get_search_band(self) -> dict[str, Any]:
"""Get search band (trending sidebar, ~16 items)."""
return self._get(SEARCH_BAND_URL, action="搜索推荐")
# ── Feed / Timeline ─────────────────────────────────────────────
def get_hot_timeline(self, group_id: str = "102803", count: int = 10, max_id: str = "0") -> dict[str, Any]:
"""Get hot timeline (热门微博 feed)."""
return self._get(HOT_TIMELINE_URL, params={
"since_id": "0", "refresh": "0",
"group_id": group_id, "containerid": group_id,
"extparam": "discover|new_feed",
"max_id": max_id, "count": str(count),
}, action="热门Feed", unwrap=False)
def get_friends_timeline(self, count: int = 20, max_id: str = "0") -> dict[str, Any]:
"""Get friends timeline (关注者 feed, requires auth)."""
return self._get(FRIENDS_TIMELINE_URL, params={
"count": str(count), "max_id": max_id,
}, action="关注Feed", unwrap=False)
def get_feed_groups(self) -> dict[str, Any]:
"""Get feed group configuration."""
return self._get(FEED_GROUPS_URL, params={"is_new_segment": "1", "fetch_hot": "1"}, action="Feed分组", unwrap=False)
# ── User / Profile ──────────────────────────────────────────────
def get_profile(self, uid: str) -> dict[str, Any]:
"""Get user profile info."""
return self._get(PROFILE_INFO_URL, params={"uid": uid}, action="用户资料")
def get_user_weibos(self, uid: str, page: int = 1, count: int = 20, feature: int = 0) -> dict[str, Any]:
"""Get user's weibo list."""
return self._get(MY_MBLOG_URL, params={
"uid": uid, "page": str(page), "feature": str(feature),
}, action="用户微博")
# ── Weibo Detail ────────────────────────────────────────────────
def get_weibo_detail(self, mblogid: str) -> dict[str, Any]:
"""Get single weibo detail by mblogid (e.g. 'Qw06Kd98p')."""
return self._get(STATUSES_SHOW_URL, params={"id": mblogid}, action="微博详情", unwrap=False)
# ── Comments / Reposts ──────────────────────────────────────────
def get_comments(self, weibo_id: str, count: int = 20, max_id: int = 0) -> dict[str, Any]:
"""Get comments for a weibo."""
params: dict[str, Any] = {"id": weibo_id, "is_show_bulletin": "2", "count": str(count), "flow": "0"}
if max_id:
params["max_id"] = str(max_id)
return self._get(BUILD_COMMENTS_URL, params=params, action="评论")
def get_reposts(self, weibo_id: str, page: int = 1, count: int = 10) -> dict[str, Any]:
"""Get repost/forward list for a weibo."""
return self._get(REPOST_TIMELINE_URL, params={
"id": weibo_id, "page": str(page), "count": str(count),
}, action="转发", unwrap=False)
# ── Social ──────────────────────────────────────────────────────
def get_following(self, uid: str, page: int = 1) -> dict[str, Any]:
"""Get user's following list."""
return self._get(FRIENDS_URL, params={"uid": uid, "page": str(page)}, action="关注列表", unwrap=False)
def get_followers(self, uid: str, page: int = 1) -> dict[str, Any]:
"""Get user's follower list."""
return self._get(FOLLOWERS_URL, params={
"uid": uid, "page": str(page), "relate": "fans",
}, action="粉丝列表", unwrap=False)
# ── Search ──────────────────────────────────────────────────────
def _build_mobile_client(self) -> httpx.Client:
"""Build a mobile API client for m.weibo.cn endpoints."""
cookies = self.credential.cookies if self.credential else {}
return httpx.Client(
base_url=MOBILE_BASE_URL,
headers=dict(MOBILE_HEADERS),
cookies=cookies,
follow_redirects=True,
timeout=httpx.Timeout(self._timeout),
)
def search_weibo(self, keyword: str, page: int = 1) -> dict[str, Any]:
"""Search weibos by keyword using mobile API."""
containerid = f"100103type=1&q={keyword}"
params = {
"containerid": containerid,
"page_type": "searchall",
"page": str(page),
}
with self._build_mobile_client() as mobile:
data = self._request("GET", MOBILE_SEARCH_URL, params=params, client=mobile)
return data
# ── Config ──────────────────────────────────────────────────────
def get_config(self) -> dict[str, Any]:
"""Get app configuration (contains current user info)."""
return self._get(GET_CONFIG_URL, action="配置")
"""Common helpers for CLI commands."""
from __future__ import annotations
import json
import re
import sys
from typing import Any
import click
from rich.console import Console
from ..auth import Credential, get_credential
from ..client import WeiboClient
from ..exceptions import AuthRequiredError, WeiboApiError, SessionExpiredError, error_code_for_exception
console = Console()
# ── Shared formatters ───────────────────────────────────────────────
def strip_html(text: str) -> str:
"""Remove HTML tags from text."""
return re.sub(r"<[^>]+>", "", text or "")
def format_count(n: int | str) -> str:
"""Format large numbers with 万."""
try:
n = int(n)
except (ValueError, TypeError):
return str(n)
if n >= 10000:
return f"{n / 10000:.1f}万"
return str(n)
def require_auth() -> Credential:
"""Get credential or raise AuthRequiredError."""
cred = get_credential()
if not cred:
console.print("[yellow]⚠️ 未登录[/yellow],使用 [bold]weibo login[/bold] 扫码登录")
raise AuthRequiredError()
return cred
def structured_output_options(command):
"""Decorator: add --json/--yaml options to a Click command."""
command = click.option("--yaml", "as_yaml", is_flag=True, help="以 YAML 格式输出")(command)
command = click.option("--json", "as_json", is_flag=True, help="以 JSON 格式输出")(command)
return command
def handle_command(credential, *, action, render=None, as_json=False, as_yaml=False) -> Any:
"""Run action → route output: JSON / YAML(non-TTY) / Rich render.
Also supports SessionExpiredError auto browser refresh retry.
"""
try:
# First attempt
try:
with WeiboClient(credential) as client:
data = action(client)
except SessionExpiredError:
from ..auth import extract_browser_credential
fresh = extract_browser_credential()
if fresh:
with WeiboClient(fresh) as client:
data = action(client)
else:
raise
# Output routing
if as_json:
click.echo(json.dumps(data, indent=2, ensure_ascii=False))
elif as_yaml or not sys.stdout.isatty():
try:
import yaml
click.echo(yaml.dump(data, allow_unicode=True, default_flow_style=False))
except ImportError:
click.echo(json.dumps(data, indent=2, ensure_ascii=False))
elif render:
render(data)
return data
except WeiboApiError as exc:
code = error_code_for_exception(exc)
console.print(f"[red]❌ [{code}] {exc}[/red]")
return None
"""Auth commands: login, logout, status."""
from __future__ import annotations
import json
import click
from rich.panel import Panel
from ._common import console, handle_command, require_auth, structured_output_options
@click.command()
@click.option("--qrcode", is_flag=True, help="直接使用二维码扫码登录(跳过浏览器 Cookie 提取)")
@click.option("--cookie-source", type=str, default=None, help="指定浏览器 (chrome/firefox/edge/brave/arc/...)")
def login(qrcode, cookie_source):
"""登录微博(自动提取浏览器 Cookie 或 --qrcode 扫码)"""
from ..auth import extract_browser_credential, get_credential, qr_login
if qrcode:
# Skip browser cookies, go straight to QR login
try:
cred = qr_login()
if cred:
console.print("[green]✅ 登录成功![/green]")
else:
console.print("[red]❌ 登录失败[/red]")
except Exception as e:
console.print(f"[red]❌ 登录失败: {e}[/red]")
return
if cookie_source:
# Try specific browser only
cred = extract_browser_credential(cookie_source=cookie_source)
if cred:
console.print(f"[green]✅ 已从 {cookie_source} 提取 Cookie 并登录[/green]")
else:
console.print(f"[yellow]⚠️ 未在 {cookie_source} 找到有效 Cookie[/yellow]")
console.print(" 提示: 使用 [bold]weibo login --qrcode[/bold] 扫码登录")
return
# Default: try saved → browser → QR
cred = get_credential()
if cred:
console.print("[green]✅ 已登录[/green] (如需重新登录请先执行 weibo logout)")
return
try:
cred = qr_login()
if cred:
console.print("[green]✅ 登录成功![/green]")
else:
console.print("[red]❌ 登录失败[/red]")
except Exception as e:
console.print(f"[red]❌ 登录失败: {e}[/red]")
@click.command()
def logout():
"""清除已保存的登录凭证"""
from ..auth import clear_credential
clear_credential()
console.print("[green]✅ 已清除登录凭证[/green]")
@click.command()
@structured_output_options
def status(as_json, as_yaml):
"""查看当前登录状态"""
import sys
from ..auth import get_credential
cred = get_credential()
info = {
"authenticated": cred is not None,
"cookie_count": len(cred.cookies) if cred else 0,
}
if as_json:
click.echo(json.dumps(info, indent=2))
elif as_yaml or not sys.stdout.isatty():
try:
import yaml
click.echo(yaml.dump(info, allow_unicode=True, default_flow_style=False))
except ImportError:
click.echo(json.dumps(info, indent=2))
else:
if cred:
console.print(f"[green]✅ 已登录[/green] ({len(cred.cookies)} cookies)")
else:
console.print("[yellow]⚠️ 未登录[/yellow]")
@click.command()
@structured_output_options
def me(as_json, as_yaml):
"""查看个人资料"""
cred = require_auth()
def _render(data):
user = data.get("user", data)
lines = []
if user.get("screen_name"):
lines.append(f"[bold]昵称[/bold]: {user['screen_name']}")
if user.get("description"):
lines.append(f"[bold]简介[/bold]: {user['description']}")
if user.get("followers_count") is not None:
lines.append(f"[bold]粉丝[/bold]: {user['followers_count']}")
if user.get("friends_count") is not None:
lines.append(f"[bold]关注[/bold]: {user['friends_count']}")
if user.get("statuses_count") is not None:
lines.append(f"[bold]微博[/bold]: {user['statuses_count']}")
if user.get("location"):
lines.append(f"[bold]位置[/bold]: {user['location']}")
if user.get("verified_reason"):
lines.append(f"[bold]认证[/bold]: {user['verified_reason']}")
text = "\n".join(lines) if lines else "无法获取个人资料"
console.print(Panel(text, title="👤 个人资料", border_style="cyan"))
def _action(client):
# Try the direct ME endpoint first
try:
data = client._get("/ajax/profile/me", action="个人资料")
return data
except Exception:
pass
# Fallback: get config to find current UID, then get profile
try:
config = client.get_config()
uid = str(config.get("uid", config.get("user", {}).get("id", "")))
if uid:
return client.get_profile(uid)
except Exception:
pass
return {"error": "无法获取个人资料,请确认已登录"}
handle_command(cred, action=_action, render=_render, as_json=as_json, as_yaml=as_yaml)
"""Personal & profile commands: profile, weibos, following, followers, reposts, home."""
from __future__ import annotations
import click
from rich.panel import Panel
from ._common import console, format_count, handle_command, require_auth, structured_output_options
from .renderers import render_repost_list, render_user_table, render_weibo_list
@click.command()
@click.argument("uid")
@structured_output_options
def profile(uid, as_json, as_yaml):
"""查看用户资料 (weibo profile <uid>)"""
cred = require_auth()
def _render(data):
user = data.get("user", data)
lines = []
name = user.get("screen_name", "未知")
verified = " ✓" if user.get("verified") else ""
lines.append(f"[bold cyan]{name}{verified}[/bold cyan]")
if user.get("verified_reason"):
lines.append(f"[dim]{user['verified_reason']}[/dim]")
if user.get("description"):
lines.append(f"\n{user['description']}")
lines.append("")
stats = []
if user.get("followers_count") is not None:
stats.append(f"[bold]粉丝[/bold] {format_count(user['followers_count'])}")
if user.get("friends_count") is not None:
stats.append(f"[bold]关注[/bold] {format_count(user['friends_count'])}")
if user.get("statuses_count") is not None:
stats.append(f"[bold]微博[/bold] {format_count(user['statuses_count'])}")
if stats:
lines.append(" | ".join(stats))
if user.get("location"):
lines.append(f"\n📍 {user['location']}")
if user.get("gender"):
gender = "♂ 男" if user["gender"] == "m" else "♀ 女" if user["gender"] == "f" else ""
if gender:
lines.append(f" {gender}")
console.print(Panel("\n".join(lines), title=f"@{name}", border_style="cyan", padding=(0, 1)))
tabs = data.get("tabList", [])
if tabs:
tab_names = [t.get("tabName", t.get("name", "")) for t in tabs]
console.print(f"[dim]可用 Tab: {' | '.join(tab_names)}[/dim]")
def _action(client):
return client.get_profile(uid)
handle_command(cred, action=_action, render=_render, as_json=as_json, as_yaml=as_yaml)
@click.command()
@click.argument("uid")
@click.option("--page", "-p", default=1, help="页码")
@click.option("--count", "-n", default=20, help="条数")
@structured_output_options
def weibos(uid, page, count, as_json, as_yaml):
"""查看用户微博列表 (weibo weibos <uid>)"""
cred = require_auth()
def _render(data):
statuses = data if isinstance(data, list) else data.get("list", data.get("statuses", []))
render_weibo_list(statuses, count=count, show_user=False)
def _action(client):
return client.get_user_weibos(uid, page=page)
handle_command(cred, action=_action, render=_render, as_json=as_json, as_yaml=as_yaml)
@click.command()
@click.argument("uid")
@click.option("--page", "-p", default=1, help="页码")
@structured_output_options
def following(uid, page, as_json, as_yaml):
"""查看用户关注列表 (weibo following <uid>)"""
cred = require_auth()
def _render(data):
users = data.get("users", []) if isinstance(data, dict) else data
render_user_table(users, title="关注列表", empty_msg="[yellow]暂无关注[/yellow]")
def _action(client):
return client.get_following(uid, page=page)
handle_command(cred, action=_action, render=_render, as_json=as_json, as_yaml=as_yaml)
@click.command()
@click.argument("uid")
@click.option("--page", "-p", default=1, help="页码")
@structured_output_options
def followers(uid, page, as_json, as_yaml):
"""查看用户粉丝列表 (weibo followers <uid>)"""
cred = require_auth()
def _render(data):
users = data.get("users", []) if isinstance(data, dict) else data
render_user_table(users, title="粉丝列表", empty_msg="[yellow]暂无粉丝[/yellow]")
def _action(client):
return client.get_followers(uid, page=page)
handle_command(cred, action=_action, render=_render, as_json=as_json, as_yaml=as_yaml)
@click.command()
@click.argument("mblogid")
@click.option("--count", "-n", default=10, help="转发条数")
@click.option("--page", "-p", default=1, help="页码")
@structured_output_options
def reposts(mblogid, count, page, as_json, as_yaml):
"""查看微博转发 (weibo reposts <mblogid>)"""
cred = require_auth()
def _render(data):
repost_list = data.get("data", []) if isinstance(data, dict) else data
render_repost_list(repost_list, count=count)
def _action(client):
weibo = client.get_weibo_detail(mblogid)
weibo_id = str(weibo.get("id", weibo.get("mid", "")))
return client.get_reposts(weibo_id, page=page, count=count)
handle_command(cred, action=_action, render=_render, as_json=as_json, as_yaml=as_yaml)
@click.command()
@click.option("--count", "-n", default=20, help="条数 (1-50)")
@structured_output_options
def home(count, as_json, as_yaml):
"""查看关注者 Feed (weibo home) 🏠"""
cred = require_auth()
def _render(data):
statuses = data.get("statuses", [])
render_weibo_list(statuses, count=count, border_style="green", empty_msg="[yellow]暂无关注者微博[/yellow]")
def _action(client):
return client.get_friends_timeline(count=min(count, 50))
handle_command(cred, action=_action, render=_render, as_json=as_json, as_yaml=as_yaml)
"""Shared renderers for CLI output.
Eliminates rendering duplication across search.py, personal.py, etc.
Each renderer takes parsed API data and prints Rich output.
"""
from __future__ import annotations
from rich.panel import Panel
from rich.table import Table
from ._common import console, format_count, strip_html
# ── Weibo card ──────────────────────────────────────────────────────
def render_weibo_card(
s: dict,
index: int,
*,
border_style: str = "blue",
show_user: bool = True,
max_text: int = 200,
) -> None:
"""Render a single weibo status as a Rich Panel.
Used by: feed, home, search, weibos.
"""
text = strip_html(s.get("text_raw", s.get("text", "")))
created = s.get("created_at", "")
reposts = s.get("reposts_count", 0)
comments_count = s.get("comments_count", 0)
likes = s.get("attitudes_count", 0)
mblogid = s.get("mblogid", s.get("bid", ""))
parts: list[str] = []
if show_user:
user = s.get("user", {})
name = user.get("screen_name", "未知")
verified = " ✓" if user.get("verified") else ""
parts.append(f"[bold cyan]{name}{verified}[/bold cyan] [dim]{created}[/dim]")
else:
source = s.get("source", "")
parts.append(f"[dim]{created} via {source}[/dim]")
parts.append(f"{text[:max_text]}")
pic_ids = s.get("pic_ids", s.get("pics", []))
if pic_ids:
parts.append(f"[dim]📷 {len(pic_ids)} 张图片[/dim]")
stats = f"[dim]💬 {comments_count} 🔁 {reposts} ❤️ {likes}[/dim]"
if mblogid:
stats += f" [dim]ID: {mblogid}[/dim]"
parts.append(stats)
console.print(Panel("\n".join(parts), title=f"#{index}", border_style=border_style, padding=(0, 1)))
def render_weibo_list(
statuses: list[dict],
*,
count: int = 20,
border_style: str = "blue",
show_user: bool = True,
empty_msg: str = "[yellow]暂无微博[/yellow]",
) -> None:
"""Render a list of weibo statuses. Used by feed, home, search, weibos."""
if not statuses:
console.print(empty_msg)
return
for i, s in enumerate(statuses[:count], 1):
render_weibo_card(s, i, border_style=border_style, show_user=show_user)
# ── User list table ─────────────────────────────────────────────────
def render_user_table(users: list[dict], *, title: str = "用户列表", empty_msg: str = "[yellow]暂无用户[/yellow]") -> None:
"""Render a user list as a Rich Table. Used by following, followers."""
if not users:
console.print(empty_msg)
return
table = Table(title=title, show_lines=False, padding=(0, 1))
table.add_column("UID", style="dim", width=12)
table.add_column("昵称", style="bold")
table.add_column("粉丝", justify="right")
table.add_column("简介", max_width=40)
for u in users:
uid_str = str(u.get("id", u.get("idstr", "")))
name = u.get("screen_name", "")
verified = " ✓" if u.get("verified") else ""
follower_count = format_count(u.get("followers_count", 0))
desc = (u.get("description", "") or "")[:40]
table.add_row(uid_str, f"{name}{verified}", follower_count, desc)
console.print(table)
# ── Comment list ────────────────────────────────────────────────────
def render_comment_list(comments: list[dict], *, count: int = 20) -> None:
"""Render comment entries. Used by comments command."""
if not comments:
console.print("[yellow]暂无评论[/yellow]")
return
for c in comments[:count]:
user = c.get("user", {})
name = user.get("screen_name", "未知")
text = strip_html(c.get("text", ""))
created = c.get("created_at", "")
likes = c.get("like_counts", 0)
console.print(f" [bold]{name}[/bold] [dim]{created}[/dim]")
console.print(f" {text}")
if likes:
console.print(f" [dim]❤️ {likes}[/dim]")
console.print()
# ── Repost list ─────────────────────────────────────────────────────
def render_repost_list(reposts: list[dict], *, count: int = 10) -> None:
"""Render repost entries. Used by reposts command."""
if not reposts:
console.print("[yellow]暂无转发[/yellow]")
return
for _i, r in enumerate(reposts[:count], 1):
user = r.get("user", {})
name = user.get("screen_name", "未知")
text = strip_html(r.get("text", ""))
created = r.get("created_at", "")
console.print(f" [bold]{name}[/bold] [dim]{created}[/dim]")
console.print(f" {text}")
console.print()
"""Search, hot-search and feed commands."""
from __future__ import annotations
import click
from rich.panel import Panel
from rich.table import Table
from ._common import console, format_count, handle_command, require_auth, strip_html, structured_output_options
from .renderers import render_comment_list, render_weibo_list
@click.command(name="hot")
@click.option("--count", "-n", default=50, help="条数 (默认50)")
@structured_output_options
def hot(count, as_json, as_yaml):
"""查看微博热搜榜 🔥"""
from ..auth import get_credential
cred = get_credential()
def _render(data):
table = Table(title="🔥 微博热搜", show_lines=False, padding=(0, 1))
table.add_column("#", style="dim", width=4, justify="right")
table.add_column("热搜词", style="bold")
table.add_column("标签", width=4)
table.add_column("热度", justify="right", style="cyan")
items = data.get("realtime") or data.get("band_list") or []
for i, item in enumerate(items[:count], 1):
word = item.get("word", item.get("note", ""))
icon = item.get("icon_desc", item.get("label_name", ""))
num = item.get("num", item.get("raw_hot", ""))
icon_color = "red" if icon == "沸" else "yellow" if icon == "热" else "green" if icon == "新" else ""
icon_text = f"[{icon_color}]{icon}[/{icon_color}]" if icon_color and icon else icon
num_str = format_count(num) if num else ""
table.add_row(str(i), word, icon_text, num_str)
console.print(table)
def _action(client):
return client.get_hot_search()
handle_command(cred, action=_action, render=_render, as_json=as_json, as_yaml=as_yaml)
@click.command()
@click.option("--count", "-n", default=10, help="条数 (1-20)")
@structured_output_options
def feed(count, as_json, as_yaml):
"""查看热门微博 Feed 📰"""
from ..auth import get_credential
cred = get_credential()
def _render(data):
statuses = data.get("statuses", [])
render_weibo_list(statuses, count=count, border_style="blue", empty_msg="[yellow]暂无热门微博[/yellow]")
def _action(client):
return client.get_hot_timeline(count=min(count, 20))
handle_command(cred, action=_action, render=_render, as_json=as_json, as_yaml=as_yaml)
@click.command()
@click.argument("mblogid")
@structured_output_options
def detail(mblogid, as_json, as_yaml):
"""查看微博详情 (weibo detail <mblogid>)"""
cred = require_auth()
def _render(data):
user = data.get("user", {})
name = user.get("screen_name", "未知")
verified = " ✓" if user.get("verified") else ""
text = strip_html(data.get("text_raw", data.get("text", "")))
source = data.get("source", "")
created = data.get("created_at", "")
reposts = data.get("reposts_count", 0)
comments_count = data.get("comments_count", 0)
likes = data.get("attitudes_count", 0)
reads = data.get("reads_count", 0)
content = f"[bold cyan]{name}{verified}[/bold cyan]"
if user.get("verified_reason"):
content += f" [dim]{user['verified_reason']}[/dim]"
content += f"\n[dim]{created} via {source}[/dim]\n\n"
content += f"{text}\n\n"
if data.get("pic_ids"):
content += f"[dim]📷 {len(data['pic_ids'])} 张图片[/dim]\n"
content += f"👁 {reads} 💬 {comments_count} 🔁 {reposts} ❤️ {likes}"
console.print(Panel(content, title=f"微博 {data.get('mblogid', '')}", border_style="cyan", padding=(0, 1)))
def _action(client):
return client.get_weibo_detail(mblogid)
handle_command(cred, action=_action, render=_render, as_json=as_json, as_yaml=as_yaml)
@click.command()
@click.argument("mblogid")
@click.option("--count", "-n", default=20, help="评论条数")
@structured_output_options
def comments(mblogid, count, as_json, as_yaml):
"""查看微博评论 (weibo comments <mblogid>)"""
cred = require_auth()
def _render(data):
comment_list = data if isinstance(data, list) else data.get("data", []) if isinstance(data, dict) else []
render_comment_list(comment_list, count=count)
def _action(client):
weibo = client.get_weibo_detail(mblogid)
weibo_id = str(weibo.get("id", weibo.get("mid", "")))
return client.get_comments(weibo_id, count=count)
handle_command(cred, action=_action, render=_render, as_json=as_json, as_yaml=as_yaml)
@click.command()
@click.option("--count", "-n", default=16, help="条数 (默认16)")
@structured_output_options
def trending(count, as_json, as_yaml):
"""查看实时搜索趋势 📈"""
from ..auth import get_credential
cred = get_credential()
def _render(data):
items = data.get("realtime", [])
table = Table(title="📈 实时搜索趋势", show_lines=False, padding=(0, 1))
table.add_column("#", style="dim", width=4, justify="right")
table.add_column("关键词", style="bold")
table.add_column("描述", style="dim")
for i, item in enumerate(items[:count], 1):
word = item.get("word", "")
desc = str(item.get("description", ""))
table.add_row(str(i), word, desc[:40])
console.print(table)
def _action(client):
return client.get_search_band()
handle_command(cred, action=_action, render=_render, as_json=as_json, as_yaml=as_yaml)
@click.command()
@click.argument("keyword")
@click.option("--count", "-n", default=10, help="显示条数")
@click.option("--page", "-p", default=1, help="页码")
@structured_output_options
def search(keyword, count, page, as_json, as_yaml):
"""搜索微博 (weibo search <关键词>) 🔍"""
from ..auth import get_credential
cred = get_credential()
def _render(data):
# Mobile API returns cards in data.cards or data.data.cards
cards = []
if isinstance(data, dict):
cards_data = data.get("data", data)
if isinstance(cards_data, dict):
cards = cards_data.get("cards", [])
# Extract weibos from cards
statuses = []
for card in cards:
if card.get("card_type") == 9:
mblog = card.get("mblog", {})
if mblog:
statuses.append(mblog)
elif card.get("card_group"):
for sub in card["card_group"]:
if sub.get("card_type") == 9:
mblog = sub.get("mblog", {})
if mblog:
statuses.append(mblog)
if not statuses:
console.print(f"[yellow]未找到 \"{keyword}\" 相关微博[/yellow]")
return
render_weibo_list(statuses, count=count, border_style="magenta")
def _action(client):
return client.search_weibo(keyword, page=page)
handle_command(cred, action=_action, render=_render, as_json=as_json, as_yaml=as_yaml)
"""Constants for Weibo CLI — API endpoints, headers, and config paths."""
from pathlib import Path
# ── Config ──────────────────────────────────────────────────────────
CONFIG_DIR = Path.home() / ".config" / "weibo-cli"
CREDENTIAL_FILE = CONFIG_DIR / "credential.json"
# ── Base URLs ───────────────────────────────────────────────────────
BASE_URL = "https://weibo.com"
PASSPORT_URL = "https://passport.weibo.com"
# ── QR Login API (passport.weibo.com) ───────────────────────────────
QR_IMAGE_URL = "/sso/v2/qrcode/image" # GET → qrid + image URL
QR_CHECK_URL = "/sso/v2/qrcode/check" # GET → poll scan status
WEB_CONFIG_URL = "/sso/v2/web/config" # POST → login config
SSO_SIGNIN_URL = "/sso/signin" # GET → initial page (get CSRF token)
# ── Hot Search / Trending ───────────────────────────────────────────
HOT_SEARCH_URL = "/ajax/side/hotSearch" # GET → sidebar hot search (public)
HOT_BAND_URL = "/ajax/statuses/hot_band" # GET → full hot search list (public)
SEARCH_BAND_URL = "/ajax/side/searchBand" # GET → trending sidebar
# ── Feed / Timeline ────────────────────────────────────────────────
HOT_TIMELINE_URL = "/ajax/feed/hottimeline" # GET → hot feed (public)
FRIENDS_TIMELINE_URL = "/ajax/feed/friendstimeline" # GET → friends feed (auth)
FEED_GROUPS_URL = "/ajax/feed/allGroups" # GET → feed groups (public)
# ── User / Profile ─────────────────────────────────────────────────
PROFILE_INFO_URL = "/ajax/profile/info" # GET ?uid= → user profile (auth)
MY_MBLOG_URL = "/ajax/statuses/mymblog" # GET ?uid=&page= → user weibos (auth)
# ── Weibo Detail ────────────────────────────────────────────────────
STATUSES_SHOW_URL = "/ajax/statuses/show" # GET ?id= → single weibo detail (auth)
# ── Comments / Reposts ──────────────────────────────────────────────
BUILD_COMMENTS_URL = "/ajax/statuses/buildComments" # GET → comments for a weibo
REPOST_TIMELINE_URL = "/ajax/statuses/repostTimeline" # GET → reposts for a weibo
# ── Social ──────────────────────────────────────────────────────────
FRIENDS_URL = "/ajax/friendships/friends" # GET ?uid= → following list
FOLLOWERS_URL = "/ajax/friendships/friends" # GET ?uid=&relate=fans → follower list
# ── Search ──────────────────────────────────────────────────────────
MOBILE_BASE_URL = "https://m.weibo.cn"
MOBILE_SEARCH_URL = "/api/container/getIndex" # GET → mobile search (keyword)
MOBILE_HEADERS = {
"User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) "
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 "
"Mobile/15E148 Safari/604.1",
"Accept": "application/json, text/plain, */*",
"Referer": f"{MOBILE_BASE_URL}/",
"X-Requested-With": "XMLHttpRequest",
}
# ── Config ──────────────────────────────────────────────────────────
GET_CONFIG_URL = "/ajax/config/get_config" # GET → app config (auth)
SIDE_CARDS_URL = "/ajax/side/cards" # GET → sidebar cards
# ── Request Headers (Chrome 145, macOS) ─────────────────────────────
HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/145.0.0.0 Safari/537.36"
),
"sec-ch-ua": '"Not:A-Brand";v="99", "Google Chrome";v="145", "Chromium";v="145"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"macOS"',
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-origin",
"Accept": "application/json, text/plain, */*",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
"Referer": f"{BASE_URL}/",
}
# ── Passport-specific headers ───────────────────────────────────────
PASSPORT_HEADERS = {
**HEADERS,
"x-requested-with": "XMLHttpRequest",
"Referer": f"{PASSPORT_URL}/sso/signin?entry=miniblog&source=miniblog&url=https://weibo.com/",
}
# ── Cookie keys required for authenticated sessions ─────────────────
REQUIRED_COOKIES = {"SUB", "SUBP"}
# ── QR Login constants ──────────────────────────────────────────────
QR_ENTRY = "miniblog"
QR_SOURCE = "miniblog"
QR_REDIRECT_URL = "https://weibo.com/"
QR_VERSION = "20250520"
# ── Response codes ──────────────────────────────────────────────────
RETCODE_SUCCESS = 20000000
RETCODE_QR_NOT_SCANNED = 50114001
RETCODE_QR_SCANNED = 50114002
RETCODE_QR_EXPIRED = 50114004
"""Custom exceptions for Weibo CLI API client."""
from __future__ import annotations
class WeiboApiError(Exception):
"""Base exception for Weibo API errors."""
def __init__(self, message: str, code: int | str | None = None, response: dict | None = None):
super().__init__(message)
self.code = code
self.response = response
class SessionExpiredError(WeiboApiError):
"""Raised when session cookies have expired."""
def __init__(self):
super().__init__(
"会话已过期,请重新登录: weibo logout && weibo login",
code="session_expired",
)
class AuthRequiredError(WeiboApiError):
"""Raised when user is not logged in."""
def __init__(self):
super().__init__("未登录,请先使用 weibo login 扫码登录")
class ParamError(WeiboApiError):
"""Raised when API reports missing or invalid parameters."""
def __init__(self, message: str, code: int | None = None):
super().__init__(f"参数错误: {message}", code=code)
class RateLimitError(WeiboApiError):
"""Raised when too many requests are made."""
def __init__(self):
super().__init__("请求过于频繁,请稍后再试")
class QRExpiredError(WeiboApiError):
"""Raised when the QR code has expired."""
def __init__(self):
super().__init__("二维码已过期,请重新运行 weibo login")
def error_code_for_exception(exc: Exception) -> str:
"""Map domain exceptions to stable error code strings."""
if isinstance(exc, (AuthRequiredError, SessionExpiredError)):
return "not_authenticated"
if isinstance(exc, RateLimitError):
return "rate_limited"
if isinstance(exc, ParamError):
return "invalid_params"
if isinstance(exc, QRExpiredError):
return "qr_expired"
if isinstance(exc, WeiboApiError):
return "api_error"
return "unknown_error"