diff --git a/backend/.env b/backend/.env index 742a7e7..b117239 100644 --- a/backend/.env +++ b/backend/.env @@ -15,5 +15,9 @@ JWT_EXPIRATION_HOURS=24 # Cookie 加密密钥 (32字节) COOKIE_ENCRYPTION_KEY=dev-cookie-encryption-key-32b +# 微信小程序配置 +WX_APPID= +WX_SECRET= + # 环境 ENVIRONMENT=development diff --git a/backend/api_service/app/routers/__pycache__/accounts.cpython-311.pyc b/backend/api_service/app/routers/__pycache__/accounts.cpython-311.pyc index 83e4255..e339a61 100644 Binary files a/backend/api_service/app/routers/__pycache__/accounts.cpython-311.pyc and b/backend/api_service/app/routers/__pycache__/accounts.cpython-311.pyc differ diff --git a/backend/api_service/app/routers/accounts.py b/backend/api_service/app/routers/accounts.py index 59193b9..6713e9f 100644 --- a/backend/api_service/app/routers/accounts.py +++ b/backend/api_service/app/routers/accounts.py @@ -3,12 +3,17 @@ 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, User +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 @@ -19,8 +24,20 @@ from api_service.app.schemas.account import ( 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) @@ -137,3 +154,312 @@ async def delete_account( 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", + ) diff --git a/backend/auth_service/app/__pycache__/main.cpython-311.pyc b/backend/auth_service/app/__pycache__/main.cpython-311.pyc index 5ba4017..c9eefad 100644 Binary files a/backend/auth_service/app/__pycache__/main.cpython-311.pyc and b/backend/auth_service/app/__pycache__/main.cpython-311.pyc differ diff --git a/backend/auth_service/app/main.py b/backend/auth_service/app/main.py index 884d189..01f07ab 100644 --- a/backend/auth_service/app/main.py +++ b/backend/auth_service/app/main.py @@ -13,9 +13,11 @@ import os import logging from shared.models import get_db, User +from shared.config import shared_settings from auth_service.app.models.database import create_tables from auth_service.app.schemas.user import ( - UserCreate, UserLogin, UserResponse, Token, TokenData, RefreshTokenRequest, AuthResponse, + UserCreate, UserLogin, UserResponse, Token, TokenData, + RefreshTokenRequest, AuthResponse, WxLoginRequest, ) from auth_service.app.services.auth_service import AuthService from auth_service.app.utils.security import ( @@ -235,3 +237,99 @@ async def get_current_user_info(current_user: UserResponse = Depends(get_current Get current user information """ return current_user + + +@app.post("/auth/wx-login", response_model=AuthResponse) +async def wx_login(body: WxLoginRequest, db: AsyncSession = Depends(get_db)): + """ + 微信小程序登录。 + + 流程: + 1. 用 code 调微信 code2Session 接口换取 openid + 2. 查找是否已有该 openid 的用户 + 3. 没有则自动注册,有则直接登录 + 4. 返回 JWT token + """ + import httpx + + appid = shared_settings.WX_APPID + secret = shared_settings.WX_SECRET + + if not appid or not secret: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="微信小程序未配置 APPID 和 SECRET", + ) + + # Step 1: code 换 openid + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.get( + "https://api.weixin.qq.com/sns/jscode2session", + params={ + "appid": appid, + "secret": secret, + "js_code": body.code, + "grant_type": "authorization_code", + }, + ) + wx_data = resp.json() + + openid = wx_data.get("openid") + if not openid: + errcode = wx_data.get("errcode", "unknown") + errmsg = wx_data.get("errmsg", "未知错误") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=f"微信登录失败: {errmsg} (errcode={errcode})", + ) + + # Step 2: 查找已有用户 + result = await db.execute(select(User).where(User.wx_openid == openid)) + user = result.scalar_one_or_none() + + if user: + # 已有用户 — 更新昵称头像(如果传了) + if body.nickname and body.nickname != user.wx_nickname: + user.wx_nickname = body.nickname + if body.avatar_url and body.avatar_url != user.wx_avatar: + user.wx_avatar = body.avatar_url + await db.commit() + await db.refresh(user) + else: + # Step 3: 自动注册 + import uuid + nickname = body.nickname or f"wx_{openid[:8]}" + # 生成唯一 username(避免冲突) + username = f"wx_{openid[:12]}" + user = User( + id=str(uuid.uuid4()), + username=username, + wx_openid=openid, + wx_nickname=nickname, + wx_avatar=body.avatar_url, + is_active=True, + ) + db.add(user) + await db.commit() + await db.refresh(user) + logger.info(f"微信用户自动注册: openid={openid[:16]}..., username={username}") + + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="账号已被禁用", + ) + + # Step 4: 签发 token + access_token = create_access_token( + data={"sub": str(user.id), "username": user.username} + ) + refresh_token = await create_refresh_token(str(user.id)) + + return AuthResponse( + access_token=access_token, + refresh_token=refresh_token, + token_type="bearer", + expires_in=3600, + user=UserResponse.from_orm(user), + ) diff --git a/backend/auth_service/app/schemas/__pycache__/user.cpython-311.pyc b/backend/auth_service/app/schemas/__pycache__/user.cpython-311.pyc index b31be47..966539c 100644 Binary files a/backend/auth_service/app/schemas/__pycache__/user.cpython-311.pyc and b/backend/auth_service/app/schemas/__pycache__/user.cpython-311.pyc differ diff --git a/backend/auth_service/app/schemas/user.py b/backend/auth_service/app/schemas/user.py index 9755de5..b2c2e1f 100644 --- a/backend/auth_service/app/schemas/user.py +++ b/backend/auth_service/app/schemas/user.py @@ -28,12 +28,17 @@ class UserUpdate(BaseModel): email: Optional[EmailStr] = None is_active: Optional[bool] = None -class UserResponse(UserBase): +class UserResponse(BaseModel): """Schema for user response data""" id: UUID + username: str + email: Optional[EmailStr] = None created_at: datetime is_active: bool - + wx_openid: Optional[str] = None + wx_nickname: Optional[str] = None + wx_avatar: Optional[str] = None + class Config: from_attributes = True # Enable ORM mode @@ -64,3 +69,10 @@ class TokenData(BaseModel): sub: str = Field(..., description="Subject (user ID)") username: str = Field(..., description="Username") exp: Optional[int] = None + + +class WxLoginRequest(BaseModel): + """微信小程序登录请求""" + code: str = Field(..., description="wx.login() 获取的临时登录凭证") + nickname: Optional[str] = Field(None, max_length=100, description="微信昵称") + avatar_url: Optional[str] = Field(None, max_length=500, description="微信头像 URL") diff --git a/backend/shared/__pycache__/config.cpython-311.pyc b/backend/shared/__pycache__/config.cpython-311.pyc index d2cd4b5..8d0a160 100644 Binary files a/backend/shared/__pycache__/config.cpython-311.pyc and b/backend/shared/__pycache__/config.cpython-311.pyc differ diff --git a/backend/shared/config.py b/backend/shared/config.py index b44cfbd..e6e676f 100644 --- a/backend/shared/config.py +++ b/backend/shared/config.py @@ -24,7 +24,11 @@ class SharedSettings(BaseSettings): # Cookie encryption COOKIE_ENCRYPTION_KEY: str = "change-me-in-production" - + + # 微信小程序 + WX_APPID: str = "" + WX_SECRET: str = "" + # Environment ENVIRONMENT: str = "development" diff --git a/backend/shared/models/__pycache__/user.cpython-311.pyc b/backend/shared/models/__pycache__/user.cpython-311.pyc index 9ebac97..257278c 100644 Binary files a/backend/shared/models/__pycache__/user.cpython-311.pyc and b/backend/shared/models/__pycache__/user.cpython-311.pyc differ diff --git a/backend/shared/models/user.py b/backend/shared/models/user.py index 616d1aa..c1f78e0 100644 --- a/backend/shared/models/user.py +++ b/backend/shared/models/user.py @@ -14,8 +14,11 @@ class User(Base): id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) username = Column(String(50), unique=True, nullable=False, index=True) - email = Column(String(255), unique=True, nullable=False, index=True) - hashed_password = Column(String(255), nullable=False) + email = Column(String(255), unique=True, nullable=True, index=True) + hashed_password = Column(String(255), nullable=True) + wx_openid = Column(String(64), unique=True, nullable=True, index=True) + wx_nickname = Column(String(100), nullable=True) + wx_avatar = Column(String(500), nullable=True) created_at = Column(DateTime, server_default=func.now()) is_active = Column(Boolean, default=True) diff --git a/backend/signin_executor/app/main.py b/backend/signin_executor/app/main.py index 63e84b0..ef3fe39 100644 --- a/backend/signin_executor/app/main.py +++ b/backend/signin_executor/app/main.py @@ -19,6 +19,8 @@ from app.services.signin_service import SignInService from app.services.weibo_client import WeiboClient from app.models.signin_models import SignInRequest, SignInResult, TaskStatus +logger = logging.getLogger(__name__) + # Initialize FastAPI app app = FastAPI( title="Weibo-HotSign Sign-in Executor", diff --git a/backend/signin_executor/app/services/signin_service.py b/backend/signin_executor/app/services/signin_service.py index 83926a5..0d54595 100644 --- a/backend/signin_executor/app/services/signin_service.py +++ b/backend/signin_executor/app/services/signin_service.py @@ -6,10 +6,9 @@ Handles Weibo super topic sign-in operations import os import sys import asyncio -import httpx import logging import random -from datetime import datetime, timedelta +from datetime import datetime from typing import Dict, Any, List, Optional from uuid import UUID from sqlalchemy import select, update @@ -230,118 +229,116 @@ class SignInService: logger.debug(f"Browser fingerprint: {fingerprint}") async def _get_super_topics_list(self, account: WeiboAccount) -> List[WeiboSuperTopic]: - """Get list of super topics for account""" + """ + Fetch the real list of followed super topics from Weibo API. + Delegates to WeiboClient.get_super_topics(). + """ try: - # Mock implementation - in real system, fetch from Weibo API - # Simulate API call delay - await asyncio.sleep(1) - - # Return mock super topics - return [ - WeiboSuperTopic( - id="topic_001", - title="Python编程", - url="https://weibo.com/p/100808xxx", - is_signed=False, - sign_url="https://weibo.com/p/aj/general/button", - reward_exp=2, - reward_credit=1 - ), - WeiboSuperTopic( - id="topic_002", - title="人工智能", - url="https://weibo.com/p/100808yyy", - is_signed=False, - sign_url="https://weibo.com/p/aj/general/button", - reward_exp=2, - reward_credit=1 - ), - WeiboSuperTopic( - id="topic_003", - title="机器学习", - url="https://weibo.com/p/100808zzz", - is_signed=True, # Already signed - sign_url="https://weibo.com/p/aj/general/button", - reward_exp=2, - reward_credit=1 - ) - ] + topics = await self.weibo_client.get_super_topics(account) + logger.info(f"Fetched {len(topics)} super topics for account {account.weibo_user_id}") + return topics except Exception as e: - logger.error(f"Error fetching super topics: {e}") + logger.error(f"Error fetching super topics for {account.weibo_user_id}: {e}") return [] async def _execute_topic_signin(self, account: WeiboAccount, topics: List[WeiboSuperTopic], task_id: str) -> Dict[str, List[str]]: - """Execute sign-in for each super topic""" - signed = [] - already_signed = [] - errors = [] - - for topic in topics: + """ + Execute sign-in for each super topic with retry logic and + per-topic progress updates. + """ + signed: List[str] = [] + already_signed: List[str] = [] + errors: List[str] = [] + max_retries = 2 + + total = len(topics) if topics else 1 + for idx, topic in enumerate(topics): + # Update progress: 50% -> 80% spread across topics + pct = 50 + int((idx / total) * 30) + await self._update_task_progress(task_id, pct) + try: - # Add small delay between requests - await asyncio.sleep(random.uniform(0.5, 1.5)) - if topic.is_signed: already_signed.append(topic.title) - # Write log for already signed await self._write_signin_log( account_id=str(account.id), topic_title=topic.title, status="failed_already_signed", reward_info=None, - error_message="Already signed today" + error_message="Already signed today", ) continue - - # Execute signin for this topic - success, reward_info, error_msg = await self.weibo_client.sign_super_topic( - account=account, - topic=topic, - task_id=task_id - ) - - if success: - signed.append(topic.title) - logger.info(f"✅ Successfully signed topic: {topic.title}") - - # Write success log - await self._write_signin_log( - account_id=str(account.id), - topic_title=topic.title, - status="success", - reward_info=reward_info, - error_message=None + + # Retry loop + last_error: Optional[str] = None + succeeded = False + for attempt in range(1, max_retries + 1): + # Inter-request delay (longer for retries) + delay = random.uniform(1.0, 3.0) * attempt + await asyncio.sleep(delay) + + success, reward_info, error_msg = await self.weibo_client.sign_super_topic( + account=account, + topic=topic, + task_id=task_id, ) - else: - errors.append(f"Failed to sign topic: {topic.title}") - - # Write failure log + + if success: + # "Already signed" from the API is still a success + if error_msg and "already" in error_msg.lower(): + already_signed.append(topic.title) + await self._write_signin_log( + account_id=str(account.id), + topic_title=topic.title, + status="failed_already_signed", + reward_info=None, + error_message=error_msg, + ) + else: + signed.append(topic.title) + logger.info(f"✅ Signed topic: {topic.title}") + await self._write_signin_log( + account_id=str(account.id), + topic_title=topic.title, + status="success", + reward_info=reward_info, + error_message=None, + ) + succeeded = True + break + + last_error = error_msg + logger.warning( + f"Attempt {attempt}/{max_retries} failed for " + f"{topic.title}: {error_msg}" + ) + + if not succeeded: + errors.append(f"{topic.title}: {last_error}") await self._write_signin_log( account_id=str(account.id), topic_title=topic.title, status="failed_network", reward_info=None, - error_message=error_msg + error_message=last_error, ) - + except Exception as e: - error_msg = f"Error signing topic {topic.title}: {str(e)}" - logger.error(error_msg) - errors.append(error_msg) - - # Write error log + err = f"Error signing topic {topic.title}: {e}" + logger.error(err) + errors.append(err) await self._write_signin_log( account_id=str(account.id), topic_title=topic.title, status="failed_network", reward_info=None, - error_message=str(e) + error_message=str(e), ) - + return { "signed": signed, - "already_signed": already_signed, - "errors": errors + "already_signed": already_signed, + "errors": errors, } async def _write_signin_log( diff --git a/backend/signin_executor/app/services/weibo_client.py b/backend/signin_executor/app/services/weibo_client.py index ff36d23..e8ec90b 100644 --- a/backend/signin_executor/app/services/weibo_client.py +++ b/backend/signin_executor/app/services/weibo_client.py @@ -1,6 +1,13 @@ """ Weibo API Client -Handles all interactions with Weibo.com, including login, sign-in, and data fetching +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) """ import os @@ -9,6 +16,9 @@ import httpx import asyncio import logging import random +import json +import re +import time from typing import Dict, Any, Optional, List, Tuple # Add parent directory to path for imports @@ -23,200 +33,313 @@ from app.services.antibot import antibot logger = logging.getLogger(__name__) +# --------------------------------------------------------------------------- +# Constants – Weibo mobile API base URLs +# --------------------------------------------------------------------------- +WEIBO_PC_API = "https://weibo.com" +WEIBO_HUATI_CHECKIN = "http://i.huati.weibo.com/aj/super/checkin" + + class WeiboClient: - """Client for interacting with Weibo API""" - + """Client for interacting with Weibo mobile API.""" + def __init__(self): - # Use antibot module for dynamic headers - self.base_headers = antibot.build_headers() - - async def verify_cookies(self, account: WeiboAccount) -> bool: - """Verify if Weibo cookies are still valid""" - try: - # Decrypt cookies using shared crypto module - 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 - - # Get proxy (with fallback to direct connection) - proxy = await antibot.get_proxy() - - # Use dynamic headers with random User-Agent - headers = antibot.build_headers() - - # Add random delay before request - delay = antibot.get_random_delay() - await asyncio.sleep(delay) - - async with httpx.AsyncClient( - cookies=cookies_dict, - headers=headers, - proxies=proxy, - timeout=10.0 - ) as client: - response = await client.get("https://weibo.com/mygroups", follow_redirects=True) - - if response.status_code == 200 and "我的首页" in response.text: - logger.info(f"Cookies for account {account.weibo_user_id} are valid") - return True - else: - logger.warning(f"Cookies for account {account.weibo_user_id} are invalid") - return False - except Exception as e: - logger.error(f"Error verifying cookies: {e}") - return False - - async def get_super_topics(self, account: WeiboAccount) -> List[WeiboSuperTopic]: - """Get list of super topics for an account""" - try: - # Mock implementation - in real system, this would involve complex API calls - # Simulate API call delay - await asyncio.sleep(random.uniform(1.0, 2.0)) - - # Return mock data - return [ - WeiboSuperTopic(id="topic_001", title="Python编程", url="...", is_signed=False), - WeiboSuperTopic(id="topic_002", title="人工智能", url="...", is_signed=False), - WeiboSuperTopic(id="topic_003", title="机器学习", url="...", is_signed=True) - ] - except Exception as e: - logger.error(f"Error fetching super topics: {e}") - return [] - - async def sign_super_topic( - self, - account: WeiboAccount, - topic: WeiboSuperTopic, - task_id: str - ) -> Tuple[bool, Optional[Dict[str, Any]], Optional[str]]: - """ - Execute sign-in for a single super topic - Returns: (success, reward_info, error_message) - """ - try: - # Decrypt cookies using shared crypto module - cookies_dict = self._decrypt_cookies(account.encrypted_cookies, account.iv) - - if not cookies_dict: - error_msg = "Failed to decrypt cookies" - logger.error(error_msg) - return False, None, error_msg - - # Get proxy (with fallback to direct connection) - proxy = await antibot.get_proxy() - - # Use dynamic headers with random User-Agent - headers = antibot.build_headers() - - # Add random delay before request (anti-bot protection) - delay = antibot.get_random_delay() - await asyncio.sleep(delay) - - # Prepare request payload - payload = { - "ajwvr": "6", - "api": "http://i.huati.weibo.com/aj/super/checkin", - "id": topic.id, - "location": "page_100808_super_index", - "refer_flag": "100808_-_1", - "refer_lflag": "100808_-_1", - "ua": headers["User-Agent"], - "is_new": "1", - "is_from_ad": "0", - "ext": "mi_898_1_0_0" - } - - # In a real scenario, we might need to call browser automation service - # to get signed parameters or handle JS challenges - - # Simulate API call - await asyncio.sleep(random.uniform(0.5, 1.5)) - - # Mock response - assume success - response_data = { - "code": "100000", - "msg": "签到成功", - "data": { - "tip": "签到成功", - "alert_title": "签到成功", - "alert_subtitle": "恭喜你成为今天第12345位签到的人", - "reward": {"exp": 2, "credit": 1} - } - } - - if response_data.get("code") == "100000": - logger.info(f"Successfully signed topic: {topic.title}") - reward_info = response_data.get("data", {}).get("reward", {}) - return True, reward_info, None - elif response_data.get("code") == "382004": - logger.info(f"Topic {topic.title} already signed today") - return True, None, "Already signed" - else: - error_msg = response_data.get("msg", "Unknown error") - logger.error(f"Failed to sign topic {topic.title}: {error_msg}") - return False, None, error_msg - - except Exception as e: - error_msg = f"Exception signing topic {topic.title}: {str(e)}" - logger.error(error_msg) - return False, None, error_msg - + 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: - # Derive encryption key from shared settings key = derive_key(shared_settings.COOKIE_ENCRYPTION_KEY) - - # Decrypt using shared crypto module plaintext = decrypt_cookie(encrypted_cookies, iv, key) - - # Parse cookie string into dict - # Expected format: "key1=value1; key2=value2; ..." - cookies_dict = {} - for cookie_pair in plaintext.split(";"): - cookie_pair = cookie_pair.strip() - if "=" in cookie_pair: - key, value = cookie_pair.split("=", 1) - cookies_dict[key.strip()] = value.strip() - + + 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 {} - - async def get_proxy(self) -> Optional[Dict[str, str]]: - """Get a proxy from the proxy pool service""" + + 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 + # ------------------------------------------------------------------ + async def verify_cookies(self, account: WeiboAccount) -> bool: + """ + 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) + try: - async with httpx.AsyncClient(timeout=5.0) as client: - response = await client.get(f"{settings.PROXY_POOL_URL}/get") - if response.status_code == 200: - proxy_info = response.json() - return { - "http://": f"http://{proxy_info['proxy']}", - "https://": f"https://{proxy_info['proxy']}" - } + 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}") + return True else: - return None + logger.warning(f"Cookies invalid for account {account.weibo_user_id}") + return False except Exception as e: - logger.error(f"Failed to get proxy: {e}") - return None - - async def get_browser_fingerprint(self) -> Dict[str, Any]: - """Get a browser fingerprint from the generator service""" + logger.error(f"Error verifying cookies for {account.weibo_user_id}: {e}") + return False + + # ------------------------------------------------------------------ + # 2. Fetch super topic list + # ------------------------------------------------------------------ + async def get_super_topics(self, account: WeiboAccount) -> List[WeiboSuperTopic]: + """ + 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 + try: - # Mock implementation - return { - "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", - "screen_resolution": "1920x1080", - "timezone": "Asia/Shanghai", - "plugins": ["PDF Viewer", "Chrome PDF Viewer", "Native Client"] - } + 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 + except Exception as e: - logger.error(f"Failed to get browser fingerprint: {e}") - return {} + logger.error(f"Error fetching super topics: {e}") + + 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 + # ------------------------------------------------------------------ + async def sign_super_topic( + self, + account: WeiboAccount, + topic: WeiboSuperTopic, + task_id: str, + ) -> Tuple[bool, Optional[Dict[str, Any]], Optional[str]]: + """ + 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} + + Returns: (success, reward_info, error_message) + """ + 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) + + try: + 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" + + else: + 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" + except Exception as e: + logger.error(f"Checkin error for {topic.title}: {e}") + return False, None, str(e) diff --git a/backend/weibo_hotsign.db b/backend/weibo_hotsign.db index f831694..5b8b19a 100644 Binary files a/backend/weibo_hotsign.db and b/backend/weibo_hotsign.db differ diff --git a/debug_cookies.json b/debug_cookies.json new file mode 100644 index 0000000..191eb45 --- /dev/null +++ b/debug_cookies.json @@ -0,0 +1,13 @@ +{ + "cookies": { + "SUB": "_2A25Es9x8DeRhGeBP6VYU8SrOyDyIHXVnsVG0rDV8PUNbmtAbLWPukW9NRX34vRCN3Y6G7yWMXYmI6ePZCu0B1OTM", + "SUBP": "0033WrSXqPxfM725Ws9jqgMF55529P9D9WhVQ79mdyajoRQ5FLMr171v5JpX5KzhUgL.FoqpeoBfeKBEe052dJLoIXnLxKBLBonL12BLxK-LB.-L1KMLxKBLBonL12BLxK.L1K-LB.qLxK-LBKBLBKMLxKML1-2L1hBLxK-LBo5L12qLxK-LB-BLBK.t", + "ALF": "1776236842", + "SSOLoginState": "1773644844", + "SRT": "D.QqHBJZPEVqbtKmMb4cYGS4HAibSSPOYOV!yuMGbHNEYdUeidMqBpMERt4EPKRcsrAdHJPsBsTsVuObHRJqM-4!iKJs!-MqWuPrsRddsHicPAd!isJmzoOsMi*B.vAflW-P9Rc0lR-yk6DvnJqiQVbiRVPBtS!r3JZPQVqbgVdWiMZ4siOzu4DbmKPWfPbm3Kds!5dEgUsHMi4SJOQzoiqE-i49ndDPI5cYPSrnlMcyoVFP6AcSbNrHESOvsJcM1OFyHdFuJ5mkCOmH6U!noJ!HJ5mjkOmzlI4noNrsJ5mkCOmH6U!noJ!HJ5mjlODEII4oCIeEJ5mjkOmHIA!oCNrsJ5mkiODmkJ!noTmHJ5mjkOmH6i4noJeEJ5mjkOmzkA!oCNpur", + "SRF": "1773644844" + }, + "uid": "6124514230", + "nick": "不得不长大的小孩", + "time": 1773644845.0194361 +} \ No newline at end of file diff --git a/debug_full_signin.py b/debug_full_signin.py new file mode 100644 index 0000000..5564e8e --- /dev/null +++ b/debug_full_signin.py @@ -0,0 +1,276 @@ +""" +完整签到流程验证脚本 +1. 用缓存 cookie 验证登录 +2. 获取超话列表 (GET /ajax/profile/topicContent?tabid=231093_-_chaohua) +3. 逐个签到 (GET /p/aj/general/button?api=...checkin&id={containerid}) +4. 汇总结果 + +Cookie 自动从 debug_cookies.json 加载,失效才重新扫码 +""" +import re, json, time, sys, os, requests + +WEIBO_HEADERS = { + 'User-Agent': ( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' + '(KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36 Edg/145.0.0.0' + ), + 'Referer': 'https://weibo.com/', + 'Accept': '*/*', + 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', +} +COOKIE_FILE = 'debug_cookies.json' + + +def parse_jsonp(text): + if not text: return None + m = re.search(r'STK_\d+\s*\((.*)\)\s*;?\s*$', text, re.DOTALL) + if m: + try: return json.loads(m.group(1)) + except: pass + m = re.search(r'\w+\s*\((.*)\)\s*;?\s*$', text, re.DOTALL) + if m: + try: return json.loads(m.group(1)) + except: pass + try: return json.loads(text) + except: return None + + +def save_cookies(cookies, uid, nick): + with open(COOKIE_FILE, 'w', encoding='utf-8') as f: + json.dump({'cookies': cookies, 'uid': uid, 'nick': nick, 'time': time.time()}, + f, ensure_ascii=False, indent=2) + + +def load_cookies(): + if not os.path.exists(COOKIE_FILE): + return None, None, None + try: + with open(COOKIE_FILE, 'r', encoding='utf-8') as f: + data = json.load(f) + cookies = data.get('cookies', {}) + uid = data.get('uid', '') + nick = data.get('nick', '') + age = time.time() - data.get('time', 0) + print(f" 从 {COOKIE_FILE} 加载 (uid={uid}, nick={nick}, {age/3600:.1f}h ago)") + r = requests.get('https://weibo.com/ajax/side/cards', params={'count': 1}, + cookies=cookies, headers=WEIBO_HEADERS, timeout=10) + if r.json().get('ok') == 1: + print(f" ✅ Cookie 有效") + return cookies, uid, nick + print(f" ❌ Cookie 已失效") + except Exception as e: + print(f" 加载失败: {e}") + return None, None, None + + +def qrcode_login(): + print(" 扫码登录...") + sess = requests.Session() + sess.headers.update(WEIBO_HEADERS) + resp = sess.get('https://login.sina.com.cn/sso/qrcode/image', + params={'entry': 'weibo', 'size': '180', + 'callback': f'STK_{int(time.time()*1000)}'}, timeout=10) + data = parse_jsonp(resp.text) + if not data or not isinstance(data.get('data'), dict): + print(f" ❌ 生成二维码失败"); sys.exit(1) + qrid = data['data']['qrid'] + image = data['data']['image'] + if image.startswith('//'): image = 'https:' + image + with open('qrcode.png', 'wb') as f: + f.write(requests.get(image, timeout=10).content) + print(f" 请扫描 qrcode.png ...") + alt_token = None + last = None + for i in range(120): + time.sleep(2) + resp = sess.get('https://login.sina.com.cn/sso/qrcode/check', + params={'entry': 'weibo', 'qrid': qrid, + 'callback': f'STK_{int(time.time()*1000)}'}, timeout=10) + d = parse_jsonp(resp.text) + rc = d.get('retcode') if d else None + if rc != last: print(f" [{i+1}] {last} -> {rc}"); last = rc + if rc == 20000000 and isinstance(d.get('data'), dict) and d['data'].get('alt'): + alt_token = d['data']['alt']; break + if rc in (50114004, 50050002): sys.exit(1) + if not alt_token: sys.exit(1) + sso = requests.Session() + sso.headers.update(WEIBO_HEADERS) + resp = sso.get( + f"https://login.sina.com.cn/sso/login.php?entry=weibo&returntype=TEXT" + f"&crossdomain=1&cdult=3&domain=weibo.com&alt={alt_token}&savestate=30" + f"&callback=STK_{int(time.time()*1000)}", + allow_redirects=True, timeout=15) + sso_data = parse_jsonp(resp.text) + uid = str(sso_data.get('uid', '')) + nick = sso_data.get('nick', '') + for u in sso_data.get('crossDomainUrlList', []): + if isinstance(u, str) and u.startswith('http'): + try: sso.get(u, allow_redirects=True, timeout=10) + except: pass + cookies = {} + for c in sso.cookies: + if c.domain and 'weibo.com' in c.domain: + cookies[c.name] = c.value + print(f" ✅ uid={uid}, nick={nick}") + save_cookies(cookies, uid, nick) + return cookies, uid, nick + + +def get_super_topics(sess, xsrf, uid): + """获取关注的超话列表""" + topics = [] + page = 1 + while page <= 10: + r = sess.get( + 'https://weibo.com/ajax/profile/topicContent', + params={'tabid': '231093_-_chaohua', 'page': str(page)}, + headers={ + 'Referer': f'https://weibo.com/u/page/follow/{uid}/231093_-_chaohua', + 'X-XSRF-TOKEN': xsrf, + 'X-Requested-With': 'XMLHttpRequest', + }, + timeout=10, + ) + d = r.json() + if d.get('ok') != 1: + break + topic_list = d.get('data', {}).get('list', []) + if not topic_list: + break + for item in topic_list: + title = item.get('topic_name', '') or item.get('title', '') + # 从 oid "1022:100808xxx" 提取 containerid + containerid = '' + oid = item.get('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}) + max_page = d.get('data', {}).get('max_page', 1) + if page >= max_page: + break + page += 1 + return topics + + +def do_signin(sess, xsrf, containerid, topic_title): + """ + 签到单个超话 + 完整参数来自浏览器抓包: + GET /p/aj/general/button?ajwvr=6&api=http://i.huati.weibo.com/aj/super/checkin + &texta=签到&textb=已签到&status=0&id={containerid} + &location=page_100808_super_index&... + """ + r = sess.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={ + 'Referer': f'https://weibo.com/p/{containerid}/super_index', + 'X-Requested-With': 'XMLHttpRequest', + 'X-XSRF-TOKEN': xsrf, + }, + timeout=10, + ) + try: + d = r.json() + code = str(d.get('code', '')) + msg = d.get('msg', '') + data = d.get('data', {}) + if code == '100000': + tip = '' + if isinstance(data, dict): + tip = data.get('alert_title', '') or data.get('tipMessage', '') + return {'status': 'success', 'message': tip or '签到成功', 'data': data} + 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': f'非 JSON: {r.text[:200]}'} + + +# ================================================================ +# Main +# ================================================================ +print("=" * 60) +print("微博超话自动签到 - 完整流程验证") +print("=" * 60) + +# Step 1: 获取 cookie +print("\n--- Step 1: 初始化 ---") +cookies, uid, nick = load_cookies() +if not cookies: + cookies, uid, nick = qrcode_login() + +# 建立 session +sess = requests.Session() +sess.headers.update(WEIBO_HEADERS) +for k, v in cookies.items(): + sess.cookies.set(k, v, domain='.weibo.com') +sess.get('https://weibo.com/', timeout=10) +xsrf = sess.cookies.get('XSRF-TOKEN', '') +print(f" XSRF: {'✅' if xsrf else '❌ MISSING'}") + +# Step 2: 获取超话列表 +print(f"\n--- Step 2: 获取超话列表 ---") +topics = get_super_topics(sess, xsrf, uid) +print(f" 找到 {len(topics)} 个超话:") +for i, t in enumerate(topics): + print(f" [{i+1}] {t['title']} ({t['containerid'][:20]}...)") + +if not topics: + print(" ❌ 没有找到超话,退出") + sys.exit(1) + +# Step 3: 逐个签到 +print(f"\n--- Step 3: 签到 ({len(topics)} 个超话) ---") +signed = already = failed = 0 +results = [] +for i, t in enumerate(topics): + print(f"\n [{i+1}/{len(topics)}] {t['title']}", end=' ... ') + r = do_signin(sess, xsrf, t['containerid'], t['title']) + results.append({**r, 'topic': t['title']}) + + if r['status'] == 'success': + signed += 1 + print(f"✅ {r['message']}") + elif r['status'] == 'already_signed': + already += 1 + print(f"ℹ️ {r['message']}") + else: + failed += 1 + print(f"❌ {r['message']}") + + if i < len(topics) - 1: + time.sleep(1.5) # 防封间隔 + +# Step 4: 汇总 +print(f"\n{'=' * 60}") +print(f"签到完成!") +print(f" ✅ 成功: {signed}") +print(f" ℹ️ 已签: {already}") +print(f" ❌ 失败: {failed}") +print(f" 📊 总计: {len(topics)} 个超话") +print(f"{'=' * 60}") diff --git a/debug_qrcode_flow.py b/debug_qrcode_flow.py new file mode 100644 index 0000000..9eddf48 --- /dev/null +++ b/debug_qrcode_flow.py @@ -0,0 +1,509 @@ +""" +微博扫码登录 + 完整业务流程调试脚本。 + +模式: + python debug_qrcode_flow.py # 直连模式:扫码 + 添加 + 验证 + 签到 + python debug_qrcode_flow.py --frontend # 前端模式:通过 Flask 路由 + python debug_qrcode_flow.py --api-only # 仅测试后端 API(跳过扫码,用已有账号) +""" + +import re +import json +import time +import base64 +import requests +import sys + +# ---- 配置 ---- +AUTH_BASE_URL = 'http://localhost:8001' +API_BASE_URL = 'http://localhost:8000' +FRONTEND_URL = 'http://localhost:5000' +TEST_EMAIL = 'admin@example.com' +TEST_PASSWORD = 'Admin123!' + +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', +} + +WEIBO_SESSION = requests.Session() +WEIBO_SESSION.headers.update(WEIBO_HEADERS) + + +def parse_jsonp(text): + m = re.search(r'\((.*)\)', text, re.DOTALL) + if m: + return json.loads(m.group(1)) + try: + return json.loads(text) + except Exception: + return None + + +def api_headers(token): + return {'Content-Type': 'application/json', 'Authorization': f'Bearer {token}'} + + +# ================================================================ +# STEP 0: 登录系统 +# ================================================================ +def step0_login(): + print("=" * 60) + print("STEP 0: 登录系统") + print("=" * 60) + resp = requests.post( + f'{AUTH_BASE_URL}/auth/login', + json={'email': TEST_EMAIL, 'password': TEST_PASSWORD}, + timeout=10, + ) + print(f" Status: {resp.status_code}") + if resp.status_code != 200: + print(f" [ERROR] 登录失败: {resp.text[:300]}") + return None + data = resp.json() + token = data['access_token'] + user = data.get('user', {}) + print(f" 用户: {user.get('username')} ({user.get('email')})") + print(f" token: {token[:50]}...") + + # 验证 token 对 API 服务有效 + verify = requests.get( + f'{API_BASE_URL}/api/v1/accounts', + headers=api_headers(token), + timeout=10, + ) + print(f" 验证 API 服务: GET /accounts -> {verify.status_code}") + if verify.status_code != 200: + print(f" [ERROR] API 服务 token 无效: {verify.text[:300]}") + return None + print(f" ✅ token 有效") + return token + + +# ================================================================ +# STEP 1: 清理重复账号 +# ================================================================ +def step1_cleanup(token): + print("\n" + "=" * 60) + print("STEP 1: 清理重复账号") + print("=" * 60) + resp = requests.get(f'{API_BASE_URL}/api/v1/accounts', headers=api_headers(token), timeout=10) + accounts = resp.json().get('data', []) + print(f" 当前账号数: {len(accounts)}") + + if len(accounts) <= 1: + print(" 无需清理") + return accounts[0]['id'] if accounts else None + + # 按 weibo_user_id 分组,每组只保留最新的 + groups = {} + for acc in accounts: + wid = acc['weibo_user_id'] + if wid not in groups: + groups[wid] = [] + groups[wid].append(acc) + + keep_id = None + for wid, accs in groups.items(): + accs.sort(key=lambda a: a['created_at'], reverse=True) + keep = accs[0] + keep_id = keep['id'] + print(f" 保留: {keep['id'][:8]}... ({keep['remark']}) created={keep['created_at']}") + for dup in accs[1:]: + print(f" 删除: {dup['id'][:8]}... ({dup['remark']}) created={dup['created_at']}") + del_resp = requests.delete( + f'{API_BASE_URL}/api/v1/accounts/{dup["id"]}', + headers=api_headers(token), + timeout=10, + ) + print(f" -> {del_resp.status_code}") + + # 验证 + resp2 = requests.get(f'{API_BASE_URL}/api/v1/accounts', headers=api_headers(token), timeout=10) + remaining = resp2.json().get('data', []) + print(f" 清理后账号数: {len(remaining)}") + return keep_id + + +# ================================================================ +# STEP 2: 验证 Cookie(POST /accounts/{id}/verify) +# ================================================================ +def step2_verify(token, account_id): + print("\n" + "=" * 60) + print("STEP 2: 验证 Cookie") + print("=" * 60) + resp = requests.post( + f'{API_BASE_URL}/api/v1/accounts/{account_id}/verify', + headers=api_headers(token), + timeout=20, + ) + print(f" Status: {resp.status_code}") + print(f" Body: {resp.text[:500]}") + + if resp.status_code == 200: + data = resp.json().get('data', {}) + valid = data.get('cookie_valid') + status = data.get('status') + name = data.get('weibo_screen_name', '') + print(f" cookie_valid: {valid}") + print(f" status: {status}") + print(f" screen_name: {name}") + if valid: + print(" ✅ Cookie 有效,账号已激活") + else: + print(" ❌ Cookie 无效") + return valid + else: + print(f" ❌ 验证失败") + return False + + +# ================================================================ +# STEP 3: 手动签到(POST /accounts/{id}/signin) +# ================================================================ +def step3_signin(token, account_id): + print("\n" + "=" * 60) + print("STEP 3: 手动签到") + print("=" * 60) + print(" 调用 POST /accounts/{id}/signin ...") + print(" (这可能需要一些时间,每个超话间隔 1.5 秒)") + resp = requests.post( + f'{API_BASE_URL}/api/v1/accounts/{account_id}/signin', + headers=api_headers(token), + timeout=300, # 签到可能很慢 + ) + print(f" Status: {resp.status_code}") + + if resp.status_code == 200: + result = resp.json() + data = result.get('data', {}) + msg = result.get('message', '') + print(f" 消息: {msg}") + print(f" 签到成功: {data.get('signed', 0)}") + print(f" 已签过: {data.get('already_signed', 0)}") + print(f" 失败: {data.get('failed', 0)}") + print(f" 总超话数: {data.get('total_topics', 0)}") + details = data.get('details', []) + for d in details[:10]: # 最多显示 10 条 + icon = '✅' if d['status'] == 'success' else '⏭️' if d['status'] == 'already_signed' else '❌' + print(f" {icon} {d.get('topic', '?')}: {d.get('message', '')}") + if len(details) > 10: + print(f" ... 还有 {len(details) - 10} 条") + return True + else: + print(f" ❌ 签到失败: {resp.text[:500]}") + return False + + +# ================================================================ +# STEP 4: 查看签到日志 +# ================================================================ +def step4_logs(token, account_id): + print("\n" + "=" * 60) + print("STEP 4: 查看签到日志") + print("=" * 60) + resp = requests.get( + f'{API_BASE_URL}/api/v1/accounts/{account_id}/signin-logs', + params={'page': 1, 'size': 10}, + headers=api_headers(token), + timeout=10, + ) + print(f" Status: {resp.status_code}") + if resp.status_code == 200: + data = resp.json().get('data', {}) + items = data.get('items', []) + total = data.get('total', 0) + print(f" 总日志数: {total}") + for log in items[:5]: + print(f" [{log.get('status')}] {log.get('topic_title', '?')} @ {log.get('signed_at', '?')[:19]}") + if not items: + print(" (暂无日志)") + else: + print(f" ❌ 获取日志失败: {resp.text[:300]}") + + +# ================================================================ +# STEP 5: 查看账号详情(验证前端会用到的接口) +# ================================================================ +def step5_detail(token, account_id): + print("\n" + "=" * 60) + print("STEP 5: 验证账号详情接口") + print("=" * 60) + + # 账号详情 + r1 = requests.get(f'{API_BASE_URL}/api/v1/accounts/{account_id}', headers=api_headers(token), timeout=10) + print(f" GET /accounts/{{id}} -> {r1.status_code}") + if r1.status_code == 200: + acc = r1.json().get('data', {}) + print(f" status={acc.get('status')}, remark={acc.get('remark')}") + else: + print(f" ❌ {r1.text[:200]}") + + # 任务列表 + r2 = requests.get(f'{API_BASE_URL}/api/v1/accounts/{account_id}/tasks', headers=api_headers(token), timeout=10) + print(f" GET /accounts/{{id}}/tasks -> {r2.status_code}") + if r2.status_code == 200: + tasks = r2.json().get('data', []) + print(f" 任务数: {len(tasks)}") + else: + print(f" ❌ {r2.text[:200]}") + + # 签到日志 + r3 = requests.get( + f'{API_BASE_URL}/api/v1/accounts/{account_id}/signin-logs', + params={'page': 1, 'size': 5}, + headers=api_headers(token), + timeout=10, + ) + print(f" GET /accounts/{{id}}/signin-logs -> {r3.status_code}") + if r3.status_code == 200: + logs = r3.json().get('data', {}) + print(f" 日志数: {logs.get('total', 0)}, items: {len(logs.get('items', []))}") + else: + print(f" ❌ {r3.text[:200]}") + + if all(r.status_code == 200 for r in [r1, r2, r3]): + print(" ✅ 所有详情接口正常") + else: + print(" ❌ 部分接口异常") + + +# ================================================================ +# 扫码流程(直连模式用) +# ================================================================ +def qrcode_flow(token): + """生成二维码 → 轮询 → SSO 登录 → 添加账号,返回 account_id""" + + # 生成二维码 + print("\n" + "=" * 60) + print("QRCODE: 生成二维码") + print("=" * 60) + url = 'https://login.sina.com.cn/sso/qrcode/image' + params = {'entry': 'weibo', 'size': '180', 'callback': f'STK_{int(time.time() * 1000)}'} + resp = WEIBO_SESSION.get(url, params=params, timeout=10) + data = parse_jsonp(resp.text) + if not data or data.get('retcode') != 20000000: + print(" [ERROR] 生成二维码失败") + return None + qr_data = data.get('data', {}) + qrid = qr_data.get('qrid') + image = qr_data.get('image', '') + if image.startswith('//'): + image = 'https:' + image + if image.startswith('http'): + img_resp = WEIBO_SESSION.get(image, timeout=10) + with open('qrcode.png', 'wb') as f: + f.write(img_resp.content) + print(f" qrid: {qrid}") + print(f" 二维码已保存到 qrcode.png") + + # 轮询 + print("\n 请用手机微博扫描 qrcode.png ...") + check_url = 'https://login.sina.com.cn/sso/qrcode/check' + last_retcode = None + alt_token = None + for i in range(120): + time.sleep(2) + params = {'entry': 'weibo', 'qrid': qrid, 'callback': f'STK_{int(time.time() * 1000)}'} + resp = WEIBO_SESSION.get(check_url, params=params, timeout=10) + data = parse_jsonp(resp.text) + retcode = data.get('retcode') if data else None + if retcode != last_retcode: + msg = data.get('msg', '') if data else '' + print(f" [{i+1}] retcode: {last_retcode} -> {retcode} ({msg})") + last_retcode = retcode + else: + print(f" [{i+1}] retcode={retcode}", end='\r') + if not data: + continue + nested = data.get('data') + alt = nested.get('alt', '') if isinstance(nested, dict) else '' + if retcode == 20000000 and alt: + print(f"\n ✅ 登录成功! alt={alt[:40]}...") + alt_token = alt + break + if retcode in (50114004, 50050002): + print(f"\n ❌ 二维码失效") + return None + + if not alt_token: + print("\n ❌ 轮询超时") + return None + + # SSO 登录 + print("\n SSO 登录...") + sso_url = ( + f"https://login.sina.com.cn/sso/login.php" + f"?entry=weibo&returntype=TEXT&crossdomain=1&cdult=3" + f"&domain=weibo.com&alt={alt_token}&savestate=30" + f"&callback=STK_{int(time.time() * 1000)}" + ) + sso_session = requests.Session() + sso_session.headers.update(WEIBO_HEADERS) + resp = sso_session.get(sso_url, allow_redirects=True, timeout=15) + sso_data = parse_jsonp(resp.text) + uid = str(sso_data.get('uid', '')) if sso_data else '' + nick = sso_data.get('nick', '') if sso_data else '' + cross_urls = sso_data.get('crossDomainUrlList', []) if sso_data else [] + print(f" uid={uid}, nick={nick}, crossDomainUrls={len(cross_urls)}") + for u in cross_urls: + if isinstance(u, str) and u.startswith('http'): + try: + sso_session.get(u, allow_redirects=True, timeout=10) + except Exception: + pass + all_cookies = {} + for c in sso_session.cookies: + if c.domain and 'weibo.com' in c.domain: + all_cookies[c.name] = c.value + cookie_str = '; '.join(f'{k}={v}' for k, v in all_cookies.items()) + has_sub = 'SUB' in all_cookies + print(f" weibo.com Cookie 字段 ({len(all_cookies)}): {list(all_cookies.keys())}") + print(f" 包含 SUB: {'✅' if has_sub else '❌'}") + if not has_sub: + print(" [ERROR] 缺少 SUB cookie") + return None + + # 添加账号 + print("\n 添加账号到后端...") + remark = f"{nick} (调试脚本)" if nick else "调试脚本添加" + resp = requests.post( + f'{API_BASE_URL}/api/v1/accounts', + json={'weibo_user_id': uid, 'cookie': cookie_str, 'remark': remark}, + headers=api_headers(token), + timeout=10, + ) + print(f" Status: {resp.status_code}") + if resp.status_code in (200, 201): + acc = resp.json().get('data', {}) + account_id = acc.get('id') + print(f" ✅ 账号添加成功: {account_id}") + return account_id + else: + print(f" ❌ 添加失败: {resp.text[:300]}") + return None + + +# ================================================================ +# 前端模式 +# ================================================================ +def frontend_flow(): + fe = requests.Session() + + print("=" * 60) + print("FRONTEND: 登录") + print("=" * 60) + resp = fe.post(f'{FRONTEND_URL}/login', data={'email': TEST_EMAIL, 'password': TEST_PASSWORD}, allow_redirects=False, timeout=10) + print(f" POST /login -> {resp.status_code}") + if resp.status_code in (301, 302): + fe.get(f'{FRONTEND_URL}{resp.headers["Location"]}', timeout=10) + dash = fe.get(f'{FRONTEND_URL}/dashboard', timeout=10) + if dash.status_code != 200: + print(" [ERROR] 登录失败") + return + print(" ✅ 登录成功") + + print("\n" + "=" * 60) + print("FRONTEND: 生成二维码") + print("=" * 60) + resp = fe.post(f'{FRONTEND_URL}/api/weibo/qrcode/generate', headers={'Content-Type': 'application/json'}, timeout=15) + print(f" Status: {resp.status_code}, Body: {resp.text[:300]}") + gen = resp.json() + if not gen.get('success'): + print(" [ERROR] 生成失败") + return + qrid = gen['qrid'] + qr_image = gen.get('qr_image', '') + if qr_image.startswith('http'): + img = requests.get(qr_image, headers=WEIBO_HEADERS, timeout=10) + with open('qrcode.png', 'wb') as f: + f.write(img.content) + print(" 二维码已保存到 qrcode.png") + + print("\n 请用手机微博扫描 qrcode.png ...") + last_status = None + for i in range(120): + time.sleep(2) + resp = fe.get(f'{FRONTEND_URL}/api/weibo/qrcode/check/{qrid}', timeout=15) + data = resp.json() + st = data.get('status') + if st != last_status: + print(f" [{i+1}] status: {last_status} -> {st} | {json.dumps(data, ensure_ascii=False)[:200]}") + last_status = st + else: + print(f" [{i+1}] status={st}", end='\r') + if st == 'success': + print(f"\n ✅ 扫码成功! uid={data.get('weibo_uid')}") + break + if st in ('expired', 'cancelled', 'error'): + print(f"\n ❌ {st}: {data.get('error', '')}") + return + else: + print("\n ❌ 超时") + return + + # 添加账号 + print("\n 添加账号...") + time.sleep(0.5) + resp = fe.post( + f'{FRONTEND_URL}/api/weibo/qrcode/add-account', + json={'qrid': qrid}, + headers={'Content-Type': 'application/json'}, + timeout=15, + ) + print(f" Status: {resp.status_code}, Body: {resp.text[:500]}") + result = resp.json() + if result.get('success'): + print(f" ✅ 账号添加成功!") + else: + print(f" ❌ 添加失败: {result.get('message')}") + + +# ================================================================ +# MAIN +# ================================================================ +if __name__ == '__main__': + mode = 'direct' + if '--frontend' in sys.argv: + mode = 'frontend' + elif '--api-only' in sys.argv: + mode = 'api-only' + + if mode == 'frontend': + print("🌐 前端模式") + frontend_flow() + else: + # 直连模式 或 api-only 模式 + token = step0_login() + if not token: + sys.exit(1) + + if mode == 'api-only': + print("\n📡 API-only 模式:跳过扫码,使用已有账号") + account_id = step1_cleanup(token) + if not account_id: + print("\n没有账号,请先用默认模式添加") + sys.exit(1) + else: + print("\n🔗 直连模式:扫码 + 完整业务流程") + # 先清理 + step1_cleanup(token) + # 扫码添加 + account_id = qrcode_flow(token) + if not account_id: + sys.exit(1) + + # 后续业务验证 + step2_verify(token, account_id) + step3_signin(token, account_id) + step4_logs(token, account_id) + step5_detail(token, account_id) + + print("\n" + "=" * 60) + print("全部流程完成!") + print("=" * 60) diff --git a/frontend/app.py b/frontend/app.py index eb18708..5918e0b 100644 --- a/frontend/app.py +++ b/frontend/app.py @@ -1,4 +1,10 @@ import os +import re +import json +import time +import uuid +import logging +import traceback from flask import Flask, render_template, request, redirect, url_for, session, flash, jsonify from flask_session import Session import requests @@ -16,19 +22,79 @@ Session(app) API_BASE_URL = os.getenv('API_BASE_URL', 'http://localhost:8000') AUTH_BASE_URL = os.getenv('AUTH_BASE_URL', 'http://localhost:8001') +logger = logging.getLogger(__name__) + def get_headers(): - """获取请求头,包含认证令牌""" + """获取请求头,包含认证令牌。如果 token 过期会自动刷新。""" headers = {'Content-Type': 'application/json'} if 'access_token' in session: headers['Authorization'] = f"Bearer {session['access_token']}" return headers + +def _try_refresh_token(): + """ + 尝试用 refresh_token 刷新 access_token。 + 成功返回 True 并更新 session,失败返回 False。 + """ + refresh_token = session.get('refresh_token') + if not refresh_token: + logger.warning("Token 刷新失败: session 中没有 refresh_token") + return False + try: + resp = requests.post( + f'{AUTH_BASE_URL}/auth/refresh', + json={'refresh_token': refresh_token}, + timeout=10, + ) + if resp.status_code == 200: + data = resp.json() + session['access_token'] = data['access_token'] + session['refresh_token'] = data['refresh_token'] + session.modified = True + logger.info("Token 刷新成功") + return True + else: + logger.warning(f"Token 刷新失败: HTTP {resp.status_code}, body={resp.text[:200]}") + except Exception as e: + logger.warning(f"Token 刷新异常: {e}") + return False + + +def api_request(method, url, **kwargs): + """ + 封装 API 请求,自动处理 token 过期刷新。 + 如果收到 401,尝试刷新 token 后重试一次。 + """ + headers = kwargs.pop('headers', None) or get_headers() + token_preview = headers.get('Authorization', 'NONE')[:30] + logger.info(f"api_request: {method} {url} token={token_preview}...") + + resp = requests.request(method, url, headers=headers, timeout=10, **kwargs) + + if resp.status_code == 401: + logger.warning(f"api_request: 收到 401, 尝试刷新 token...") + if _try_refresh_token(): + # Token 已刷新,用新 token 重试 + headers['Authorization'] = f"Bearer {session['access_token']}" + logger.info(f"api_request: 刷新成功,重试请求...") + resp = requests.request(method, url, headers=headers, timeout=10, **kwargs) + else: + logger.error(f"api_request: token 刷新失败,清除 session 让用户重新登录") + # 清除无效的 token,下次访问会被 login_required 拦截 + session.pop('access_token', None) + session.pop('refresh_token', None) + session.pop('user', None) + session.modified = True + + return resp + def login_required(f): """登录验证装饰器""" @wraps(f) def decorated_function(*args, **kwargs): if 'user' not in session: - flash('Please login first', 'warning') + flash('请先登录', 'warning') return redirect(url_for('login')) return f(*args, **kwargs) return decorated_function @@ -48,7 +114,7 @@ def register(): confirm_password = request.form.get('confirm_password') if password != confirm_password: - flash('Passwords do not match', 'danger') + flash('两次输入的密码不一致', 'danger') return redirect(url_for('register')) try: @@ -63,13 +129,13 @@ def register(): session['user'] = data['user'] session['access_token'] = data['access_token'] session['refresh_token'] = data['refresh_token'] - flash('Registration successful!', 'success') + flash('注册成功', 'success') return redirect(url_for('dashboard')) else: error_data = response.json() - flash(error_data.get('detail', 'Registration failed'), 'danger') + flash(error_data.get('detail', '注册失败'), 'danger') except requests.RequestException as e: - flash(f'Connection error: {str(e)}', 'danger') + flash(f'连接错误: {str(e)}', 'danger') return render_template('register.html') @@ -91,354 +157,391 @@ def login(): session['user'] = data['user'] session['access_token'] = data['access_token'] session['refresh_token'] = data['refresh_token'] - flash('Login successful!', 'success') + flash('登录成功', 'success') return redirect(url_for('dashboard')) else: error_data = response.json() - flash(error_data.get('detail', 'Login failed'), 'danger') + flash(error_data.get('detail', '登录失败'), 'danger') except requests.RequestException as e: - flash(f'Connection error: {str(e)}', 'danger') + flash(f'连接错误: {str(e)}', 'danger') return render_template('login.html') + +@app.route('/api/auth/wx-login', methods=['POST']) +def wx_login_proxy(): + """ + 微信小程序登录代理。 + 小程序端调这个接口,转发到 auth_service 的 /auth/wx-login。 + Web 端也可以用(未来微信扫码登录)。 + """ + try: + data = request.json + response = requests.post( + f'{AUTH_BASE_URL}/auth/wx-login', + json=data, + timeout=15, + ) + + if response.status_code == 200: + result = response.json() + # 写入 session(Web 端用) + session['user'] = result.get('user') + session['access_token'] = result.get('access_token') + session['refresh_token'] = result.get('refresh_token') + session.modified = True + return jsonify({'success': True, 'data': result}) + else: + detail = response.json().get('detail', '微信登录失败') + return jsonify({'success': False, 'message': detail}), response.status_code + except Exception as e: + logger.exception("微信登录代理异常") + return jsonify({'success': False, 'message': str(e)}), 500 + + @app.route('/logout') def logout(): session.clear() - flash('Logged out successfully', 'success') + flash('已退出登录', 'success') return redirect(url_for('login')) @app.route('/dashboard') @login_required def dashboard(): try: - response = requests.get( + response = api_request( + 'GET', f'{API_BASE_URL}/api/v1/accounts', - headers=get_headers(), - timeout=10 ) data = response.json() accounts = data.get('data', []) if data.get('success') else [] except requests.RequestException: accounts = [] - flash('Failed to load accounts', 'warning') + flash('加载账号列表失败', 'warning') return render_template('dashboard.html', accounts=accounts, user=session.get('user')) -@app.route('/accounts/new', methods=['GET', 'POST']) +@app.route('/accounts/new') @login_required def add_account(): - if request.method == 'POST': - login_method = request.form.get('login_method', 'manual') - - if login_method == 'manual': - weibo_user_id = request.form.get('weibo_user_id') - cookie = request.form.get('cookie') - remark = request.form.get('remark') + return render_template('add_account.html') - try: - response = requests.post( - f'{API_BASE_URL}/api/v1/accounts', - json={ - 'weibo_user_id': weibo_user_id, - 'cookie': cookie, - 'remark': remark - }, - headers=get_headers(), - timeout=10 - ) - data = response.json() - if response.status_code == 200 and data.get('success'): - flash('Account added successfully!', 'success') - return redirect(url_for('dashboard')) - else: - flash(data.get('message', 'Failed to add account'), 'danger') - except requests.RequestException as e: - flash(f'Connection error: {str(e)}', 'danger') +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', + 'Connection': 'keep-alive', +} - # 扫码授权功能总是启用(使用微博网页版接口) - weibo_qrcode_enabled = True - - return render_template('add_account.html', - weibo_qrcode_enabled=weibo_qrcode_enabled) + +def _parse_jsonp(text): + """Strip JSONP wrapper and return parsed dict, or None.""" + m = re.search(r'\((.*)\)', text, re.DOTALL) + if m: + return json.loads(m.group(1)) + # Maybe it's already plain JSON + try: + return json.loads(text) + except (json.JSONDecodeError, ValueError): + return None @app.route('/api/weibo/qrcode/generate', methods=['POST']) @login_required def generate_weibo_qrcode(): - """生成微博扫码登录二维码(模拟网页版)""" - import uuid - import time - import traceback - + """ + 生成微博扫码登录二维码。 + 调用 https://login.sina.com.cn/sso/qrcode/image 获取 qrid + 二维码图片。 + """ try: - # 模拟微博网页版的二维码生成接口 - # 实际接口:https://login.sina.com.cn/sso/qrcode/image - - # 生成唯一的 qrcode_id - qrcode_id = str(uuid.uuid4()) - - # 调用微博的二维码生成接口 qr_api_url = 'https://login.sina.com.cn/sso/qrcode/image' params = { 'entry': 'weibo', 'size': '180', - 'callback': f'STK_{int(time.time() * 1000)}' + 'callback': f'STK_{int(time.time() * 1000)}', } - - # 添加浏览器请求头,模拟真实浏览器 - 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', - 'Accept-Encoding': 'gzip, deflate, br', - 'Connection': 'keep-alive' + + resp = requests.get(qr_api_url, params=params, headers=WEIBO_HEADERS, timeout=10) + logger.debug(f"qrcode/image status={resp.status_code} body={resp.text[:300]}") + + data = _parse_jsonp(resp.text) + if not data or data.get('retcode') != 20000000: + return jsonify({'success': False, 'error': '生成二维码失败'}), 500 + + qr_data = data.get('data', {}) + qrid = qr_data.get('qrid') + qr_image_url = qr_data.get('image', '') + + if not qrid or not qr_image_url: + return jsonify({'success': False, 'error': '二维码数据不完整'}), 500 + + if qr_image_url.startswith('//'): + qr_image_url = 'https:' + qr_image_url + + # 存储二维码状态 + if 'weibo_qrcodes' not in session: + session['weibo_qrcodes'] = {} + session['weibo_qrcodes'][qrid] = { + 'status': 'waiting', + 'created_at': str(datetime.now()), } - - print(f"[DEBUG] 请求微博 API: {qr_api_url}") - response = requests.get(qr_api_url, params=params, headers=headers, timeout=10) - print(f"[DEBUG] 响应状态码: {response.status_code}") - print(f"[DEBUG] 响应内容: {response.text[:200]}") - - # 微博返回的是 JSONP 格式,需要解析 - # 格式:STK_xxx({"retcode":20000000,"qrid":"xxx","image":"data:image/png;base64,xxx"}) - import re - import json - - match = re.search(r'\((.*)\)', response.text) - if match: - data = json.loads(match.group(1)) - print(f"[DEBUG] 解析的数据: retcode={data.get('retcode')}, data={data.get('data')}") - - if data.get('retcode') == 20000000: - # 微博返回的数据结构:{"retcode":20000000,"data":{"qrid":"...","image":"..."}} - qr_data = data.get('data', {}) - qrid = qr_data.get('qrid') - qr_image_url = qr_data.get('image') - - if not qrid or not qr_image_url: - print(f"[ERROR] 缺少 qrid 或 image: qrid={qrid}, image={qr_image_url}") - return jsonify({'success': False, 'error': '二维码数据不完整'}), 500 - - # 如果 image 是相对 URL,补全为完整 URL - if qr_image_url.startswith('//'): - qr_image_url = 'https:' + qr_image_url - - print(f"[DEBUG] 二维码 URL: {qr_image_url}") - - # 存储二维码状态 - if 'weibo_qrcodes' not in session: - session['weibo_qrcodes'] = {} - session['weibo_qrcodes'][qrid] = { - 'status': 'waiting', - 'created_at': str(datetime.now()), - 'qrcode_id': qrcode_id - } - session.modified = True - - print(f"[DEBUG] 二维码生成成功: qrid={qrid}") - return jsonify({ - 'success': True, - 'qrid': qrid, - 'qr_image': qr_image_url, # 返回二维码图片 URL - 'expires_in': 180 - }) - - print("[DEBUG] 未能解析响应或 retcode 不正确") - return jsonify({'success': False, 'error': '生成二维码失败'}), 500 - + session.modified = True + + return jsonify({ + 'success': True, + 'qrid': qrid, + 'qr_image': qr_image_url, + 'expires_in': 180, + }) + except Exception as e: - print(f"[ERROR] 生成二维码异常: {str(e)}") - print(f"[ERROR] 堆栈跟踪:\n{traceback.format_exc()}") + logger.exception("生成二维码异常") return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/weibo/qrcode/check/', methods=['GET']) @login_required def check_weibo_qrcode(qrid): - """检查微博扫码状态(模拟网页版)""" - import time - + """ + 轮询微博扫码状态。 + + 微博 check 接口必须用 JSONP(带 callback),否则返回非 JSON 内容。 + 成功时 retcode=20000001 会携带 alt 跳转 URL。 + 用 requests.Session 跟踪 alt 的完整 SSO 重定向链来收集 Cookie。 + """ try: - # 检查二维码是否存在 qrcodes = session.get('weibo_qrcodes', {}) if qrid not in qrcodes: return jsonify({'status': 'expired'}) - - # 调用微博的轮询接口 - # 实际接口:https://login.sina.com.cn/sso/qrcode/check - check_api_url = 'https://login.sina.com.cn/sso/qrcode/check' + + # 如果之前已经成功处理过,直接返回缓存结果 + qr_info = qrcodes[qrid] + if qr_info.get('status') == 'success': + return jsonify({ + 'status': 'success', + 'weibo_uid': qr_info.get('weibo_uid'), + 'screen_name': qr_info.get('screen_name'), + }) + + # ---- 1. 调用 check 接口(JSONP 模式)---- + check_url = 'https://login.sina.com.cn/sso/qrcode/check' params = { 'entry': 'weibo', 'qrid': qrid, - 'callback': f'STK_{int(time.time() * 1000)}' + 'callback': f'STK_{int(time.time() * 1000)}', } - - # 添加浏览器请求头 - 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', - 'Connection': 'keep-alive' - } - - response = requests.get(check_api_url, params=params, headers=headers, timeout=10) - - print(f"[DEBUG] 检查状态 - qrid: {qrid}") - print(f"[DEBUG] 检查状态 - 响应状态码: {response.status_code}") - print(f"[DEBUG] 检查状态 - 响应内容: {response.text[:500]}") - - # 解析 JSONP 响应 - import re - match = re.search(r'\((.*)\)', response.text) - if match: - import json - data = json.loads(match.group(1)) - - retcode = data.get('retcode') - print(f"[DEBUG] 检查状态 - retcode: {retcode}, data: {data}") - - # 微博扫码状态码: - # 20000000: 等待扫码 - # 50050001: 已扫码,等待确认 - # 20000001: 确认成功 - # 50050002: 二维码过期 - # 50050004: 取消授权 - # 50114001: 未使用(等待扫码) - # 50114004: 该二维码已登录(可能是成功状态) - - if retcode == 20000000 or retcode == 50114001: - # 等待扫码 - return jsonify({'status': 'waiting'}) - elif retcode == 50050001: - # 已扫码,等待确认 - return jsonify({'status': 'scanned'}) - elif retcode == 20000001 or retcode == 50114004: - # 登录成功,获取跳转 URL - # 50114004 也表示已登录成功 - alt_url = data.get('alt') - - # 如果没有 alt 字段,尝试从 data 中获取 - if not alt_url and data.get('data'): - alt_url = data.get('data', {}).get('alt') - - print(f"[DEBUG] 登录成功 - retcode: {retcode}, alt_url: {alt_url}, full_data: {data}") - - # 如果没有 alt_url,尝试构造登录 URL - if not alt_url: - # 尝试使用 qrid 构造登录 URL - # 微博可能使用不同的 URL 格式 - possible_urls = [ - f"https://login.sina.com.cn/sso/login.php?entry=weibo&qrid={qrid}", - f"https://passport.weibo.com/sso/login?qrid={qrid}", - f"https://login.sina.com.cn/sso/qrcode/login?qrid={qrid}" - ] - - print(f"[DEBUG] 尝试构造登录 URL") - for url in possible_urls: - try: - print(f"[DEBUG] 尝试 URL: {url}") - test_response = requests.get(url, headers=headers, allow_redirects=False, timeout=5) - print(f"[DEBUG] 响应状态码: {test_response.status_code}") - if test_response.status_code in [200, 302, 301]: - alt_url = url - print(f"[DEBUG] 找到有效 URL: {alt_url}") - break - except Exception as e: - print(f"[DEBUG] URL 失败: {str(e)}") - continue - - if alt_url: - # 添加浏览器请求头 - 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': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', - 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', - 'Connection': 'keep-alive' - } - - print(f"[DEBUG] 访问跳转 URL: {alt_url}") - # 访问跳转 URL 获取 Cookie - cookie_response = requests.get(alt_url, headers=headers, allow_redirects=True, timeout=10) - cookies = cookie_response.cookies - - print(f"[DEBUG] 获取到的 Cookies: {dict(cookies)}") - - # 构建 Cookie 字符串 - cookie_str = '; '.join([f'{k}={v}' for k, v in cookies.items()]) - - if not cookie_str: - print("[ERROR] 未获取到任何 Cookie") - return jsonify({'status': 'error', 'error': '未获取到 Cookie'}) - - print(f"[DEBUG] Cookie 字符串长度: {len(cookie_str)}") - - # 获取用户信息 - # 可以通过 Cookie 访问微博 API 获取 uid - user_info_url = 'https://weibo.com/ajax/profile/info' - user_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': 'application/json, text/plain, */*', - 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8' - } - - print(f"[DEBUG] 请求用户信息: {user_info_url}") - user_response = requests.get(user_info_url, cookies=cookies, headers=user_headers, timeout=10) - - print(f"[DEBUG] 用户信息响应状态码: {user_response.status_code}") - print(f"[DEBUG] 用户信息响应内容: {user_response.text[:500]}") - - user_data = user_response.json() - - if user_data.get('ok') == 1: - user_info = user_data.get('data', {}).get('user', {}) - weibo_uid = user_info.get('idstr', '') - screen_name = user_info.get('screen_name', 'Weibo User') - - print(f"[DEBUG] 获取用户信息成功: uid={weibo_uid}, name={screen_name}") - - # 更新状态 - session['weibo_qrcodes'][qrid]['status'] = 'success' - session['weibo_qrcodes'][qrid]['cookie'] = cookie_str - session['weibo_qrcodes'][qrid]['weibo_uid'] = weibo_uid - session['weibo_qrcodes'][qrid]['screen_name'] = screen_name - session.modified = True - - return jsonify({ - 'status': 'success', - 'weibo_uid': weibo_uid, - 'screen_name': screen_name - }) - else: - print(f"[ERROR] 获取用户信息失败: {user_data}") - return jsonify({'status': 'error', 'error': '获取用户信息失败'}) - else: - print("[ERROR] 未获取到跳转 URL") - - return jsonify({'status': 'error', 'error': '获取登录信息失败'}) - elif retcode == 50050002: - return jsonify({'status': 'expired'}) - elif retcode == 50050004: - return jsonify({'status': 'cancelled'}) + + resp = requests.get(check_url, params=params, headers=WEIBO_HEADERS, timeout=10) + logger.info(f"qrcode/check status={resp.status_code} body={resp.text[:500]}") + + data = _parse_jsonp(resp.text) + if not data: + logger.error(f"无法解析 check 响应: {resp.text[:300]}") + return jsonify({'status': 'error', 'error': '解析响应失败'}) + + retcode = data.get('retcode') + logger.info(f"check retcode={retcode}") + + # 等待扫码(50114001 = "未使用") + if retcode == 50114001: + return jsonify({'status': 'waiting'}) + + # 已扫码,等待确认 + if retcode in (50050001, 50114002): + return jsonify({'status': 'scanned'}) + + # 二维码过期 + if retcode == 50050002: + return jsonify({'status': 'expired'}) + + # 取消授权 + if retcode == 50050004: + return jsonify({'status': 'cancelled'}) + + # 50114004 = 二维码已被消费(重复轮询),不带 alt + if retcode == 50114004: + if qr_info.get('status') == 'success': + return jsonify({ + 'status': 'success', + 'weibo_uid': qr_info.get('weibo_uid'), + 'screen_name': qr_info.get('screen_name'), + }) + return jsonify({'status': 'error', 'error': '二维码已失效,请重新生成'}) + + # ---- 2. 登录成功 ---- + # retcode=20000000 + data.alt 存在 = 扫码确认成功 + # retcode=20000001 = 旧版成功状态 + # alt 是一个 token(如 "ALT-xxx"),不是 URL,需要拼接成 SSO 登录 URL + alt_token = '' + nested = data.get('data') + if isinstance(nested, dict): + alt_token = nested.get('alt', '') + + if retcode == 20000000 and not alt_token: + # 20000000 无 alt = 正常的等待扫码状态 + return jsonify({'status': 'waiting'}) + + if not alt_token: + if retcode in (20000001, 50114003): + alt_token = data.get('alt', '') else: - # 未知状态码,记录日志 - print(f"[WARN] 未知的 retcode: {retcode}, msg: {data.get('msg')}") - return jsonify({'status': 'waiting'}) # 默认继续等待 - - print("[DEBUG] 未能解析响应") - return jsonify({'status': 'error', 'error': '检查状态失败'}) - + logger.warning(f"未知 retcode: {retcode}, data: {data}") + return jsonify({'status': 'waiting'}) + + if not alt_token: + return jsonify({'status': 'error', 'error': '微博未返回登录凭证,请重新扫码'}) + + logger.info(f"获取到 alt token: {alt_token}") + + # 将 alt token 拼接成完整的 SSO 登录 URL + alt_url = ( + f"https://login.sina.com.cn/sso/login.php" + f"?entry=weibo&returntype=TEXT&crossdomain=1&cdult=3" + f"&domain=weibo.com&alt={alt_token}&savestate=30" + f"&callback=STK_{int(time.time() * 1000)}" + ) + logger.info(f"构造 SSO URL: {alt_url}") + + # ---- 3. 执行 SSO 登录,收集 Cookie 和用户信息 ---- + cookie_str, uid, nick = _execute_sso_login(alt_url) + + if not cookie_str: + return jsonify({ + 'status': 'error', + 'error': 'Cookie 获取失败,请重新扫码', + }) + + screen_name = nick or f'用户{uid}' + + # 存储结果到 session(防止重复轮询时丢失) + session['weibo_qrcodes'][qrid]['status'] = 'success' + session['weibo_qrcodes'][qrid]['cookie'] = cookie_str + session['weibo_qrcodes'][qrid]['weibo_uid'] = uid + session['weibo_qrcodes'][qrid]['screen_name'] = screen_name + session.modified = True + logger.info(f"check 成功: qrid={qrid}, uid={uid}, cookie长度={len(cookie_str)}, session已写入") + + return jsonify({ + 'status': 'success', + 'weibo_uid': uid, + 'screen_name': screen_name, + }) + except Exception as e: - print(f"[ERROR] 检查二维码状态异常: {str(e)}") - import traceback - print(f"[ERROR] 堆栈跟踪:\n{traceback.format_exc()}") + logger.exception("检查二维码状态异常") return jsonify({'status': 'error', 'error': str(e)}) +def _execute_sso_login(sso_url): + """ + 执行微博 SSO 登录流程,收集 Cookie 并提取用户信息。 + + 流程: + 1. GET sso_url → 返回 JSONP,包含: + - uid, nick(用户信息,直接可用) + - crossDomainUrlList(跨域种 cookie 的 URL) + - Set-Cookie: SUB, SUBP, ALF 等 + 2. 逐个访问 crossDomainUrlList 中的 URL(种跨域 cookie) + 3. 汇总所有 cookie + + Returns: (cookie_str, uid, nick) or (None, None, None) + """ + sso_session = requests.Session() + sso_session.headers.update({ + 'User-Agent': WEIBO_HEADERS['User-Agent'], + 'Referer': 'https://weibo.com/', + 'Accept': '*/*', + 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', + }) + + try: + # Step 1: 访问 SSO 登录 URL + resp = sso_session.get(sso_url, allow_redirects=True, timeout=15) + logger.info(f"SSO login status={resp.status_code}, cookies={len(sso_session.cookies)}") + + # 解析 JSONP 响应 + sso_data = _parse_jsonp(resp.text) + uid = '' + nick = '' + cross_urls = [] + + if sso_data: + # 直接从 SSO 响应中提取用户信息 + uid = str(sso_data.get('uid', '')) + nick = sso_data.get('nick', '') + cross_urls = sso_data.get('crossDomainUrlList', []) + logger.info(f"SSO 响应: uid={uid}, nick={nick}, crossDomainUrls={len(cross_urls)}") + else: + logger.warning(f"无法解析 SSO 响应: {resp.text[:500]}") + + # Step 2: 逐个访问跨域 URL 种 cookie + for url in cross_urls: + if not isinstance(url, str) or not url.startswith('http'): + continue + try: + logger.debug(f"访问跨域 URL: {url[:120]}") + sso_session.get(url, allow_redirects=True, timeout=10) + except Exception as e: + logger.debug(f"跨域 URL 访问失败: {e}") + + # Step 3: 只提取 weibo.com 域名的 Cookie(签到 API 只需要这些) + weibo_com_cookies = {} + for cookie in sso_session.cookies: + if cookie.domain and 'weibo.com' in cookie.domain: + weibo_com_cookies[cookie.name] = cookie.value + + cookie_str = '; '.join(f'{k}={v}' for k, v in weibo_com_cookies.items()) + logger.info(f"weibo.com Cookie ({len(weibo_com_cookies)} 个): {list(weibo_com_cookies.keys())}") + + if not cookie_str or 'SUB' not in weibo_com_cookies: + logger.error(f"Cookie 不完整,缺少 SUB。获取到: {list(weibo_com_cookies.keys())}") + return None, None, None + + if not uid: + logger.warning("SSO 响应中没有 uid,尝试从 API 获取...") + uid, nick = _fetch_weibo_user_info(sso_session) + + if not uid: + logger.error("无法获取用户 uid") + return None, None, None + + return cookie_str, uid, nick + + except Exception as e: + logger.exception(f"SSO 登录流程失败: {e}") + return None, None, None + + +def _fetch_weibo_user_info(sso_session): + """用已登录的 session 获取当前用户 uid 和昵称(PC 端接口)。""" + try: + resp = sso_session.get( + 'https://weibo.com/ajax/profile/info', + headers={ + 'User-Agent': WEIBO_HEADERS['User-Agent'], + 'Referer': 'https://weibo.com/', + 'Accept': '*/*', + }, + timeout=10, + ) + data = resp.json() + if data.get('ok') == 1: + user = data.get('data', {}).get('user', {}) + uid = user.get('idstr', '') + screen_name = user.get('screen_name', f'用户{uid}') + if uid: + logger.info(f"weibo.com/ajax/profile/info: uid={uid}, name={screen_name}") + return uid, screen_name + except Exception as e: + logger.warning(f"weibo.com/ajax/profile/info 失败: {e}") + return None, None + + @app.route('/api/weibo/qrcode/add-account', methods=['POST']) @login_required def add_account_from_qrcode(): @@ -447,68 +550,79 @@ def add_account_from_qrcode(): data = request.json qrid = data.get('qrid') remark = data.get('remark', '') - - print(f"[DEBUG] 添加账号 - qrid: {qrid}") - - # 获取扫码结果 + qrcodes = session.get('weibo_qrcodes', {}) qr_info = qrcodes.get(qrid) - - print(f"[DEBUG] 添加账号 - qr_info: {qr_info}") - + + logger.info(f"add-account: qrid={qrid}, qrcodes keys={list(qrcodes.keys())}") + logger.info(f"add-account: qr_info={json.dumps(qr_info, default=str)[:500] if qr_info else None}") + if not qr_info or qr_info.get('status') != 'success': - print(f"[ERROR] 添加账号失败 - 二维码状态不正确: {qr_info.get('status') if qr_info else 'None'}") + logger.error(f"add-account 失败: qr_info={qr_info}, status={qr_info.get('status') if qr_info else 'N/A'}") return jsonify({'success': False, 'message': '二维码未完成授权'}), 400 - + cookie = qr_info.get('cookie') weibo_uid = qr_info.get('weibo_uid') - screen_name = qr_info.get('screen_name', 'Weibo User') - - print(f"[DEBUG] 添加账号 - uid: {weibo_uid}, name: {screen_name}, cookie_len: {len(cookie) if cookie else 0}") - + screen_name = qr_info.get('screen_name', '微博用户') + + if not cookie: + logger.error(f"add-account: cookie 为空! qr_info keys={list(qr_info.keys())}") + return jsonify({'success': False, 'message': 'Cookie 数据丢失,请重新扫码'}), 400 + if not remark: remark = f"{screen_name} (扫码添加)" - - # 添加账号到系统 - print(f"[DEBUG] 调用后端 API 添加账号: {API_BASE_URL}/api/v1/accounts") - response = requests.post( + + response = api_request( + 'POST', f'{API_BASE_URL}/api/v1/accounts', json={ 'weibo_user_id': weibo_uid, 'cookie': cookie, - 'remark': remark + 'remark': remark, }, - headers=get_headers(), - timeout=10 ) - - print(f"[DEBUG] 后端响应状态码: {response.status_code}") - print(f"[DEBUG] 后端响应内容: {response.text[:500]}") - + result = response.json() - - if response.status_code == 200 and result.get('success'): - # 清除已使用的二维码 + + if response.status_code in (200, 201) and result.get('success'): + account_data = result.get('data', {}) + account_id = account_data.get('id') + + # 扫码添加后自动触发 Cookie 验证,激活账号 + if account_id: + try: + verify_resp = api_request('POST', f'{API_BASE_URL}/api/v1/accounts/{account_id}/verify') + verify_data = verify_resp.json() + if verify_data.get('success') and verify_data.get('data', {}).get('cookie_valid'): + logger.info(f"扫码添加后自动验证成功: account_id={account_id}") + else: + logger.warning(f"扫码添加后自动验证失败: {verify_data}") + except Exception as e: + logger.warning(f"扫码添加后自动验证异常: {e}") + session['weibo_qrcodes'].pop(qrid, None) session.modified = True - - print(f"[DEBUG] 账号添加成功") return jsonify({ 'success': True, - 'message': 'Account added successfully', - 'account': result.get('data', {}) + 'message': '账号添加成功', + 'account': account_data, }) - else: - print(f"[ERROR] 后端返回失败: {result}") + elif response.status_code == 401: + logger.error(f"add-account 后端返回 401: {response.text[:500]}") return jsonify({ 'success': False, - 'message': result.get('message', 'Failed to add account') + 'message': '登录已过期,请重新登录后再试', + 'need_login': True, + }), 401 + else: + logger.error(f"add-account 后端返回失败: status={response.status_code}, body={response.text[:500]}") + return jsonify({ + 'success': False, + 'message': result.get('message', result.get('detail', '添加账号失败')), }), 400 - + except Exception as e: - print(f"[ERROR] 添加账号异常: {str(e)}") - import traceback - print(f"[ERROR] 堆栈跟踪:\n{traceback.format_exc()}") + logger.exception("添加账号异常") return jsonify({'success': False, 'message': str(e)}), 500 @app.route('/accounts/') @@ -516,36 +630,35 @@ def add_account_from_qrcode(): def account_detail(account_id): try: # 获取账号详情 - response = requests.get( - f'{API_BASE_URL}/api/v1/accounts/{account_id}', - headers=get_headers(), - timeout=10 - ) + response = api_request('GET', f'{API_BASE_URL}/api/v1/accounts/{account_id}') account_data = response.json() account = account_data.get('data') if account_data.get('success') else None # 获取任务列表 - tasks_response = requests.get( - f'{API_BASE_URL}/api/v1/accounts/{account_id}/tasks', - headers=get_headers(), - timeout=10 - ) + tasks_response = api_request('GET', f'{API_BASE_URL}/api/v1/accounts/{account_id}/tasks') tasks_data = tasks_response.json() tasks = tasks_data.get('data', []) if tasks_data.get('success') else [] # 获取签到日志 page = request.args.get('page', 1, type=int) - logs_response = requests.get( + logs_response = api_request( + 'GET', f'{API_BASE_URL}/api/v1/accounts/{account_id}/signin-logs', params={'page': page, 'size': 20}, - headers=get_headers(), - timeout=10 ) logs_data = logs_response.json() logs = logs_data.get('data', {}) if logs_data.get('success') else {} + # 确保 logs 有默认结构,避免模板报错 + if not isinstance(logs, dict): + logs = {} + logs.setdefault('items', []) + logs.setdefault('total', 0) + logs.setdefault('page', page) + logs.setdefault('size', 20) + logs.setdefault('total_pages', 0) if not account: - flash('Account not found', 'danger') + flash('账号不存在', 'danger') return redirect(url_for('dashboard')) return render_template( @@ -556,9 +669,46 @@ def account_detail(account_id): user=session.get('user') ) except requests.RequestException as e: - flash(f'Connection error: {str(e)}', 'danger') + flash(f'连接错误: {str(e)}', 'danger') return redirect(url_for('dashboard')) + +@app.route('/accounts//verify', methods=['POST']) +@login_required +def verify_account(account_id): + """验证账号 Cookie 有效性""" + try: + response = api_request('POST', f'{API_BASE_URL}/api/v1/accounts/{account_id}/verify') + data = response.json() + if data.get('success') and data.get('data', {}).get('cookie_valid'): + flash('Cookie 验证通过,账号已激活', 'success') + else: + flash(data.get('message', 'Cookie 无效或已过期'), 'warning') + except requests.RequestException as e: + flash(f'连接错误: {str(e)}', 'danger') + return redirect(url_for('account_detail', account_id=account_id)) + + +@app.route('/accounts//signin', methods=['POST']) +@login_required +def manual_signin(account_id): + """手动触发签到""" + try: + response = api_request('POST', f'{API_BASE_URL}/api/v1/accounts/{account_id}/signin') + data = response.json() + if data.get('success'): + result = data.get('data', {}) + signed = result.get('signed', 0) + already = result.get('already_signed', 0) + failed = result.get('failed', 0) + flash(f'签到完成: {signed} 成功, {already} 已签, {failed} 失败', 'success') + else: + flash(data.get('message', '签到失败'), 'danger') + except requests.RequestException as e: + flash(f'连接错误: {str(e)}', 'danger') + return redirect(url_for('account_detail', account_id=account_id)) + + @app.route('/accounts//edit', methods=['GET', 'POST']) @login_required def edit_account(account_id): @@ -571,57 +721,48 @@ def edit_account(account_id): if cookie: data['cookie'] = cookie - response = requests.put( + response = api_request( + 'PUT', f'{API_BASE_URL}/api/v1/accounts/{account_id}', json=data, - headers=get_headers(), - timeout=10 ) result = response.json() if response.status_code == 200 and result.get('success'): - flash('Account updated successfully!', 'success') + flash('账号更新成功', 'success') return redirect(url_for('account_detail', account_id=account_id)) else: - flash(result.get('message', 'Failed to update account'), 'danger') + flash(result.get('message', '更新账号失败'), 'danger') except requests.RequestException as e: - flash(f'Connection error: {str(e)}', 'danger') + flash(f'连接错误: {str(e)}', 'danger') try: - response = requests.get( - f'{API_BASE_URL}/api/v1/accounts/{account_id}', - headers=get_headers(), - timeout=10 - ) + response = api_request('GET', f'{API_BASE_URL}/api/v1/accounts/{account_id}') data = response.json() account = data.get('data') if data.get('success') else None if not account: - flash('Account not found', 'danger') + flash('账号不存在', 'danger') return redirect(url_for('dashboard')) return render_template('edit_account.html', account=account) except requests.RequestException as e: - flash(f'Connection error: {str(e)}', 'danger') + flash(f'连接错误: {str(e)}', 'danger') return redirect(url_for('dashboard')) @app.route('/accounts//delete', methods=['POST']) @login_required def delete_account(account_id): try: - response = requests.delete( - f'{API_BASE_URL}/api/v1/accounts/{account_id}', - headers=get_headers(), - timeout=10 - ) + response = api_request('DELETE', f'{API_BASE_URL}/api/v1/accounts/{account_id}') data = response.json() if response.status_code == 200 and data.get('success'): - flash('Account deleted successfully!', 'success') + flash('账号删除成功', 'success') else: - flash(data.get('message', 'Failed to delete account'), 'danger') + flash(data.get('message', '删除账号失败'), 'danger') except requests.RequestException as e: - flash(f'Connection error: {str(e)}', 'danger') + flash(f'连接错误: {str(e)}', 'danger') return redirect(url_for('dashboard')) @@ -632,21 +773,20 @@ def add_task(account_id): cron_expression = request.form.get('cron_expression') try: - response = requests.post( + response = api_request( + 'POST', f'{API_BASE_URL}/api/v1/accounts/{account_id}/tasks', json={'cron_expression': cron_expression}, - headers=get_headers(), - timeout=10 ) data = response.json() - if response.status_code == 200 and data.get('success'): - flash('Task created successfully!', 'success') + if response.status_code in (200, 201) and data.get('success'): + flash('任务创建成功', 'success') return redirect(url_for('account_detail', account_id=account_id)) else: - flash(data.get('message', 'Failed to create task'), 'danger') + flash(data.get('message', '创建任务失败'), 'danger') except requests.RequestException as e: - flash(f'Connection error: {str(e)}', 'danger') + flash(f'连接错误: {str(e)}', 'danger') return render_template('add_task.html', account_id=account_id) @@ -656,20 +796,19 @@ def toggle_task(task_id): is_enabled = request.form.get('is_enabled') == 'true' try: - response = requests.put( + response = api_request( + 'PUT', f'{API_BASE_URL}/api/v1/tasks/{task_id}', json={'is_enabled': not is_enabled}, - headers=get_headers(), - timeout=10 ) data = response.json() if response.status_code == 200 and data.get('success'): - flash('Task updated successfully!', 'success') + flash('任务更新成功', 'success') else: - flash(data.get('message', 'Failed to update task'), 'danger') + flash(data.get('message', '更新任务失败'), 'danger') except requests.RequestException as e: - flash(f'Connection error: {str(e)}', 'danger') + flash(f'连接错误: {str(e)}', 'danger') account_id = request.form.get('account_id') return redirect(url_for('account_detail', account_id=account_id)) @@ -680,22 +819,89 @@ def delete_task(task_id): account_id = request.form.get('account_id') try: - response = requests.delete( - f'{API_BASE_URL}/api/v1/tasks/{task_id}', - headers=get_headers(), - timeout=10 - ) + response = api_request('DELETE', f'{API_BASE_URL}/api/v1/tasks/{task_id}') data = response.json() if response.status_code == 200 and data.get('success'): - flash('Task deleted successfully!', 'success') + flash('任务删除成功', 'success') else: - flash(data.get('message', 'Failed to delete task'), 'danger') + flash(data.get('message', '删除任务失败'), 'danger') except requests.RequestException as e: - flash(f'Connection error: {str(e)}', 'danger') + flash(f'连接错误: {str(e)}', 'danger') return redirect(url_for('account_detail', account_id=account_id)) +@app.route('/api/batch/verify', methods=['POST']) +@login_required +def batch_verify(): + """批量验证所有账号的 Cookie 有效性""" + try: + response = api_request('GET', f'{API_BASE_URL}/api/v1/accounts') + data = response.json() + accounts = data.get('data', []) if data.get('success') else [] + + valid = invalid = errors = 0 + for account in accounts: + try: + resp = api_request('POST', f'{API_BASE_URL}/api/v1/accounts/{account["id"]}/verify') + result = resp.json() + if result.get('success') and result.get('data', {}).get('cookie_valid'): + valid += 1 + else: + invalid += 1 + except Exception: + errors += 1 + + return jsonify({ + 'success': True, + 'data': {'valid': valid, 'invalid': invalid, 'errors': errors, 'total': len(accounts)}, + }) + except Exception as e: + logger.exception("批量验证异常") + return jsonify({'success': False, 'message': str(e)}), 500 + + +@app.route('/api/batch/signin', methods=['POST']) +@login_required +def batch_signin(): + """批量签到所有正常状态的账号""" + try: + response = api_request('GET', f'{API_BASE_URL}/api/v1/accounts') + data = response.json() + accounts = data.get('data', []) if data.get('success') else [] + + total_signed = total_already = total_failed = 0 + processed = 0 + for account in accounts: + if account.get('status') != 'active': + continue + try: + resp = api_request('POST', f'{API_BASE_URL}/api/v1/accounts/{account["id"]}/signin') + result = resp.json() + if result.get('success'): + d = result.get('data', {}) + total_signed += d.get('signed', 0) + total_already += d.get('already_signed', 0) + total_failed += d.get('failed', 0) + processed += 1 + except Exception as e: + logger.warning(f"批量签到账号 {account['id']} 失败: {e}") + total_failed += 1 + + return jsonify({ + 'success': True, + 'data': { + 'total_accounts': processed, + 'total_signed': total_signed, + 'total_already': total_already, + 'total_failed': total_failed, + }, + }) + except Exception as e: + logger.exception("批量签到异常") + return jsonify({'success': False, 'message': str(e)}), 500 + + @app.errorhandler(404) def not_found(error): return render_template('404.html'), 404 @@ -705,4 +911,5 @@ def server_error(error): return render_template('500.html'), 500 if __name__ == '__main__': - app.run(debug=True, port=5000) + # use_reloader=False 避免 Windows 终端 QuickEdit 模式导致进程挂起 + app.run(debug=True, port=5000, use_reloader=False) diff --git a/frontend/flask_session/2029240f6d1128be89ddc32729463129 b/frontend/flask_session/2029240f6d1128be89ddc32729463129 index 7f5741f..ffb2cd9 100644 Binary files a/frontend/flask_session/2029240f6d1128be89ddc32729463129 and b/frontend/flask_session/2029240f6d1128be89ddc32729463129 differ diff --git a/frontend/flask_session/63ef42ce753fadbb10b138d04f18d64d b/frontend/flask_session/63ef42ce753fadbb10b138d04f18d64d new file mode 100644 index 0000000..40132eb Binary files /dev/null and b/frontend/flask_session/63ef42ce753fadbb10b138d04f18d64d differ diff --git a/frontend/flask_session/9954d94905e0926ef31c3ae1f3a81f9f b/frontend/flask_session/9954d94905e0926ef31c3ae1f3a81f9f index bb76c8e..b62de3e 100644 Binary files a/frontend/flask_session/9954d94905e0926ef31c3ae1f3a81f9f and b/frontend/flask_session/9954d94905e0926ef31c3ae1f3a81f9f differ diff --git a/frontend/templates/404.html b/frontend/templates/404.html index 0e818fb..22fede6 100644 --- a/frontend/templates/404.html +++ b/frontend/templates/404.html @@ -1,12 +1,12 @@ {% extends "base.html" %} -{% block title %}Page Not Found - Weibo-HotSign{% endblock %} +{% block title %}页面未找到 - 微博超话签到{% endblock %} {% block content %} -
-

404

-

Page Not Found

-

The page you're looking for doesn't exist.

- Go to Dashboard +
+
🔍
+

404

+

页面未找到

+ 返回控制台
{% endblock %} diff --git a/frontend/templates/500.html b/frontend/templates/500.html index 91efdf3..d5f1fb8 100644 --- a/frontend/templates/500.html +++ b/frontend/templates/500.html @@ -1,12 +1,12 @@ {% extends "base.html" %} -{% block title %}Server Error - Weibo-HotSign{% endblock %} +{% block title %}服务器错误 - 微博超话签到{% endblock %} {% block content %} -
-

500

-

Server Error

-

Something went wrong on our end.

- Go to Dashboard +
+
⚠️
+

500

+

服务器出了点问题,请稍后再试

+ 返回控制台
{% endblock %} diff --git a/frontend/templates/account_detail.html b/frontend/templates/account_detail.html index 7270512..d69bd84 100644 --- a/frontend/templates/account_detail.html +++ b/frontend/templates/account_detail.html @@ -1,170 +1,163 @@ {% extends "base.html" %} -{% block title %}Account Detail - Weibo-HotSign{% endblock %} +{% block title %}账号详情 - 微博超话签到{% endblock %} + +{% block extra_css %} + +{% endblock %} {% block content %} -
-
-

{{ account.weibo_user_id }}

-
- Edit -
- +
+
+
+

{{ account.remark or account.weibo_user_id }}

+
UID: {{ account.weibo_user_id }}
+
+
+ ✏️ 编辑 + +
-
+
-
Account Info
- +
📋 账号信息
+
- + - - - - - - - - - - - - + + +
Status状态 - {% if account.status == 'active' %} - Active - {% elif account.status == 'pending' %} - Pending - {% elif account.status == 'invalid_cookie' %} - Invalid Cookie - {% elif account.status == 'banned' %} - Banned + {% if account.status == 'active' %}正常 + {% elif account.status == 'pending' %}待验证 + {% elif account.status == 'invalid_cookie' %}Cookie 失效 + {% elif account.status == 'banned' %}已封禁 {% endif %}
Remark{{ account.remark or '-' }}
Created{{ account.created_at[:10] }}
Last Checked{{ account.last_checked_at[:10] if account.last_checked_at else '-' }}
备注{{ account.remark or '-' }}
添加时间{{ account.created_at[:10] }}
上次检查{{ account.last_checked_at[:10] if account.last_checked_at else '-' }}
-
-
Quick Actions
-
- + Add Task - Update Cookie +
⚡ 快捷操作
+
+
+ +
+
+ +
+ ⏰ 添加定时任务
-
Tasks
+
⏰ 定时任务
{% if tasks %} - - - - - - - - - - - {% for task in tasks %} - - - - - - - {% endfor %} - -
Cron ExpressionStatusCreatedActions
{{ task.cron_expression }} - {% if task.is_enabled %} - Enabled - {% else %} - Disabled - {% endif %} - {{ task.created_at[:10] }} -
- - - -
-
- - -
-
+ {% for task in tasks %} +
+
+ {{ task.cron_expression }} + {% if task.is_enabled %}已启用 + {% else %}已禁用{% endif %} +
+
+
+ + + +
+
+ + +
+
+
+ {% endfor %} {% else %} -

No tasks yet

+

暂无定时任务

{% endif %}
-
Signin Logs
- {% if logs.items %} - - - - - - - - - - - {% for log in logs.items %} - - - - - - - {% endfor %} - -
TopicStatusRewardTime
{{ log.topic_title or '-' }} - {% if log.status == 'success' %} - Success - {% elif log.status == 'failed_already_signed' %} - Already Signed - {% elif log.status == 'failed_network' %} - Network Error - {% elif log.status == 'failed_banned' %} - Banned - {% endif %} - - {% if log.reward_info %} - {{ log.reward_info.get('points', '-') }} pts - {% else %} - - - {% endif %} - {{ log.signed_at[:10] }}
- - {% if logs.total > logs.size %} -
diff --git a/frontend/templates/add_account.html b/frontend/templates/add_account.html index 2d1f080..aef38ef 100644 --- a/frontend/templates/add_account.html +++ b/frontend/templates/add_account.html @@ -1,444 +1,149 @@ {% extends "base.html" %} -{% block title %}Add Account - Weibo-HotSign{% endblock %} +{% block title %}添加账号 - 微博超话签到{% endblock %} {% block extra_css %} {% endblock %} {% block content %} -
-

Add Weibo Account

+
+
+

📱 添加微博账号

+ 返回 +
-
- - - -
- - -
-

如何获取微博 Cookie

- -
-
- 方法一:使用浏览器开发者工具 - 推荐 +
+ + - -
-

⚠️ 重要提示

-
    -
  • Cookie 包含你的登录凭证,请妥善保管
  • -
  • 不要在公共场合或不信任的网站输入 Cookie
  • -
  • Cookie 会被加密存储在数据库中
  • -
  • 如果 Cookie 失效,系统会提示你更新
  • -
  • 建议使用小号或测试账号,避免主账号风险
  • -
-
- -
- - +
等待扫码...
- - -
-

微博扫码登录

- -
-
- 扫码快速添加账号 - 推荐 -
-
-

- 使用微博网页版扫码登录,安全便捷地添加账号。 -

-

使用步骤:

-
    -
  1. 点击下方"生成二维码"按钮
  2. -
  3. 使用手机微博 APP 扫描二维码
  4. -
  5. 在手机上点击"确认登录"
  6. -
  7. 等待页面自动完成账号添加
  8. -
-
-
- -
- - - -
- -
-

💡 说明

-
    -
  • 使用微博网页版扫码登录接口,无需注册开放平台应用
  • -
  • 扫码后自动获取登录 Cookie
  • -
  • Cookie 会被加密存储在数据库中
  • -
  • 建议使用小号或测试账号,避免主账号风险
  • -
-
- -
-

⚠️ 注意事项

-
    -
  • 二维码有效期 3 分钟,过期后需重新生成
  • -
  • 扫码后请在手机上点击"确认登录"
  • -
  • 如果长时间未响应,请刷新页面重试
  • -
-
-
- - -
-

手动添加账号

-
- - -
- - - - 你的微博数字 ID,可以在个人主页 URL 中找到 - -
- -
- - - - 粘贴从浏览器获取的完整 Cookie 字符串。Cookie 将被加密存储。 - -
- -
- - - - 给这个账号添加备注,方便识别 - -
- -
- - Cancel -
-
- -
-

💡 快速提示

-

Weibo User ID 在哪里找?

-
    -
  1. 登录微博后,点击右上角头像进入个人主页
  2. -
  3. 查看浏览器地址栏,格式类似:https://weibo.com/u/1234567890
  4. -
  5. 最后的数字 1234567890 就是你的 User ID
  6. -
-
+
+

使用说明

+
    +
  1. 点击"生成二维码"
  2. +
  3. 打开手机微博 APP,扫描二维码
  4. +
  5. 在手机上点击"确认登录"
  6. +
  7. 等待自动完成,跳转到控制台
  8. +
{% endblock %} diff --git a/frontend/templates/add_task.html b/frontend/templates/add_task.html index e386e61..fd3e5f4 100644 --- a/frontend/templates/add_task.html +++ b/frontend/templates/add_task.html @@ -1,30 +1,365 @@ {% extends "base.html" %} -{% block title %}Add Task - Weibo-HotSign{% endblock %} +{% block title %}添加任务 - 微博超话签到{% endblock %} + +{% block extra_css %} + +{% endblock %} {% block content %} -
-

Add Signin Task

+
+
+

⏰ 添加签到任务

+ 返回 +
-
-
- - - - Standard cron format (minute hour day month weekday)
- Examples:
- • 0 9 * * * - Every day at 9:00 AM
- • 0 9 * * 1-5 - Weekdays at 9:00 AM
- • 0 9,21 * * * - Every day at 9:00 AM and 9:00 PM -
-
+
选择签到时间
+ +
+
+
+
+
+
+
+
+
:
+
+
+
+
+
+
+
+
+ + +
重复方式
+
+
每天
+
工作日
+
自定义
+
+ + + + +
+
生成的 Cron 表达式: 0 8 * * *
+
每天 08:00 执行签到
+
+ + + +
- - Cancel + + 取消
+ + {% endblock %} diff --git a/frontend/templates/base.html b/frontend/templates/base.html index 73d694d..b072d05 100644 --- a/frontend/templates/base.html +++ b/frontend/templates/base.html @@ -3,366 +3,215 @@ - {% block title %}Weibo-HotSign{% endblock %} + {% block title %}微博超话签到{% endblock %} {% block extra_css %}{% endblock %} @@ -371,12 +220,12 @@ {% if session.get('user') %}
diff --git a/frontend/templates/dashboard.html b/frontend/templates/dashboard.html index 8df14a6..bba1304 100644 --- a/frontend/templates/dashboard.html +++ b/frontend/templates/dashboard.html @@ -1,40 +1,297 @@ {% extends "base.html" %} -{% block title %}Dashboard - Weibo-HotSign{% endblock %} +{% block title %}控制台 - 微博超话签到{% endblock %} + +{% block extra_css %} + +{% endblock %} {% block content %} -
-

Weibo Accounts

- + Add Account +
+

👋 控制台

+
{% if accounts %} -
- {% for account in accounts %} -
-
{{ account.weibo_user_id }}
-
{{ account.remark or 'No remark' }}
-
+ +
+
+
📊
+
{{ accounts|length }}
+
账号总数
+
+
+
+
{{ accounts|selectattr('status','equalto','active')|list|length }}
+
正常运行
+
+
+
⚠️
+
{{ accounts|selectattr('status','equalto','pending')|list|length + accounts|selectattr('status','equalto','invalid_cookie')|list|length }}
+
需要关注
+
+
+ + +
+
+ 💡 系统每天 23:50 自动批量验证 Cookie,也可手动触发 +
+
+ + +
+
+ + +