466 lines
15 KiB
Python
466 lines
15 KiB
Python
"""
|
|
Weibo Account CRUD router.
|
|
All endpoints require JWT authentication and enforce resource ownership.
|
|
"""
|
|
|
|
import logging
|
|
from datetime import datetime
|
|
from typing import Dict, List
|
|
|
|
import httpx
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
from shared.models import get_db, Account, SigninLog, User
|
|
from shared.crypto import encrypt_cookie, decrypt_cookie, derive_key
|
|
from shared.config import shared_settings
|
|
from shared.response import success_response, error_response
|
|
from api_service.app.dependencies import get_current_user
|
|
from api_service.app.schemas.account import (
|
|
AccountCreate,
|
|
AccountUpdate,
|
|
AccountResponse,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/api/v1/accounts", tags=["accounts"])
|
|
|
|
WEIBO_HEADERS = {
|
|
"User-Agent": (
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
|
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
|
),
|
|
"Referer": "https://weibo.com/",
|
|
"Accept": "*/*",
|
|
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
|
}
|
|
|
|
|
|
def _encryption_key() -> bytes:
|
|
return derive_key(shared_settings.COOKIE_ENCRYPTION_KEY)
|
|
|
|
|
|
def _account_to_dict(account: Account) -> dict:
|
|
return AccountResponse.model_validate(account).model_dump(mode="json")
|
|
|
|
|
|
async def _get_owned_account(
|
|
account_id: str,
|
|
user: User,
|
|
db: AsyncSession,
|
|
) -> Account:
|
|
"""Fetch an account and verify it belongs to the current user."""
|
|
result = await db.execute(select(Account).where(Account.id == account_id))
|
|
account = result.scalar_one_or_none()
|
|
if account is None:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found")
|
|
if account.user_id != user.id:
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
|
|
return account
|
|
|
|
|
|
# ---- CREATE ----
|
|
|
|
@router.post("", status_code=status.HTTP_201_CREATED)
|
|
async def create_account(
|
|
body: AccountCreate,
|
|
user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
key = _encryption_key()
|
|
ciphertext, iv = encrypt_cookie(body.cookie, key)
|
|
|
|
account = Account(
|
|
user_id=user.id,
|
|
weibo_user_id=body.weibo_user_id,
|
|
remark=body.remark,
|
|
encrypted_cookies=ciphertext,
|
|
iv=iv,
|
|
status="pending",
|
|
)
|
|
db.add(account)
|
|
await db.commit()
|
|
await db.refresh(account)
|
|
|
|
return success_response(_account_to_dict(account), "Account created")
|
|
|
|
|
|
# ---- LIST ----
|
|
|
|
@router.get("")
|
|
async def list_accounts(
|
|
user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
result = await db.execute(
|
|
select(Account).where(Account.user_id == user.id)
|
|
)
|
|
accounts = result.scalars().all()
|
|
return success_response(
|
|
[_account_to_dict(a) for a in accounts],
|
|
"Accounts retrieved",
|
|
)
|
|
|
|
|
|
# ---- DETAIL ----
|
|
|
|
@router.get("/{account_id}")
|
|
async def get_account(
|
|
account_id: str,
|
|
user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
account = await _get_owned_account(account_id, user, db)
|
|
return success_response(_account_to_dict(account), "Account retrieved")
|
|
|
|
|
|
# ---- UPDATE ----
|
|
|
|
@router.put("/{account_id}")
|
|
async def update_account(
|
|
account_id: str,
|
|
body: AccountUpdate,
|
|
user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
account = await _get_owned_account(account_id, user, db)
|
|
|
|
if body.remark is not None:
|
|
account.remark = body.remark
|
|
|
|
if body.cookie is not None:
|
|
key = _encryption_key()
|
|
ciphertext, iv = encrypt_cookie(body.cookie, key)
|
|
account.encrypted_cookies = ciphertext
|
|
account.iv = iv
|
|
|
|
await db.commit()
|
|
await db.refresh(account)
|
|
return success_response(_account_to_dict(account), "Account updated")
|
|
|
|
|
|
# ---- DELETE ----
|
|
|
|
@router.delete("/{account_id}")
|
|
async def delete_account(
|
|
account_id: str,
|
|
user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
account = await _get_owned_account(account_id, user, db)
|
|
await db.delete(account)
|
|
await db.commit()
|
|
return success_response(None, "Account deleted")
|
|
|
|
|
|
# ---- helpers for verify / signin ----
|
|
|
|
def _parse_cookie_str(cookie_str: str) -> Dict[str, str]:
|
|
"""Parse 'k1=v1; k2=v2' into a dict."""
|
|
cookies: Dict[str, str] = {}
|
|
for pair in cookie_str.split(";"):
|
|
pair = pair.strip()
|
|
if "=" in pair:
|
|
k, v = pair.split("=", 1)
|
|
cookies[k.strip()] = v.strip()
|
|
return cookies
|
|
|
|
|
|
async def _verify_weibo_cookie(cookie_str: str) -> dict:
|
|
"""
|
|
Verify cookie via weibo.com PC API.
|
|
Uses /ajax/side/cards which returns ok=1 when logged in.
|
|
Returns {"valid": bool, "uid": str|None, "screen_name": str|None}.
|
|
"""
|
|
cookies = _parse_cookie_str(cookie_str)
|
|
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
|
|
# Step 1: check login via /ajax/side/cards
|
|
resp = await client.get(
|
|
"https://weibo.com/ajax/side/cards",
|
|
params={"count": "1"},
|
|
headers=WEIBO_HEADERS,
|
|
cookies=cookies,
|
|
)
|
|
data = resp.json()
|
|
if data.get("ok") != 1:
|
|
return {"valid": False, "uid": None, "screen_name": None}
|
|
|
|
# Step 2: get user info via /ajax/profile/detail
|
|
uid = None
|
|
screen_name = None
|
|
try:
|
|
resp2 = await client.get(
|
|
"https://weibo.com/ajax/profile/info",
|
|
headers=WEIBO_HEADERS,
|
|
cookies=cookies,
|
|
)
|
|
info = resp2.json()
|
|
if info.get("ok") == 1:
|
|
user = info.get("data", {}).get("user", {})
|
|
uid = str(user.get("idstr", user.get("id", "")))
|
|
screen_name = user.get("screen_name", "")
|
|
except Exception:
|
|
pass # profile info is optional, login check already passed
|
|
|
|
return {"valid": True, "uid": uid, "screen_name": screen_name}
|
|
|
|
|
|
# ---- VERIFY COOKIE ----
|
|
|
|
@router.post("/{account_id}/verify")
|
|
async def verify_account(
|
|
account_id: str,
|
|
user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Verify the stored cookie is still valid and update account status."""
|
|
account = await _get_owned_account(account_id, user, db)
|
|
key = _encryption_key()
|
|
|
|
try:
|
|
cookie_str = decrypt_cookie(account.encrypted_cookies, account.iv, key)
|
|
except Exception:
|
|
account.status = "invalid_cookie"
|
|
await db.commit()
|
|
await db.refresh(account)
|
|
return success_response(
|
|
{**_account_to_dict(account), "cookie_valid": False},
|
|
"Cookie decryption failed",
|
|
)
|
|
|
|
result = await _verify_weibo_cookie(cookie_str)
|
|
|
|
if result["valid"]:
|
|
account.status = "active"
|
|
account.last_checked_at = datetime.utcnow()
|
|
else:
|
|
account.status = "invalid_cookie"
|
|
|
|
await db.commit()
|
|
await db.refresh(account)
|
|
|
|
return success_response(
|
|
{**_account_to_dict(account), "cookie_valid": result["valid"],
|
|
"weibo_screen_name": result.get("screen_name")},
|
|
"Cookie verified" if result["valid"] else "Cookie is invalid or expired",
|
|
)
|
|
|
|
|
|
# ---- MANUAL SIGNIN ----
|
|
|
|
async def _get_super_topics(cookie_str: str, weibo_uid: str = "") -> List[dict]:
|
|
"""
|
|
Fetch followed super topics via weibo.com PC API.
|
|
GET /ajax/profile/topicContent?tabid=231093_-_chaohua
|
|
Returns list of {"title": str, "containerid": str}.
|
|
"""
|
|
import re
|
|
cookies = _parse_cookie_str(cookie_str)
|
|
topics: List[dict] = []
|
|
|
|
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
|
|
# First get XSRF-TOKEN by visiting weibo.com
|
|
await client.get("https://weibo.com/", headers=WEIBO_HEADERS, cookies=cookies)
|
|
xsrf = client.cookies.get("XSRF-TOKEN", "")
|
|
|
|
headers = {
|
|
**WEIBO_HEADERS,
|
|
"X-Requested-With": "XMLHttpRequest",
|
|
}
|
|
if xsrf:
|
|
headers["X-XSRF-TOKEN"] = xsrf
|
|
|
|
page = 1
|
|
max_page = 10
|
|
while page <= max_page:
|
|
params = {"tabid": "231093_-_chaohua", "page": str(page)}
|
|
resp = await client.get(
|
|
"https://weibo.com/ajax/profile/topicContent",
|
|
params=params,
|
|
headers=headers,
|
|
cookies=cookies,
|
|
)
|
|
data = resp.json()
|
|
if data.get("ok") != 1:
|
|
break
|
|
|
|
topic_list = data.get("data", {}).get("list", [])
|
|
if not topic_list:
|
|
break
|
|
|
|
for item in topic_list:
|
|
title = item.get("topic_name", "") or item.get("title", "")
|
|
# Extract containerid from oid "1022:100808xxx" or scheme
|
|
containerid = ""
|
|
oid = item.get("oid", "")
|
|
if "100808" in oid:
|
|
m = re.search(r"100808[0-9a-fA-F]+", oid)
|
|
if m:
|
|
containerid = m.group(0)
|
|
if not containerid:
|
|
scheme = item.get("scheme", "")
|
|
m = re.search(r"100808[0-9a-fA-F]+", scheme)
|
|
if m:
|
|
containerid = m.group(0)
|
|
if title and containerid:
|
|
topics.append({"title": title, "containerid": containerid})
|
|
|
|
# Check pagination
|
|
api_max = data.get("data", {}).get("max_page", 1)
|
|
if page >= api_max:
|
|
break
|
|
page += 1
|
|
|
|
return topics
|
|
|
|
|
|
async def _do_signin(cookie_str: str, topic_title: str, containerid: str) -> dict:
|
|
"""
|
|
Sign in to a single super topic via weibo.com PC API.
|
|
GET /p/aj/general/button with full browser-matching parameters.
|
|
Returns {"status": "success"|"already_signed"|"failed", "message": str}.
|
|
"""
|
|
import time as _time
|
|
cookies = _parse_cookie_str(cookie_str)
|
|
|
|
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
|
|
# Get XSRF-TOKEN
|
|
await client.get("https://weibo.com/", headers=WEIBO_HEADERS, cookies=cookies)
|
|
xsrf = client.cookies.get("XSRF-TOKEN", "")
|
|
|
|
headers = {
|
|
**WEIBO_HEADERS,
|
|
"Referer": f"https://weibo.com/p/{containerid}/super_index",
|
|
"X-Requested-With": "XMLHttpRequest",
|
|
}
|
|
if xsrf:
|
|
headers["X-XSRF-TOKEN"] = xsrf
|
|
|
|
try:
|
|
resp = await client.get(
|
|
"https://weibo.com/p/aj/general/button",
|
|
params={
|
|
"ajwvr": "6",
|
|
"api": "http://i.huati.weibo.com/aj/super/checkin",
|
|
"texta": "签到",
|
|
"textb": "已签到",
|
|
"status": "0",
|
|
"id": containerid,
|
|
"location": "page_100808_super_index",
|
|
"timezone": "GMT+0800",
|
|
"lang": "zh-cn",
|
|
"plat": "Win32",
|
|
"ua": WEIBO_HEADERS["User-Agent"],
|
|
"screen": "1920*1080",
|
|
"__rnd": str(int(_time.time() * 1000)),
|
|
},
|
|
headers=headers,
|
|
cookies=cookies,
|
|
)
|
|
data = resp.json()
|
|
code = str(data.get("code", ""))
|
|
msg = data.get("msg", "")
|
|
|
|
if code == "100000":
|
|
tip = ""
|
|
if isinstance(data.get("data"), dict):
|
|
tip = data["data"].get("alert_title", "") or data["data"].get("tipMessage", "")
|
|
return {"status": "success", "message": tip or "签到成功"}
|
|
elif code == "382004":
|
|
return {"status": "already_signed", "message": msg or "今日已签到"}
|
|
elif code == "382003":
|
|
return {"status": "failed", "message": msg or "非超话成员"}
|
|
else:
|
|
return {"status": "failed", "message": f"code={code}, msg={msg}"}
|
|
except Exception as e:
|
|
return {"status": "failed", "message": str(e)}
|
|
|
|
|
|
@router.post("/{account_id}/signin")
|
|
async def manual_signin(
|
|
account_id: str,
|
|
user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""
|
|
Manually trigger sign-in for all followed super topics.
|
|
Verifies cookie first, fetches topic list, signs each one, writes logs.
|
|
"""
|
|
account = await _get_owned_account(account_id, user, db)
|
|
key = _encryption_key()
|
|
|
|
# Decrypt cookie
|
|
try:
|
|
cookie_str = decrypt_cookie(account.encrypted_cookies, account.iv, key)
|
|
except Exception:
|
|
account.status = "invalid_cookie"
|
|
await db.commit()
|
|
return error_response("Cookie decryption failed", "COOKIE_ERROR", status_code=400)
|
|
|
|
# Verify cookie
|
|
verify = await _verify_weibo_cookie(cookie_str)
|
|
if not verify["valid"]:
|
|
account.status = "invalid_cookie"
|
|
await db.commit()
|
|
return error_response("Cookie is invalid or expired", "COOKIE_EXPIRED", status_code=400)
|
|
|
|
# Activate account if pending
|
|
if account.status != "active":
|
|
account.status = "active"
|
|
account.last_checked_at = datetime.utcnow()
|
|
|
|
# Get super topics
|
|
topics = await _get_super_topics(cookie_str, account.weibo_user_id)
|
|
if not topics:
|
|
await db.commit()
|
|
return success_response(
|
|
{"signed": 0, "already_signed": 0, "failed": 0, "topics": []},
|
|
"No super topics found for this account",
|
|
)
|
|
|
|
# Sign each topic
|
|
results = []
|
|
signed = already = failed = 0
|
|
for topic in topics:
|
|
import asyncio
|
|
await asyncio.sleep(1.5) # anti-bot delay
|
|
r = await _do_signin(cookie_str, topic["title"], topic["containerid"])
|
|
r["topic"] = topic["title"]
|
|
results.append(r)
|
|
|
|
# Write signin log
|
|
log = SigninLog(
|
|
account_id=account.id,
|
|
topic_title=topic["title"],
|
|
status="success" if r["status"] == "success"
|
|
else "failed_already_signed" if r["status"] == "already_signed"
|
|
else "failed_network",
|
|
reward_info={"message": r["message"]},
|
|
signed_at=datetime.utcnow(),
|
|
)
|
|
db.add(log)
|
|
|
|
if r["status"] == "success":
|
|
signed += 1
|
|
elif r["status"] == "already_signed":
|
|
already += 1
|
|
else:
|
|
failed += 1
|
|
|
|
account.last_checked_at = datetime.utcnow()
|
|
await db.commit()
|
|
|
|
return success_response(
|
|
{
|
|
"signed": signed,
|
|
"already_signed": already,
|
|
"failed": failed,
|
|
"total_topics": len(topics),
|
|
"details": results,
|
|
},
|
|
f"Signed {signed} topics, {already} already signed, {failed} failed",
|
|
)
|