2026-03-09 14:05:00 +08:00
|
|
|
|
"""
|
|
|
|
|
|
Weibo API Client
|
2026-03-16 16:14:08 +08:00
|
|
|
|
Handles all interactions with Weibo.com, including cookie verification,
|
|
|
|
|
|
super topic listing, and sign-in execution.
|
|
|
|
|
|
|
|
|
|
|
|
Key Weibo API endpoints used:
|
|
|
|
|
|
- Cookie验证: GET https://m.weibo.cn/api/config
|
|
|
|
|
|
- 超话列表: GET https://m.weibo.cn/api/container/getIndex (containerid=100803_-_followsuper)
|
|
|
|
|
|
- 超话签到: GET https://m.weibo.cn/api/container/getIndex (containerid=100808{topic_id})
|
|
|
|
|
|
POST https://huati.weibo.cn/aj/super/checkin (actual sign-in)
|
2026-03-09 14:05:00 +08:00
|
|
|
|
"""
|
|
|
|
|
|
|
2026-03-09 16:10:29 +08:00
|
|
|
|
import os
|
|
|
|
|
|
import sys
|
2026-03-09 14:05:00 +08:00
|
|
|
|
import httpx
|
|
|
|
|
|
import asyncio
|
|
|
|
|
|
import logging
|
|
|
|
|
|
import random
|
2026-03-16 16:14:08 +08:00
|
|
|
|
import json
|
|
|
|
|
|
import re
|
|
|
|
|
|
import time
|
2026-03-09 16:10:29 +08:00
|
|
|
|
from typing import Dict, Any, Optional, List, Tuple
|
|
|
|
|
|
|
|
|
|
|
|
# Add parent directory to path for imports
|
|
|
|
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../../.."))
|
|
|
|
|
|
|
|
|
|
|
|
from shared.crypto import decrypt_cookie, derive_key
|
|
|
|
|
|
from shared.config import shared_settings
|
2026-03-09 14:05:00 +08:00
|
|
|
|
|
|
|
|
|
|
from app.config import settings
|
|
|
|
|
|
from app.models.signin_models import WeiboAccount, WeiboSuperTopic
|
2026-03-09 16:10:29 +08:00
|
|
|
|
from app.services.antibot import antibot
|
2026-03-09 14:05:00 +08:00
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
2026-03-16 16:14:08 +08:00
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Constants – Weibo mobile API base URLs
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
WEIBO_PC_API = "https://weibo.com"
|
|
|
|
|
|
WEIBO_HUATI_CHECKIN = "http://i.huati.weibo.com/aj/super/checkin"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-09 14:05:00 +08:00
|
|
|
|
class WeiboClient:
|
2026-03-16 16:14:08 +08:00
|
|
|
|
"""Client for interacting with Weibo mobile API."""
|
|
|
|
|
|
|
2026-03-09 14:05:00 +08:00
|
|
|
|
def __init__(self):
|
2026-03-16 16:14:08 +08:00
|
|
|
|
self.timeout = httpx.Timeout(15.0, connect=10.0)
|
|
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
# Cookie helpers
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
def _decrypt_cookies(self, encrypted_cookies: str, iv: str) -> Dict[str, str]:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Decrypt cookies using AES-256-GCM from shared crypto module.
|
|
|
|
|
|
Returns dict of cookie key-value pairs.
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
key = derive_key(shared_settings.COOKIE_ENCRYPTION_KEY)
|
|
|
|
|
|
plaintext = decrypt_cookie(encrypted_cookies, iv, key)
|
|
|
|
|
|
|
|
|
|
|
|
cookies_dict: Dict[str, str] = {}
|
|
|
|
|
|
for pair in plaintext.split(";"):
|
|
|
|
|
|
pair = pair.strip()
|
|
|
|
|
|
if "=" in pair:
|
|
|
|
|
|
k, v = pair.split("=", 1)
|
|
|
|
|
|
cookies_dict[k.strip()] = v.strip()
|
|
|
|
|
|
return cookies_dict
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"Failed to decrypt cookies: {e}")
|
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
|
|
def _build_client(
|
|
|
|
|
|
self,
|
|
|
|
|
|
cookies: Dict[str, str],
|
|
|
|
|
|
headers: Optional[Dict[str, str]] = None,
|
|
|
|
|
|
proxy: Optional[Dict[str, str]] = None,
|
|
|
|
|
|
) -> httpx.AsyncClient:
|
|
|
|
|
|
"""Create a configured httpx.AsyncClient for weibo.com PC API."""
|
|
|
|
|
|
hdrs = headers or antibot.build_headers()
|
|
|
|
|
|
hdrs["Referer"] = "https://weibo.com/"
|
|
|
|
|
|
hdrs["Accept"] = "*/*"
|
|
|
|
|
|
hdrs["Accept-Language"] = "zh-CN,zh;q=0.9,en;q=0.8"
|
|
|
|
|
|
return httpx.AsyncClient(
|
|
|
|
|
|
cookies=cookies,
|
|
|
|
|
|
headers=hdrs,
|
|
|
|
|
|
proxies=proxy,
|
|
|
|
|
|
timeout=self.timeout,
|
|
|
|
|
|
follow_redirects=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
# 1. Cookie verification
|
|
|
|
|
|
# ------------------------------------------------------------------
|
2026-03-09 14:05:00 +08:00
|
|
|
|
async def verify_cookies(self, account: WeiboAccount) -> bool:
|
2026-03-16 16:14:08 +08:00
|
|
|
|
"""
|
|
|
|
|
|
Verify if Weibo cookies are still valid using the PC API.
|
|
|
|
|
|
GET https://weibo.com/ajax/side/cards?count=1
|
|
|
|
|
|
Returns ok=1 when logged in.
|
|
|
|
|
|
"""
|
|
|
|
|
|
cookies_dict = self._decrypt_cookies(account.encrypted_cookies, account.iv)
|
|
|
|
|
|
if not cookies_dict:
|
|
|
|
|
|
logger.error(f"Failed to decrypt cookies for account {account.weibo_user_id}")
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
proxy = await antibot.get_proxy()
|
|
|
|
|
|
headers = antibot.build_headers()
|
|
|
|
|
|
delay = antibot.get_random_delay()
|
|
|
|
|
|
await asyncio.sleep(delay)
|
|
|
|
|
|
|
2026-03-09 14:05:00 +08:00
|
|
|
|
try:
|
2026-03-16 16:14:08 +08:00
|
|
|
|
async with self._build_client(cookies_dict, headers, proxy) as client:
|
|
|
|
|
|
resp = await client.get(
|
|
|
|
|
|
f"{WEIBO_PC_API}/ajax/side/cards",
|
|
|
|
|
|
params={"count": "1"},
|
|
|
|
|
|
)
|
|
|
|
|
|
if resp.status_code != 200:
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
f"Side cards API returned {resp.status_code} for {account.weibo_user_id}"
|
|
|
|
|
|
)
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
data = resp.json()
|
|
|
|
|
|
if data.get("ok") == 1:
|
|
|
|
|
|
logger.info(f"Cookies valid for account {account.weibo_user_id}")
|
2026-03-09 14:05:00 +08:00
|
|
|
|
return True
|
|
|
|
|
|
else:
|
2026-03-16 16:14:08 +08:00
|
|
|
|
logger.warning(f"Cookies invalid for account {account.weibo_user_id}")
|
2026-03-09 14:05:00 +08:00
|
|
|
|
return False
|
|
|
|
|
|
except Exception as e:
|
2026-03-16 16:14:08 +08:00
|
|
|
|
logger.error(f"Error verifying cookies for {account.weibo_user_id}: {e}")
|
2026-03-09 14:05:00 +08:00
|
|
|
|
return False
|
2026-03-16 16:14:08 +08:00
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
# 2. Fetch super topic list
|
|
|
|
|
|
# ------------------------------------------------------------------
|
2026-03-09 14:05:00 +08:00
|
|
|
|
async def get_super_topics(self, account: WeiboAccount) -> List[WeiboSuperTopic]:
|
2026-03-16 16:14:08 +08:00
|
|
|
|
"""
|
|
|
|
|
|
Fetch the list of super topics the account has followed.
|
|
|
|
|
|
|
|
|
|
|
|
Uses the PC API:
|
|
|
|
|
|
GET https://weibo.com/ajax/profile/topicContent?tabid=231093_-_chaohua&page={n}
|
|
|
|
|
|
|
|
|
|
|
|
Response contains data.list[] with topic objects including:
|
|
|
|
|
|
- topic_name: super topic name
|
|
|
|
|
|
- oid: "1022:100808xxx" (containerid)
|
|
|
|
|
|
- scheme: "sinaweibo://pageinfo?containerid=100808xxx"
|
|
|
|
|
|
"""
|
|
|
|
|
|
cookies_dict = self._decrypt_cookies(account.encrypted_cookies, account.iv)
|
|
|
|
|
|
if not cookies_dict:
|
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
|
proxy = await antibot.get_proxy()
|
|
|
|
|
|
headers = antibot.build_headers()
|
|
|
|
|
|
all_topics: List[WeiboSuperTopic] = []
|
|
|
|
|
|
page = 1
|
|
|
|
|
|
max_pages = 10
|
|
|
|
|
|
|
2026-03-09 14:05:00 +08:00
|
|
|
|
try:
|
2026-03-16 16:14:08 +08:00
|
|
|
|
async with self._build_client(cookies_dict, headers, proxy) as client:
|
|
|
|
|
|
# Get XSRF-TOKEN first
|
|
|
|
|
|
await client.get(f"{WEIBO_PC_API}/", params={})
|
|
|
|
|
|
xsrf = client.cookies.get("XSRF-TOKEN", "")
|
|
|
|
|
|
if xsrf:
|
|
|
|
|
|
client.headers["X-XSRF-TOKEN"] = xsrf
|
|
|
|
|
|
client.headers["X-Requested-With"] = "XMLHttpRequest"
|
|
|
|
|
|
|
|
|
|
|
|
while page <= max_pages:
|
|
|
|
|
|
delay = antibot.get_random_delay()
|
|
|
|
|
|
await asyncio.sleep(delay)
|
|
|
|
|
|
|
|
|
|
|
|
resp = await client.get(
|
|
|
|
|
|
f"{WEIBO_PC_API}/ajax/profile/topicContent",
|
|
|
|
|
|
params={
|
|
|
|
|
|
"tabid": "231093_-_chaohua",
|
|
|
|
|
|
"page": str(page),
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if resp.status_code != 200:
|
|
|
|
|
|
logger.warning(f"Topic list API returned {resp.status_code}")
|
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
|
|
body = resp.json()
|
|
|
|
|
|
if body.get("ok") != 1:
|
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
|
|
topic_list = body.get("data", {}).get("list", [])
|
|
|
|
|
|
if not topic_list:
|
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
|
|
for item in topic_list:
|
|
|
|
|
|
topic = self._parse_pc_topic(item)
|
|
|
|
|
|
if topic:
|
|
|
|
|
|
all_topics.append(topic)
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(
|
|
|
|
|
|
f"Page {page}: found {len(topic_list)} topics "
|
|
|
|
|
|
f"(total so far: {len(all_topics)})"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
api_max = body.get("data", {}).get("max_page", 1)
|
|
|
|
|
|
if page >= api_max:
|
|
|
|
|
|
break
|
|
|
|
|
|
page += 1
|
|
|
|
|
|
|
2026-03-09 14:05:00 +08:00
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"Error fetching super topics: {e}")
|
2026-03-16 16:14:08 +08:00
|
|
|
|
|
|
|
|
|
|
logger.info(
|
|
|
|
|
|
f"Fetched {len(all_topics)} super topics for account {account.weibo_user_id}"
|
|
|
|
|
|
)
|
|
|
|
|
|
return all_topics
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_pc_topic(self, item: Dict[str, Any]) -> Optional[WeiboSuperTopic]:
|
|
|
|
|
|
"""Parse a topic item from /ajax/profile/topicContent response."""
|
|
|
|
|
|
title = item.get("topic_name", "") or item.get("title", "")
|
|
|
|
|
|
if not title:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
# Extract containerid from oid "1022:100808xxx" or scheme
|
|
|
|
|
|
containerid = ""
|
|
|
|
|
|
oid = item.get("oid", "")
|
|
|
|
|
|
if "100808" in oid:
|
|
|
|
|
|
match = re.search(r"100808[0-9a-fA-F]+", oid)
|
|
|
|
|
|
if match:
|
|
|
|
|
|
containerid = match.group(0)
|
|
|
|
|
|
if not containerid:
|
|
|
|
|
|
scheme = item.get("scheme", "")
|
|
|
|
|
|
match = re.search(r"100808[0-9a-fA-F]+", scheme)
|
|
|
|
|
|
if match:
|
|
|
|
|
|
containerid = match.group(0)
|
|
|
|
|
|
if not containerid:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
return WeiboSuperTopic(
|
|
|
|
|
|
id=containerid,
|
|
|
|
|
|
title=title,
|
|
|
|
|
|
url=f"https://weibo.com/p/{containerid}/super_index",
|
|
|
|
|
|
is_signed=False,
|
|
|
|
|
|
sign_url=f"{WEIBO_PC_API}/p/aj/general/button",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
# 3. Execute sign-in for a single super topic
|
|
|
|
|
|
# ------------------------------------------------------------------
|
2026-03-09 16:10:29 +08:00
|
|
|
|
async def sign_super_topic(
|
2026-03-16 16:14:08 +08:00
|
|
|
|
self,
|
|
|
|
|
|
account: WeiboAccount,
|
|
|
|
|
|
topic: WeiboSuperTopic,
|
|
|
|
|
|
task_id: str,
|
2026-03-09 16:10:29 +08:00
|
|
|
|
) -> Tuple[bool, Optional[Dict[str, Any]], Optional[str]]:
|
2026-03-09 14:05:00 +08:00
|
|
|
|
"""
|
2026-03-16 16:14:08 +08:00
|
|
|
|
Execute sign-in for a single super topic via weibo.com PC API.
|
|
|
|
|
|
|
|
|
|
|
|
GET https://weibo.com/p/aj/general/button
|
|
|
|
|
|
?api=http://i.huati.weibo.com/aj/super/checkin&id={topic_id}
|
|
|
|
|
|
|
2026-03-09 16:10:29 +08:00
|
|
|
|
Returns: (success, reward_info, error_message)
|
2026-03-09 14:05:00 +08:00
|
|
|
|
"""
|
2026-03-16 16:14:08 +08:00
|
|
|
|
cookies_dict = self._decrypt_cookies(account.encrypted_cookies, account.iv)
|
|
|
|
|
|
if not cookies_dict:
|
|
|
|
|
|
return False, None, "Failed to decrypt cookies"
|
|
|
|
|
|
|
|
|
|
|
|
proxy = await antibot.get_proxy()
|
|
|
|
|
|
headers = antibot.build_headers()
|
|
|
|
|
|
|
|
|
|
|
|
# Anti-bot delay
|
|
|
|
|
|
delay = antibot.get_random_delay()
|
|
|
|
|
|
await asyncio.sleep(delay)
|
|
|
|
|
|
|
2026-03-09 14:05:00 +08:00
|
|
|
|
try:
|
2026-03-16 16:14:08 +08:00
|
|
|
|
headers["Referer"] = f"https://weibo.com/p/{topic.id}/super_index"
|
|
|
|
|
|
headers["X-Requested-With"] = "XMLHttpRequest"
|
|
|
|
|
|
|
|
|
|
|
|
async with httpx.AsyncClient(
|
|
|
|
|
|
cookies=cookies_dict,
|
|
|
|
|
|
headers=headers,
|
|
|
|
|
|
proxies=proxy,
|
|
|
|
|
|
timeout=self.timeout,
|
|
|
|
|
|
follow_redirects=True,
|
|
|
|
|
|
) as client:
|
|
|
|
|
|
# Get XSRF-TOKEN
|
|
|
|
|
|
await client.get(f"{WEIBO_PC_API}/")
|
|
|
|
|
|
xsrf = client.cookies.get("XSRF-TOKEN", "")
|
|
|
|
|
|
if xsrf:
|
|
|
|
|
|
client.headers["X-XSRF-TOKEN"] = xsrf
|
|
|
|
|
|
|
|
|
|
|
|
resp = await client.get(
|
|
|
|
|
|
f"{WEIBO_PC_API}/p/aj/general/button",
|
|
|
|
|
|
params={
|
|
|
|
|
|
"ajwvr": "6",
|
|
|
|
|
|
"api": WEIBO_HUATI_CHECKIN,
|
|
|
|
|
|
"texta": "签到",
|
|
|
|
|
|
"textb": "已签到",
|
|
|
|
|
|
"status": "0",
|
|
|
|
|
|
"id": topic.id,
|
|
|
|
|
|
"location": "page_100808_super_index",
|
|
|
|
|
|
"timezone": "GMT+0800",
|
|
|
|
|
|
"lang": "zh-cn",
|
|
|
|
|
|
"plat": "Win32",
|
|
|
|
|
|
"ua": headers.get("User-Agent", ""),
|
|
|
|
|
|
"screen": "1920*1080",
|
|
|
|
|
|
"__rnd": str(int(time.time() * 1000)),
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if resp.status_code != 200:
|
|
|
|
|
|
return False, None, f"HTTP {resp.status_code}"
|
|
|
|
|
|
|
|
|
|
|
|
body = resp.json()
|
|
|
|
|
|
code = str(body.get("code", ""))
|
|
|
|
|
|
msg = body.get("msg", "")
|
|
|
|
|
|
data = body.get("data", {})
|
|
|
|
|
|
|
|
|
|
|
|
if code == "100000":
|
|
|
|
|
|
tip = ""
|
|
|
|
|
|
if isinstance(data, dict):
|
|
|
|
|
|
tip = data.get("alert_title", "") or data.get("tipMessage", "签到成功")
|
|
|
|
|
|
logger.info(f"Checkin success for {topic.title}: {tip}")
|
|
|
|
|
|
reward_info = {}
|
|
|
|
|
|
if isinstance(data, dict):
|
|
|
|
|
|
reward_info = {
|
|
|
|
|
|
"tip": data.get("tipMessage", ""),
|
|
|
|
|
|
"alert_title": data.get("alert_title", ""),
|
|
|
|
|
|
"alert_subtitle": data.get("alert_subtitle", ""),
|
|
|
|
|
|
}
|
|
|
|
|
|
return True, reward_info, None
|
|
|
|
|
|
|
|
|
|
|
|
elif code == "382004":
|
|
|
|
|
|
logger.info(f"Topic {topic.title} already signed today")
|
|
|
|
|
|
return False, None, "Already signed today"
|
|
|
|
|
|
|
|
|
|
|
|
elif code == "382003":
|
|
|
|
|
|
logger.warning(f"Not a member of topic {topic.title}")
|
|
|
|
|
|
return False, None, "Not a member of this super topic"
|
|
|
|
|
|
|
2026-03-09 14:05:00 +08:00
|
|
|
|
else:
|
2026-03-16 16:14:08 +08:00
|
|
|
|
logger.warning(f"Checkin unexpected code={code} msg={msg} for {topic.title}")
|
|
|
|
|
|
return False, None, f"Unexpected: code={code}, msg={msg}"
|
|
|
|
|
|
|
|
|
|
|
|
except httpx.TimeoutException:
|
|
|
|
|
|
return False, None, "Request timeout"
|
2026-03-09 14:05:00 +08:00
|
|
|
|
except Exception as e:
|
2026-03-16 16:14:08 +08:00
|
|
|
|
logger.error(f"Checkin error for {topic.title}: {e}")
|
|
|
|
|
|
return False, None, str(e)
|