接口跑通,基础功能全部实现
This commit is contained in:
@@ -15,5 +15,9 @@ JWT_EXPIRATION_HOURS=24
|
||||
# Cookie 加密密钥 (32字节)
|
||||
COOKIE_ENCRYPTION_KEY=dev-cookie-encryption-key-32b
|
||||
|
||||
# 微信小程序配置
|
||||
WX_APPID=
|
||||
WX_SECRET=
|
||||
|
||||
# 环境
|
||||
ENVIRONMENT=development
|
||||
|
||||
Binary file not shown.
@@ -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",
|
||||
)
|
||||
|
||||
Binary file not shown.
@@ -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),
|
||||
)
|
||||
|
||||
Binary file not shown.
@@ -28,11 +28,16 @@ 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")
|
||||
|
||||
Binary file not shown.
@@ -25,6 +25,10 @@ class SharedSettings(BaseSettings):
|
||||
# Cookie encryption
|
||||
COOKIE_ENCRYPTION_KEY: str = "change-me-in-production"
|
||||
|
||||
# 微信小程序
|
||||
WX_APPID: str = ""
|
||||
WX_SECRET: str = ""
|
||||
|
||||
# Environment
|
||||
ENVIRONMENT: str = "development"
|
||||
|
||||
|
||||
Binary file not shown.
@@ -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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = []
|
||||
"""
|
||||
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)
|
||||
|
||||
for topic in topics:
|
||||
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
|
||||
# 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
|
||||
task_id=task_id,
|
||||
)
|
||||
|
||||
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"✅ Successfully signed topic: {topic.title}")
|
||||
|
||||
# Write success log
|
||||
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
|
||||
error_message=None,
|
||||
)
|
||||
else:
|
||||
errors.append(f"Failed to sign topic: {topic.title}")
|
||||
succeeded = True
|
||||
break
|
||||
|
||||
# Write failure log
|
||||
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
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
async def _write_signin_log(
|
||||
|
||||
@@ -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}")
|
||||
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:
|
||||
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"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
|
||||
|
||||
async def get_browser_fingerprint(self) -> Dict[str, Any]:
|
||||
"""Get a browser fingerprint from the generator service"""
|
||||
# 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:
|
||||
# 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"]
|
||||
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"Failed to get browser fingerprint: {e}")
|
||||
return {}
|
||||
logger.error(f"Checkin error for {topic.title}: {e}")
|
||||
return False, None, str(e)
|
||||
|
||||
Binary file not shown.
13
debug_cookies.json
Normal file
13
debug_cookies.json
Normal file
@@ -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
|
||||
}
|
||||
276
debug_full_signin.py
Normal file
276
debug_full_signin.py
Normal file
@@ -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}")
|
||||
509
debug_qrcode_flow.py
Normal file
509
debug_qrcode_flow.py
Normal file
@@ -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)
|
||||
853
frontend/app.py
853
frontend/app.py
File diff suppressed because it is too large
Load Diff
Binary file not shown.
BIN
frontend/flask_session/63ef42ce753fadbb10b138d04f18d64d
Normal file
BIN
frontend/flask_session/63ef42ce753fadbb10b138d04f18d64d
Normal file
Binary file not shown.
Binary file not shown.
@@ -1,12 +1,12 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Page Not Found - Weibo-HotSign{% endblock %}
|
||||
{% block title %}页面未找到 - 微博超话签到{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div style="text-align: center; padding: 60px 20px;">
|
||||
<h1 style="font-size: 48px; color: #6366f1; margin-bottom: 20px;">404</h1>
|
||||
<p style="font-size: 24px; color: #333; margin-bottom: 20px;">Page Not Found</p>
|
||||
<p style="color: #999; margin-bottom: 30px;">The page you're looking for doesn't exist.</p>
|
||||
<a href="{{ url_for('dashboard') }}" class="btn btn-primary">Go to Dashboard</a>
|
||||
<div style="text-align:center; padding:80px 20px;">
|
||||
<div style="font-size:64px; margin-bottom:16px;">🔍</div>
|
||||
<h1 style="font-size:48px; font-weight:700; background:linear-gradient(135deg,#6366f1,#a855f7); -webkit-background-clip:text; -webkit-text-fill-color:transparent; margin-bottom:12px;">404</h1>
|
||||
<p style="font-size:18px; color:#64748b; margin-bottom:32px;">页面未找到</p>
|
||||
<a href="{{ url_for('dashboard') }}" class="btn btn-primary" style="padding:14px 32px; font-size:16px;">返回控制台</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Server Error - Weibo-HotSign{% endblock %}
|
||||
{% block title %}服务器错误 - 微博超话签到{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div style="text-align: center; padding: 60px 20px;">
|
||||
<h1 style="font-size: 48px; color: #dc3545; margin-bottom: 20px;">500</h1>
|
||||
<p style="font-size: 24px; color: #333; margin-bottom: 20px;">Server Error</p>
|
||||
<p style="color: #999; margin-bottom: 30px;">Something went wrong on our end.</p>
|
||||
<a href="{{ url_for('dashboard') }}" class="btn btn-primary">Go to Dashboard</a>
|
||||
<div style="text-align:center; padding:80px 20px;">
|
||||
<div style="font-size:64px; margin-bottom:16px;">⚠️</div>
|
||||
<h1 style="font-size:48px; font-weight:700; color:#ef4444; margin-bottom:12px;">500</h1>
|
||||
<p style="font-size:18px; color:#64748b; margin-bottom:32px;">服务器出了点问题,请稍后再试</p>
|
||||
<a href="{{ url_for('dashboard') }}" class="btn btn-primary" style="padding:14px 32px; font-size:16px;">返回控制台</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,170 +1,163 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Account Detail - Weibo-HotSign{% endblock %}
|
||||
{% block title %}账号详情 - 微博超话签到{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.detail-header {
|
||||
display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px;
|
||||
}
|
||||
.detail-header h1 { font-size: 24px; font-weight: 700; color: #1e293b; }
|
||||
.info-grid {
|
||||
display: grid; grid-template-columns: 1fr 1fr; gap: 18px; margin-bottom: 24px;
|
||||
}
|
||||
@media (max-width: 768px) { .info-grid { grid-template-columns: 1fr; } }
|
||||
.info-table td { padding: 12px 0; border-bottom: 1px solid #f1f5f9; font-size: 14px; }
|
||||
.info-table td:first-child { font-weight: 600; color: #64748b; width: 30%; }
|
||||
.action-btn {
|
||||
width: 100%; padding: 12px; border-radius: 14px; border: none;
|
||||
font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.2s;
|
||||
text-align: center; text-decoration: none; display: block;
|
||||
}
|
||||
.action-btn-primary {
|
||||
background: linear-gradient(135deg, #6366f1, #818cf8); color: white;
|
||||
box-shadow: 0 2px 8px rgba(99,102,241,0.25);
|
||||
}
|
||||
.action-btn-primary:hover { box-shadow: 0 4px 16px rgba(99,102,241,0.35); transform: translateY(-1px); }
|
||||
.action-btn-secondary { background: #f1f5f9; color: #475569; }
|
||||
.action-btn-secondary:hover { background: #e2e8f0; }
|
||||
.task-row {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 14px 0; border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
.task-row:last-child { border-bottom: none; }
|
||||
.task-cron {
|
||||
font-family: 'SF Mono', Monaco, monospace; background: #eef2ff; color: #6366f1;
|
||||
padding: 4px 12px; border-radius: 10px; font-size: 13px; font-weight: 600;
|
||||
}
|
||||
.task-actions { display: flex; gap: 8px; }
|
||||
.task-actions .btn { padding: 6px 14px; font-size: 12px; border-radius: 10px; }
|
||||
.log-row {
|
||||
display: grid; grid-template-columns: 1fr auto auto auto; gap: 16px;
|
||||
align-items: center; padding: 12px 0; border-bottom: 1px solid #f1f5f9; font-size: 14px;
|
||||
}
|
||||
.log-row:last-child { border-bottom: none; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div style="max-width: 1000px; margin: 0 auto;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 30px;">
|
||||
<h1>{{ account.weibo_user_id }}</h1>
|
||||
<div style="max-width: 960px; margin: 0 auto;">
|
||||
<div class="detail-header">
|
||||
<div>
|
||||
<h1>{{ account.remark or account.weibo_user_id }}</h1>
|
||||
<div style="color:#94a3b8; font-size:14px; margin-top:4px;">UID: {{ account.weibo_user_id }}</div>
|
||||
</div>
|
||||
<div style="display:flex; gap:10px;">
|
||||
<a href="{{ url_for('edit_account', account_id=account.id) }}" class="btn btn-secondary">Edit</a>
|
||||
<form method="POST" action="{{ url_for('delete_account', account_id=account.id) }}" style="display: inline;" onsubmit="return confirm('Are you sure?');">
|
||||
<button type="submit" class="btn btn-danger">Delete</button>
|
||||
<a href="{{ url_for('edit_account', account_id=account.id) }}" class="btn btn-secondary">✏️ 编辑</a>
|
||||
<form method="POST" action="{{ url_for('delete_account', account_id=account.id) }}" style="display:inline;" onsubmit="return confirm('确定要删除此账号吗?');">
|
||||
<button type="submit" class="btn btn-danger">删除</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 30px;">
|
||||
<div class="info-grid">
|
||||
<div class="card">
|
||||
<div class="card-header">Account Info</div>
|
||||
<table>
|
||||
<div class="card-header">📋 账号信息</div>
|
||||
<table class="info-table" style="width:100%;">
|
||||
<tr>
|
||||
<td style="font-weight: 500; width: 30%;">Status</td>
|
||||
<td>状态</td>
|
||||
<td>
|
||||
{% if account.status == 'active' %}
|
||||
<span class="badge badge-success">Active</span>
|
||||
{% elif account.status == 'pending' %}
|
||||
<span class="badge badge-warning">Pending</span>
|
||||
{% elif account.status == 'invalid_cookie' %}
|
||||
<span class="badge badge-danger">Invalid Cookie</span>
|
||||
{% elif account.status == 'banned' %}
|
||||
<span class="badge badge-danger">Banned</span>
|
||||
{% if account.status == 'active' %}<span class="badge badge-success">正常</span>
|
||||
{% elif account.status == 'pending' %}<span class="badge badge-warning">待验证</span>
|
||||
{% elif account.status == 'invalid_cookie' %}<span class="badge badge-danger">Cookie 失效</span>
|
||||
{% elif account.status == 'banned' %}<span class="badge badge-danger">已封禁</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-weight: 500;">Remark</td>
|
||||
<td>{{ account.remark or '-' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-weight: 500;">Created</td>
|
||||
<td>{{ account.created_at[:10] }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-weight: 500;">Last Checked</td>
|
||||
<td>{{ account.last_checked_at[:10] if account.last_checked_at else '-' }}</td>
|
||||
</tr>
|
||||
<tr><td>备注</td><td>{{ account.remark or '-' }}</td></tr>
|
||||
<tr><td>添加时间</td><td>{{ account.created_at[:10] }}</td></tr>
|
||||
<tr><td>上次检查</td><td>{{ account.last_checked_at[:10] if account.last_checked_at else '-' }}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">Quick Actions</div>
|
||||
<div class="card-header">⚡ 快捷操作</div>
|
||||
<div style="display:flex; flex-direction:column; gap:10px;">
|
||||
<a href="{{ url_for('add_task', account_id=account.id) }}" class="btn btn-primary" style="text-align: center;">+ Add Task</a>
|
||||
<a href="{{ url_for('edit_account', account_id=account.id) }}" class="btn btn-secondary" style="text-align: center;">Update Cookie</a>
|
||||
<form method="POST" action="{{ url_for('verify_account', account_id=account.id) }}">
|
||||
<button type="submit" class="action-btn action-btn-secondary">🔍 验证 Cookie</button>
|
||||
</form>
|
||||
<form method="POST" action="{{ url_for('manual_signin', account_id=account.id) }}" onsubmit="this.querySelector('button').disabled=true; this.querySelector('button').textContent='⏳ 签到中...';">
|
||||
<button type="submit" class="action-btn action-btn-primary">🚀 立即签到</button>
|
||||
</form>
|
||||
<a href="{{ url_for('add_task', account_id=account.id) }}" class="action-btn action-btn-secondary">⏰ 添加定时任务</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">Tasks</div>
|
||||
<div class="card-header">⏰ 定时任务</div>
|
||||
{% if tasks %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Cron Expression</th>
|
||||
<th>Status</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for task in tasks %}
|
||||
<tr>
|
||||
<td>{{ task.cron_expression }}</td>
|
||||
<td>
|
||||
{% if task.is_enabled %}
|
||||
<span class="badge badge-success">Enabled</span>
|
||||
{% else %}
|
||||
<span class="badge badge-warning">Disabled</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ task.created_at[:10] }}</td>
|
||||
<td>
|
||||
<div class="task-row">
|
||||
<div style="display:flex; align-items:center; gap:12px;">
|
||||
<span class="task-cron">{{ task.cron_expression }}</span>
|
||||
{% if task.is_enabled %}<span class="badge badge-success">已启用</span>
|
||||
{% else %}<span class="badge badge-warning">已禁用</span>{% endif %}
|
||||
</div>
|
||||
<div class="task-actions">
|
||||
<form method="POST" action="{{ url_for('toggle_task', task_id=task.id) }}" style="display:inline;">
|
||||
<input type="hidden" name="account_id" value="{{ account.id }}">
|
||||
<input type="hidden" name="is_enabled" value="{{ task.is_enabled|lower }}">
|
||||
<button type="submit" class="btn btn-secondary" style="padding: 6px 12px; font-size: 12px;">
|
||||
{% if task.is_enabled %}Disable{% else %}Enable{% endif %}
|
||||
</button>
|
||||
<button type="submit" class="btn btn-secondary">{% if task.is_enabled %}禁用{% else %}启用{% endif %}</button>
|
||||
</form>
|
||||
<form method="POST" action="{{ url_for('delete_task', task_id=task.id) }}" style="display: inline;" onsubmit="return confirm('Are you sure?');">
|
||||
<form method="POST" action="{{ url_for('delete_task', task_id=task.id) }}" style="display:inline;" onsubmit="return confirm('确定要删除此任务吗?');">
|
||||
<input type="hidden" name="account_id" value="{{ account.id }}">
|
||||
<button type="submit" class="btn btn-danger" style="padding: 6px 12px; font-size: 12px;">Delete</button>
|
||||
<button type="submit" class="btn btn-danger">删除</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p style="color: #999; text-align: center; padding: 20px;">No tasks yet</p>
|
||||
<p style="color:#94a3b8; text-align:center; padding:24px; font-size:14px;">暂无定时任务</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">Signin Logs</div>
|
||||
{% if logs.items %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Topic</th>
|
||||
<th>Status</th>
|
||||
<th>Reward</th>
|
||||
<th>Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for log in logs.items %}
|
||||
<tr>
|
||||
<td>{{ log.topic_title or '-' }}</td>
|
||||
<td>
|
||||
{% if log.status == 'success' %}
|
||||
<span class="badge badge-success">Success</span>
|
||||
{% elif log.status == 'failed_already_signed' %}
|
||||
<span class="badge badge-info">Already Signed</span>
|
||||
{% elif log.status == 'failed_network' %}
|
||||
<span class="badge badge-warning">Network Error</span>
|
||||
{% elif log.status == 'failed_banned' %}
|
||||
<span class="badge badge-danger">Banned</span>
|
||||
<div class="card-header">📝 签到记录</div>
|
||||
{% if logs['items'] %}
|
||||
{% for log in logs['items'] %}
|
||||
<div class="log-row">
|
||||
<div style="font-weight:500; color:#1e293b;">{{ log.topic_title or '-' }}</div>
|
||||
<div>
|
||||
{% if log.status == 'success' %}<span class="badge badge-success">签到成功</span>
|
||||
{% elif log.status == 'failed_already_signed' %}<span class="badge badge-info">今日已签</span>
|
||||
{% elif log.status == 'failed_network' %}<span class="badge badge-warning">网络错误</span>
|
||||
{% elif log.status == 'failed_banned' %}<span class="badge badge-danger">已封禁</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if log.reward_info %}
|
||||
{{ log.reward_info.get('points', '-') }} pts
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ log.signed_at[:10] }}</td>
|
||||
</tr>
|
||||
</div>
|
||||
<div style="font-size:13px; color:#64748b;">
|
||||
{% if log.reward_info %}{{ log.reward_info.get('points', '-') }} 经验{% else %}-{% endif %}
|
||||
</div>
|
||||
<div style="color:#94a3b8; font-size:13px;">{{ log.signed_at[:10] }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% if logs.total > logs.size %}
|
||||
{% if logs['total'] > logs['size'] %}
|
||||
<div class="pagination">
|
||||
{% if logs.page > 1 %}
|
||||
<a href="?page=1">First</a>
|
||||
<a href="?page={{ logs.page - 1 }}">Previous</a>
|
||||
{% endif %}
|
||||
|
||||
{% for p in range(max(1, logs.page - 2), min(logs.total // logs.size + 2, logs.page + 3)) %}
|
||||
{% if p == logs.page %}
|
||||
<span class="active">{{ p }}</span>
|
||||
{% else %}
|
||||
<a href="?page={{ p }}">{{ p }}</a>
|
||||
{% if logs['page'] > 1 %}
|
||||
<a href="?page=1">首页</a>
|
||||
<a href="?page={{ logs['page'] - 1 }}">上一页</a>
|
||||
{% endif %}
|
||||
{% for p in range(max(1, logs['page'] - 2), min(logs['total'] // logs['size'] + 2, logs['page'] + 3)) %}
|
||||
{% if p == logs['page'] %}<span class="active">{{ p }}</span>
|
||||
{% else %}<a href="?page={{ p }}">{{ p }}</a>{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if logs.page < logs.total // logs.size + 1 %}
|
||||
<a href="?page={{ logs.page + 1 }}">Next</a>
|
||||
<a href="?page={{ logs.total // logs.size + 1 }}">Last</a>
|
||||
{% if logs['page'] < logs['total'] // logs['size'] + 1 %}
|
||||
<a href="?page={{ logs['page'] + 1 }}">下一页</a>
|
||||
<a href="?page={{ logs['total'] // logs['size'] + 1 }}">末页</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p style="color: #999; text-align: center; padding: 20px;">No signin logs yet</p>
|
||||
<p style="color:#94a3b8; text-align:center; padding:24px; font-size:14px;">暂无签到记录</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,444 +1,149 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Add Account - Weibo-HotSign{% endblock %}
|
||||
{% block title %}添加账号 - 微博超话签到{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.tab-container {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 2px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 10px 20px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 16px;
|
||||
color: #666;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: #6366f1;
|
||||
border-bottom: 2px solid #6366f1;
|
||||
margin-bottom: -2px;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.help-text {
|
||||
background: #f8f9fa;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
margin-top: 20px;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.help-text h4 {
|
||||
margin-top: 0;
|
||||
color: #333;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.help-text ol, .help-text ul {
|
||||
margin: 10px 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.help-text li {
|
||||
margin: 8px 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.help-text code {
|
||||
background: #e9ecef;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.method-card {
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 15px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.method-card:hover {
|
||||
border-color: #6366f1;
|
||||
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.1);
|
||||
}
|
||||
|
||||
.method-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.method-badge {
|
||||
.qr-wrapper { text-align: center; padding: 40px 20px; }
|
||||
.qr-box {
|
||||
background: #f8fafc;
|
||||
padding: 28px;
|
||||
border-radius: 24px;
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
margin-left: 10px;
|
||||
border: 1.5px solid #e2e8f0;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.badge-recommended {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.warning-box {
|
||||
background: #fff3cd;
|
||||
border-left: 4px solid #ffc107;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.warning-box h4 {
|
||||
color: #856404;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: #f8f9fa;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
font-size: 13px;
|
||||
.qr-box img { width: 220px; height: 220px; display: block; margin: 0 auto 14px; border-radius: 12px; }
|
||||
#qr-timer { color: #94a3b8; font-size: 14px; }
|
||||
#qr-status { margin-top: 18px; font-size: 16px; min-height: 24px; font-weight: 500; }
|
||||
.status-waiting { color: #64748b; }
|
||||
.status-scanned { color: #f59e0b; }
|
||||
.status-success { color: #10b981; }
|
||||
.status-error { color: #ef4444; }
|
||||
.info-box {
|
||||
background: #f8fafc; border-radius: 16px; padding: 18px 22px; margin-top: 24px;
|
||||
font-size: 14px; color: #64748b; text-align: left; max-width: 480px;
|
||||
margin-left: auto; margin-right: auto; border: 1px solid #e2e8f0;
|
||||
}
|
||||
.info-box li { margin: 6px 0; line-height: 1.7; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div style="max-width: 800px; margin: 0 auto;">
|
||||
<h1 style="margin-bottom: 30px;">Add Weibo Account</h1>
|
||||
<div style="max-width: 560px; margin: 0 auto;">
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:24px;">
|
||||
<h1 style="font-size:24px; font-weight:700; color:#1e293b;">📱 添加微博账号</h1>
|
||||
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary">返回</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="tab-container">
|
||||
<button class="tab active" onclick="switchTab('guide')">获取教程</button>
|
||||
<button class="tab" onclick="switchTab('qrcode')">扫码添加</button>
|
||||
<button class="tab" onclick="switchTab('manual')">手动添加</button>
|
||||
</div>
|
||||
|
||||
<!-- 获取教程 Tab -->
|
||||
<div id="guide-tab" class="tab-content active">
|
||||
<h3 style="margin-bottom: 20px;">如何获取微博 Cookie</h3>
|
||||
|
||||
<div class="method-card">
|
||||
<div class="method-title">
|
||||
方法一:使用浏览器开发者工具
|
||||
<span class="method-badge badge-recommended">推荐</span>
|
||||
</div>
|
||||
<div class="help-text">
|
||||
<ol>
|
||||
<li>在浏览器中打开 <a href="https://weibo.com" target="_blank">https://weibo.com</a> 并登录</li>
|
||||
<li>按 <code>F12</code> 打开开发者工具</li>
|
||||
<li>切换到 <code>Network</code> (网络) 标签</li>
|
||||
<li>刷新页面 (<code>F5</code>)</li>
|
||||
<li>在请求列表中点击任意一个请求(通常是第一个)</li>
|
||||
<li>在右侧找到 <code>Request Headers</code> (请求头)</li>
|
||||
<li>找到 <code>Cookie:</code> 字段,复制整行内容</li>
|
||||
<li>切换到"添加账号"标签页,粘贴 Cookie</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="warning-box">
|
||||
<h4>⚠️ 重要提示</h4>
|
||||
<ul>
|
||||
<li>Cookie 包含你的登录凭证,请妥善保管</li>
|
||||
<li>不要在公共场合或不信任的网站输入 Cookie</li>
|
||||
<li>Cookie 会被加密存储在数据库中</li>
|
||||
<li>如果 Cookie 失效,系统会提示你更新</li>
|
||||
<li>建议使用小号或测试账号,避免主账号风险</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; margin-top: 30px;">
|
||||
<button class="btn btn-primary" onclick="switchTab('qrcode')" style="margin-right: 10px;">
|
||||
使用扫码添加 →
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="switchTab('manual')">
|
||||
手动添加账号 →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 扫码添加 Tab -->
|
||||
<div id="qrcode-tab" class="tab-content">
|
||||
<h3 style="margin-bottom: 20px;">微博扫码登录</h3>
|
||||
|
||||
<div class="method-card">
|
||||
<div class="method-title">
|
||||
扫码快速添加账号
|
||||
<span class="method-badge badge-recommended">推荐</span>
|
||||
</div>
|
||||
<div class="help-text">
|
||||
<p style="font-size: 16px; margin-bottom: 15px;">
|
||||
使用微博网页版扫码登录,安全便捷地添加账号。
|
||||
</p>
|
||||
<h4>使用步骤:</h4>
|
||||
<ol>
|
||||
<li>点击下方"生成二维码"按钮</li>
|
||||
<li>使用手机微博 APP 扫描二维码</li>
|
||||
<li>在手机上点击"确认登录"</li>
|
||||
<li>等待页面自动完成账号添加</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="qr-container" style="text-align: center; margin: 40px 0;">
|
||||
<button id="generate-qr-btn" class="btn btn-primary" style="font-size: 18px; padding: 15px 40px;">
|
||||
<div class="qr-wrapper">
|
||||
<button id="generate-qr-btn" class="btn btn-primary" style="font-size:18px; padding:16px 40px; border-radius:18px;">
|
||||
📱 生成二维码
|
||||
</button>
|
||||
|
||||
<div id="qr-display" style="display: none; margin-top: 30px;">
|
||||
<div style="background: white; padding: 20px; border-radius: 8px; display: inline-block; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
|
||||
<img id="qr-image" src="" alt="QR Code" style="width: 256px; height: 256px; margin-bottom: 15px;">
|
||||
<p style="color: #666; margin: 0;">请使用微博 APP 扫描</p>
|
||||
<p id="qr-timer" style="color: #999; font-size: 14px; margin-top: 10px;">有效期: 3:00</p>
|
||||
<div id="qr-display" style="display:none;">
|
||||
<div class="qr-box">
|
||||
<img id="qr-image" src="" alt="QR Code">
|
||||
<p style="color:#64748b; margin:0 0 4px;">请使用微博 APP 扫描</p>
|
||||
<p id="qr-timer">有效期: 3:00</p>
|
||||
</div>
|
||||
<div id="qr-status" style="margin-top: 20px; font-size: 16px; color: #666;">
|
||||
等待扫码...
|
||||
<div id="qr-status" class="status-waiting">等待扫码...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-text">
|
||||
<h4>💡 说明</h4>
|
||||
<ul>
|
||||
<li>使用微博网页版扫码登录接口,无需注册开放平台应用</li>
|
||||
<li>扫码后自动获取登录 Cookie</li>
|
||||
<li>Cookie 会被加密存储在数据库中</li>
|
||||
<li>建议使用小号或测试账号,避免主账号风险</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="warning-box">
|
||||
<h4>⚠️ 注意事项</h4>
|
||||
<ul>
|
||||
<li>二维码有效期 3 分钟,过期后需重新生成</li>
|
||||
<li>扫码后请在手机上点击"确认登录"</li>
|
||||
<li>如果长时间未响应,请刷新页面重试</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 添加账号 Tab -->
|
||||
<div id="manual-tab" class="tab-content">
|
||||
<h3 style="margin-bottom: 20px;">手动添加账号</h3>
|
||||
<form method="POST">
|
||||
<input type="hidden" name="login_method" value="manual">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="weibo_user_id">Weibo User ID</label>
|
||||
<input type="text" id="weibo_user_id" name="weibo_user_id" required placeholder="e.g., 123456789">
|
||||
<small style="color: #999; display: block; margin-top: 8px;">
|
||||
你的微博数字 ID,可以在个人主页 URL 中找到
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="cookie">Cookie</label>
|
||||
<textarea id="cookie" name="cookie" required placeholder="Paste your Weibo cookie here" style="min-height: 150px;"></textarea>
|
||||
<small style="color: #999; display: block; margin-top: 8px;">
|
||||
粘贴从浏览器获取的完整 Cookie 字符串。Cookie 将被加密存储。
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="remark">Remark (Optional)</label>
|
||||
<input type="text" id="remark" name="remark" placeholder="e.g., My main account">
|
||||
<small style="color: #999; display: block; margin-top: 8px;">
|
||||
给这个账号添加备注,方便识别
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="btn-group">
|
||||
<button type="submit" class="btn btn-primary">Add Account</button>
|
||||
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="help-text" style="margin-top: 30px;">
|
||||
<h4>💡 快速提示</h4>
|
||||
<p><strong>Weibo User ID 在哪里找?</strong></p>
|
||||
<ol>
|
||||
<li>登录微博后,点击右上角头像进入个人主页</li>
|
||||
<li>查看浏览器地址栏,格式类似:<code>https://weibo.com/u/1234567890</code></li>
|
||||
<li>最后的数字 <code>1234567890</code> 就是你的 User ID</li>
|
||||
<div class="info-box">
|
||||
<p style="margin:0 0 8px; font-weight:600; color:#334155;">使用说明</p>
|
||||
<ol style="margin:0; padding-left:20px;">
|
||||
<li>点击"生成二维码"</li>
|
||||
<li>打开手机微博 APP,扫描二维码</li>
|
||||
<li>在手机上点击"确认登录"</li>
|
||||
<li>等待自动完成,跳转到控制台</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function switchTab(tabName) {
|
||||
// 更新 tab 按钮状态
|
||||
document.querySelectorAll('.tab').forEach(tab => {
|
||||
tab.classList.remove('active');
|
||||
});
|
||||
event.target.classList.add('active');
|
||||
let pollInterval = null, timerInterval = null, currentQrid = null, timeLeft = 180, isProcessing = false;
|
||||
|
||||
// 更新 tab 内容显示
|
||||
document.querySelectorAll('.tab-content').forEach(content => {
|
||||
content.classList.remove('active');
|
||||
});
|
||||
document.getElementById(tabName + '-tab').classList.add('active');
|
||||
}
|
||||
|
||||
// 微博扫码登录相关代码
|
||||
let pollInterval = null;
|
||||
let timerInterval = null;
|
||||
let currentQrid = null;
|
||||
let timeLeft = 180; // 3分钟
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const generateBtn = document.getElementById('generate-qr-btn');
|
||||
if (generateBtn) {
|
||||
generateBtn.addEventListener('click', async function() {
|
||||
document.getElementById('generate-qr-btn').addEventListener('click', async function() {
|
||||
try {
|
||||
// 生成二维码
|
||||
const response = await fetch('/api/weibo/qrcode/generate', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.success) {
|
||||
alert('生成二维码失败: ' + (data.error || '未知错误'));
|
||||
return;
|
||||
}
|
||||
|
||||
const resp = await fetch('/api/weibo/qrcode/generate', {method:'POST', headers:{'Content-Type':'application/json'}});
|
||||
const data = await resp.json();
|
||||
if (!data.success) { alert('生成二维码失败: ' + (data.error || '未知错误')); return; }
|
||||
currentQrid = data.qrid;
|
||||
const qrImage = data.qr_image;
|
||||
|
||||
// 显示二维码
|
||||
document.getElementById('qr-image').src = qrImage;
|
||||
document.getElementById('qr-image').src = data.qr_image;
|
||||
document.getElementById('generate-qr-btn').style.display = 'none';
|
||||
document.getElementById('qr-display').style.display = 'block';
|
||||
document.getElementById('qr-status').textContent = '等待扫码...';
|
||||
|
||||
// 开始倒计时
|
||||
document.getElementById('qr-status').className = 'status-waiting';
|
||||
timeLeft = 180;
|
||||
updateTimer();
|
||||
timerInterval = setInterval(updateTimer, 1000);
|
||||
|
||||
// 开始轮询状态
|
||||
pollInterval = setInterval(checkQRStatus, 2000);
|
||||
|
||||
} catch (error) {
|
||||
alert('生成二维码失败: ' + error.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch(e) { alert('生成二维码失败: ' + e.message); }
|
||||
});
|
||||
|
||||
function updateTimer() {
|
||||
if (timeLeft <= 0) {
|
||||
clearInterval(timerInterval);
|
||||
clearInterval(pollInterval);
|
||||
document.getElementById('qr-status').innerHTML = '<span style="color: #dc3545;">二维码已过期,请重新生成</span>';
|
||||
clearInterval(timerInterval); clearInterval(pollInterval);
|
||||
document.getElementById('qr-status').textContent = '二维码已过期,请重新生成';
|
||||
document.getElementById('qr-status').className = 'status-error';
|
||||
document.getElementById('generate-qr-btn').style.display = 'inline-block';
|
||||
document.getElementById('qr-display').style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const minutes = Math.floor(timeLeft / 60);
|
||||
const seconds = timeLeft % 60;
|
||||
document.getElementById('qr-timer').textContent =
|
||||
`有效期: ${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
const m = Math.floor(timeLeft / 60), s = timeLeft % 60;
|
||||
document.getElementById('qr-timer').textContent = `有效期: ${m}:${s.toString().padStart(2,'0')}`;
|
||||
timeLeft--;
|
||||
}
|
||||
|
||||
async function checkQRStatus() {
|
||||
if (isProcessing) return;
|
||||
isProcessing = true;
|
||||
try {
|
||||
const response = await fetch(`/api/weibo/qrcode/check/${currentQrid}`);
|
||||
const data = await response.json();
|
||||
|
||||
const resp = await fetch(`/api/weibo/qrcode/check/${currentQrid}`);
|
||||
const data = await resp.json();
|
||||
if (data.status === 'waiting') {
|
||||
document.getElementById('qr-status').textContent = '等待扫码...';
|
||||
document.getElementById('qr-status').className = 'status-waiting';
|
||||
} else if (data.status === 'scanned') {
|
||||
document.getElementById('qr-status').innerHTML =
|
||||
'<span style="color: #ffc107;">✓ 已扫码,请在手机上确认登录</span>';
|
||||
document.getElementById('qr-status').textContent = '✓ 已扫码,请在手机上确认登录';
|
||||
document.getElementById('qr-status').className = 'status-scanned';
|
||||
} else if (data.status === 'success') {
|
||||
// 扫码成功
|
||||
clearInterval(pollInterval);
|
||||
clearInterval(timerInterval);
|
||||
|
||||
document.getElementById('qr-status').innerHTML =
|
||||
'<span style="color: #28a745;">✓ 登录成功!正在添加账号...</span>';
|
||||
|
||||
// 添加账号
|
||||
const addResponse = await fetch('/api/weibo/qrcode/add-account', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
clearInterval(pollInterval); pollInterval = null; clearInterval(timerInterval);
|
||||
document.getElementById('qr-status').textContent = '✓ 登录成功,正在添加账号...';
|
||||
document.getElementById('qr-status').className = 'status-success';
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
const addResp = await fetch('/api/weibo/qrcode/add-account', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({qrid: currentQrid})
|
||||
});
|
||||
|
||||
const addResult = await addResponse.json();
|
||||
|
||||
const addResult = await addResp.json();
|
||||
if (addResult.success) {
|
||||
document.getElementById('qr-status').innerHTML =
|
||||
'<span style="color: #28a745;">✓ 账号添加成功!正在跳转...</span>';
|
||||
setTimeout(() => {
|
||||
window.location.href = '/dashboard';
|
||||
}, 1500);
|
||||
document.getElementById('qr-status').textContent = '✓ 账号添加成功,正在跳转...';
|
||||
setTimeout(() => { window.location.href = '/dashboard'; }, 1000);
|
||||
} else if (addResult.need_login || addResp.status === 401) {
|
||||
document.getElementById('qr-status').textContent = '登录已过期,正在跳转...';
|
||||
document.getElementById('qr-status').className = 'status-error';
|
||||
setTimeout(() => { window.location.href = '/login'; }, 1500);
|
||||
} else {
|
||||
document.getElementById('qr-status').innerHTML =
|
||||
'<span style="color: #dc3545;">添加账号失败: ' + addResult.message + '</span>';
|
||||
document.getElementById('qr-status').textContent = '添加失败: ' + addResult.message;
|
||||
document.getElementById('qr-status').className = 'status-error';
|
||||
}
|
||||
|
||||
} else if (data.status === 'expired') {
|
||||
clearInterval(pollInterval);
|
||||
clearInterval(timerInterval);
|
||||
document.getElementById('qr-status').innerHTML =
|
||||
'<span style="color: #dc3545;">二维码已过期,请重新生成</span>';
|
||||
document.getElementById('generate-qr-btn').style.display = 'inline-block';
|
||||
document.getElementById('qr-display').style.display = 'none';
|
||||
} else if (data.status === 'cancelled') {
|
||||
clearInterval(pollInterval);
|
||||
clearInterval(timerInterval);
|
||||
document.getElementById('qr-status').innerHTML =
|
||||
'<span style="color: #dc3545;">已取消登录</span>';
|
||||
return;
|
||||
} else if (data.status === 'expired' || data.status === 'cancelled') {
|
||||
clearInterval(pollInterval); pollInterval = null; clearInterval(timerInterval);
|
||||
document.getElementById('qr-status').textContent = data.status === 'expired' ? '二维码已过期,请重新生成' : '已取消登录';
|
||||
document.getElementById('qr-status').className = 'status-error';
|
||||
document.getElementById('generate-qr-btn').style.display = 'inline-block';
|
||||
document.getElementById('qr-display').style.display = 'none';
|
||||
} else if (data.status === 'error') {
|
||||
clearInterval(pollInterval);
|
||||
clearInterval(timerInterval);
|
||||
document.getElementById('qr-status').innerHTML =
|
||||
'<span style="color: #dc3545;">错误: ' + (data.error || '未知错误') + '</span>';
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('检查二维码状态失败:', error);
|
||||
clearInterval(pollInterval); pollInterval = null; clearInterval(timerInterval);
|
||||
document.getElementById('qr-status').textContent = '错误: ' + (data.error || '未知错误');
|
||||
document.getElementById('qr-status').className = 'status-error';
|
||||
}
|
||||
} catch(e) { console.error('检查状态失败:', e); }
|
||||
finally { isProcessing = false; }
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,30 +1,365 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Add Task - Weibo-HotSign{% endblock %}
|
||||
{% block title %}添加任务 - 微博超话签到{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.task-container { max-width: 560px; margin: 0 auto; }
|
||||
|
||||
/* 时间轮盘 */
|
||||
.wheel-section {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
margin: 24px 0 28px;
|
||||
}
|
||||
.wheel-col { text-align: center; }
|
||||
.wheel-col .wheel-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.wheel-wrapper {
|
||||
position: relative;
|
||||
width: 90px;
|
||||
height: 180px;
|
||||
overflow: hidden;
|
||||
border-radius: 20px;
|
||||
background: #f8fafc;
|
||||
border: 1.5px solid #e2e8f0;
|
||||
}
|
||||
.wheel-wrapper::before, .wheel-wrapper::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0; right: 0;
|
||||
height: 60px;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
}
|
||||
.wheel-wrapper::before {
|
||||
top: 0;
|
||||
background: linear-gradient(to bottom, rgba(248,250,252,1), rgba(248,250,252,0));
|
||||
}
|
||||
.wheel-wrapper::after {
|
||||
bottom: 0;
|
||||
background: linear-gradient(to top, rgba(248,250,252,1), rgba(248,250,252,0));
|
||||
}
|
||||
.wheel-highlight {
|
||||
position: absolute;
|
||||
top: 50%; left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 78px; height: 44px;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(135deg, rgba(99,102,241,0.1), rgba(168,85,247,0.1));
|
||||
border: 2px solid rgba(99,102,241,0.3);
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
.wheel-scroll {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
height: 100%;
|
||||
overflow-y: scroll;
|
||||
scroll-snap-type: y mandatory;
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.wheel-scroll::-webkit-scrollbar { display: none; }
|
||||
.wheel-item {
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: #cbd5e1;
|
||||
scroll-snap-align: center;
|
||||
transition: color 0.15s, transform 0.15s;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.wheel-item.active {
|
||||
color: #6366f1;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* 重复模式 */
|
||||
.repeat-options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.repeat-chip {
|
||||
padding: 8px 18px;
|
||||
border-radius: 14px;
|
||||
border: 1.5px solid #e2e8f0;
|
||||
background: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
user-select: none;
|
||||
}
|
||||
.repeat-chip:hover { border-color: #818cf8; color: #6366f1; }
|
||||
.repeat-chip.selected {
|
||||
background: linear-gradient(135deg, #6366f1, #818cf8);
|
||||
color: white;
|
||||
border-color: transparent;
|
||||
box-shadow: 0 2px 8px rgba(99,102,241,0.25);
|
||||
}
|
||||
|
||||
/* 星期选择 */
|
||||
.weekday-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.weekday-btn {
|
||||
width: 44px; height: 44px;
|
||||
border-radius: 14px;
|
||||
border: 1.5px solid #e2e8f0;
|
||||
background: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.weekday-btn:hover { border-color: #818cf8; }
|
||||
.weekday-btn.selected {
|
||||
background: linear-gradient(135deg, #6366f1, #818cf8);
|
||||
color: white;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
/* 预览 */
|
||||
.cron-preview {
|
||||
margin-top: 20px;
|
||||
padding: 16px 20px;
|
||||
background: #f8fafc;
|
||||
border-radius: 16px;
|
||||
border: 1.5px solid #e2e8f0;
|
||||
font-size: 14px;
|
||||
}
|
||||
.cron-preview code {
|
||||
display: inline-block;
|
||||
background: #eef2ff;
|
||||
color: #6366f1;
|
||||
padding: 3px 10px;
|
||||
border-radius: 8px;
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.cron-preview .desc { color: #64748b; margin-top: 6px; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div style="max-width: 600px; margin: 0 auto;">
|
||||
<h1 style="margin-bottom: 30px;">Add Signin Task</h1>
|
||||
|
||||
<div class="card">
|
||||
<form method="POST">
|
||||
<div class="form-group">
|
||||
<label for="cron_expression">Cron Expression</label>
|
||||
<input type="text" id="cron_expression" name="cron_expression" required placeholder="e.g., 0 9 * * *">
|
||||
<small style="color: #999; display: block; margin-top: 8px;">
|
||||
Standard cron format (minute hour day month weekday)<br>
|
||||
Examples:<br>
|
||||
• <code>0 9 * * *</code> - Every day at 9:00 AM<br>
|
||||
• <code>0 9 * * 1-5</code> - Weekdays at 9:00 AM<br>
|
||||
• <code>0 9,21 * * *</code> - Every day at 9:00 AM and 9:00 PM
|
||||
</small>
|
||||
<div class="task-container">
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:24px;">
|
||||
<h1 style="font-size:24px; font-weight:700; color:#1e293b;">⏰ 添加签到任务</h1>
|
||||
<a href="{{ url_for('account_detail', account_id=account_id) }}" class="btn btn-secondary">返回</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">选择签到时间</div>
|
||||
|
||||
<!-- 时间轮盘 -->
|
||||
<div class="wheel-section">
|
||||
<div class="wheel-col">
|
||||
<div class="wheel-label">时</div>
|
||||
<div class="wheel-wrapper">
|
||||
<div class="wheel-highlight"></div>
|
||||
<div class="wheel-scroll" id="hour-wheel"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size:32px; font-weight:700; color:#cbd5e1; padding-top:50px;">:</div>
|
||||
<div class="wheel-col">
|
||||
<div class="wheel-label">分</div>
|
||||
<div class="wheel-wrapper">
|
||||
<div class="wheel-highlight"></div>
|
||||
<div class="wheel-scroll" id="minute-wheel"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 重复模式 -->
|
||||
<div class="card-header" style="margin-top:8px;">重复方式</div>
|
||||
<div class="repeat-options">
|
||||
<div class="repeat-chip selected" data-mode="daily" onclick="setRepeat('daily')">每天</div>
|
||||
<div class="repeat-chip" data-mode="weekdays" onclick="setRepeat('weekdays')">工作日</div>
|
||||
<div class="repeat-chip" data-mode="custom" onclick="setRepeat('custom')">自定义</div>
|
||||
</div>
|
||||
|
||||
<div id="weekday-picker" style="display:none; margin-top:12px;">
|
||||
<div class="weekday-row">
|
||||
<div class="weekday-btn" data-day="1" onclick="toggleDay(this)">一</div>
|
||||
<div class="weekday-btn" data-day="2" onclick="toggleDay(this)">二</div>
|
||||
<div class="weekday-btn" data-day="3" onclick="toggleDay(this)">三</div>
|
||||
<div class="weekday-btn" data-day="4" onclick="toggleDay(this)">四</div>
|
||||
<div class="weekday-btn" data-day="5" onclick="toggleDay(this)">五</div>
|
||||
<div class="weekday-btn" data-day="6" onclick="toggleDay(this)">六</div>
|
||||
<div class="weekday-btn" data-day="0" onclick="toggleDay(this)">日</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cron 预览 -->
|
||||
<div class="cron-preview">
|
||||
<div>生成的 Cron 表达式: <code id="cron-display">0 8 * * *</code></div>
|
||||
<div class="desc" id="cron-desc">每天 08:00 执行签到</div>
|
||||
</div>
|
||||
|
||||
<!-- 提交 -->
|
||||
<form method="POST" id="task-form" style="margin-top:20px;">
|
||||
<input type="hidden" id="cron_expression" name="cron_expression" value="0 8 * * *">
|
||||
<div class="btn-group">
|
||||
<button type="submit" class="btn btn-primary">Create Task</button>
|
||||
<a href="{{ url_for('account_detail', account_id=account_id) }}" class="btn btn-secondary">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary" style="flex:1; text-align:center;">创建任务</button>
|
||||
<a href="{{ url_for('account_detail', account_id=account_id) }}" class="btn btn-secondary" style="text-align:center;">取消</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let selectedHour = 8;
|
||||
let selectedMinute = 0;
|
||||
let repeatMode = 'daily';
|
||||
let customDays = new Set();
|
||||
|
||||
// 初始化轮盘
|
||||
function initWheel(containerId, count, padFn, onSelect) {
|
||||
const container = document.getElementById(containerId);
|
||||
// 添加前后空白占位(让第一个和最后一个能滚到中间)
|
||||
const padCount = 2;
|
||||
for (let i = 0; i < padCount; i++) {
|
||||
const spacer = document.createElement('div');
|
||||
spacer.className = 'wheel-item';
|
||||
spacer.style.visibility = 'hidden';
|
||||
container.appendChild(spacer);
|
||||
}
|
||||
for (let i = 0; i < count; i++) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'wheel-item';
|
||||
item.textContent = padFn(i);
|
||||
item.dataset.value = i;
|
||||
item.addEventListener('click', () => {
|
||||
item.scrollIntoView({behavior:'smooth', block:'center'});
|
||||
});
|
||||
container.appendChild(item);
|
||||
}
|
||||
for (let i = 0; i < padCount; i++) {
|
||||
const spacer = document.createElement('div');
|
||||
spacer.className = 'wheel-item';
|
||||
spacer.style.visibility = 'hidden';
|
||||
container.appendChild(spacer);
|
||||
}
|
||||
|
||||
// 滚动监听
|
||||
let scrollTimer;
|
||||
container.addEventListener('scroll', () => {
|
||||
clearTimeout(scrollTimer);
|
||||
scrollTimer = setTimeout(() => {
|
||||
const items = container.querySelectorAll('.wheel-item:not([style*="hidden"])');
|
||||
const center = container.scrollTop + container.clientHeight / 2;
|
||||
let closest = null, minDist = Infinity;
|
||||
items.forEach(el => {
|
||||
const dist = Math.abs(el.offsetTop + 22 - center);
|
||||
if (dist < minDist) { minDist = dist; closest = el; }
|
||||
});
|
||||
if (closest && closest.dataset.value !== undefined) {
|
||||
items.forEach(el => el.classList.remove('active'));
|
||||
closest.classList.add('active');
|
||||
onSelect(parseInt(closest.dataset.value));
|
||||
}
|
||||
}, 60);
|
||||
});
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
function pad2(n) { return n.toString().padStart(2, '0'); }
|
||||
|
||||
const hourWheel = initWheel('hour-wheel', 24, pad2, v => { selectedHour = v; updateCron(); });
|
||||
const minuteWheel = initWheel('minute-wheel', 60, pad2, v => { selectedMinute = v; updateCron(); });
|
||||
|
||||
// 初始滚动到默认值
|
||||
setTimeout(() => {
|
||||
scrollToValue(hourWheel, selectedHour);
|
||||
scrollToValue(minuteWheel, selectedMinute);
|
||||
}, 100);
|
||||
|
||||
function scrollToValue(container, value) {
|
||||
const items = container.querySelectorAll('.wheel-item');
|
||||
items.forEach(el => {
|
||||
if (el.dataset.value == value) {
|
||||
el.scrollIntoView({block:'center'});
|
||||
el.classList.add('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setRepeat(mode) {
|
||||
repeatMode = mode;
|
||||
document.querySelectorAll('.repeat-chip').forEach(c => c.classList.remove('selected'));
|
||||
document.querySelector(`.repeat-chip[data-mode="${mode}"]`).classList.add('selected');
|
||||
document.getElementById('weekday-picker').style.display = mode === 'custom' ? 'block' : 'none';
|
||||
updateCron();
|
||||
}
|
||||
|
||||
function toggleDay(el) {
|
||||
const day = el.dataset.day;
|
||||
if (customDays.has(day)) {
|
||||
customDays.delete(day);
|
||||
el.classList.remove('selected');
|
||||
} else {
|
||||
customDays.add(day);
|
||||
el.classList.add('selected');
|
||||
}
|
||||
updateCron();
|
||||
}
|
||||
|
||||
const dayNames = ['日','一','二','三','四','五','六'];
|
||||
|
||||
function updateCron() {
|
||||
let dow = '*';
|
||||
let desc = '';
|
||||
|
||||
if (repeatMode === 'daily') {
|
||||
dow = '*';
|
||||
desc = '每天';
|
||||
} else if (repeatMode === 'weekdays') {
|
||||
dow = '1-5';
|
||||
desc = '工作日';
|
||||
} else {
|
||||
if (customDays.size === 0) {
|
||||
dow = '*';
|
||||
desc = '每天';
|
||||
} else if (customDays.size === 7) {
|
||||
dow = '*';
|
||||
desc = '每天';
|
||||
} else {
|
||||
const sorted = [...customDays].sort((a,b) => a - b);
|
||||
dow = sorted.join(',');
|
||||
desc = '每周' + sorted.map(d => dayNames[d]).join('、');
|
||||
}
|
||||
}
|
||||
|
||||
const cron = `${selectedMinute} ${selectedHour} * * ${dow}`;
|
||||
const timeStr = pad2(selectedHour) + ':' + pad2(selectedMinute);
|
||||
|
||||
document.getElementById('cron-display').textContent = cron;
|
||||
document.getElementById('cron-desc').textContent = `${desc} ${timeStr} 执行签到`;
|
||||
document.getElementById('cron_expression').value = cron;
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -3,366 +3,215 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Weibo-HotSign{% endblock %}</title>
|
||||
<title>{% block title %}微博超话签到{% endblock %}</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||
background-color: #f5f5f5;
|
||||
color: #333;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
|
||||
background: linear-gradient(135deg, #f0f2ff 0%, #faf5ff 50%, #fff0f6 100%);
|
||||
color: #1a1a2e;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
background-color: #fff;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
padding: 0 20px;
|
||||
background: rgba(255,255,255,0.85);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid rgba(99,102,241,0.08);
|
||||
padding: 0 24px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.navbar-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: 60px;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #6366f1;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, #6366f1, #a855f7);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.navbar-menu {
|
||||
display: flex;
|
||||
gap: 30px;
|
||||
gap: 28px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.navbar-menu a {
|
||||
color: #666;
|
||||
.navbar-menu > a {
|
||||
color: #64748b;
|
||||
text-decoration: none;
|
||||
transition: color 0.3s;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.navbar-menu a:hover {
|
||||
color: #6366f1;
|
||||
}
|
||||
|
||||
.navbar-menu > a:hover { color: #6366f1; }
|
||||
.navbar-user {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.navbar-user span {
|
||||
color: #475569;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.btn-logout {
|
||||
background-color: #dc3545;
|
||||
background: linear-gradient(135deg, #f43f5e, #e11d48);
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
padding: 7px 18px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.btn-logout:hover {
|
||||
background-color: #c82333;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.btn-logout:hover { opacity: 0.85; }
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 30px auto;
|
||||
margin: 28px auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 12px 20px;
|
||||
padding: 14px 20px;
|
||||
margin-bottom: 20px;
|
||||
border-radius: 4px;
|
||||
border-left: 4px solid;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
border-color: #28a745;
|
||||
}
|
||||
|
||||
.alert-danger {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
border-color: #f5c6cb;
|
||||
}
|
||||
|
||||
.alert-warning {
|
||||
background-color: #fff3cd;
|
||||
color: #856404;
|
||||
border-color: #ffeaa7;
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
background-color: #d1ecf1;
|
||||
color: #0c5460;
|
||||
border-color: #bee5eb;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
border-radius: 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
.alert-success { background: #ecfdf5; color: #065f46; border: 1px solid #a7f3d0; }
|
||||
.alert-danger { background: #fef2f2; color: #991b1b; border: 1px solid #fecaca; }
|
||||
.alert-warning { background: #fffbeb; color: #92400e; border: 1px solid #fde68a; }
|
||||
.alert-info { background: #eff6ff; color: #1e40af; border: 1px solid #bfdbfe; }
|
||||
|
||||
input[type="text"],
|
||||
input[type="email"],
|
||||
input[type="password"],
|
||||
textarea,
|
||||
select {
|
||||
.form-group { margin-bottom: 20px; }
|
||||
label { display: block; margin-bottom: 8px; font-weight: 600; color: #334155; font-size: 14px; }
|
||||
|
||||
input[type="text"], input[type="email"], input[type="password"], textarea, select {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
padding: 12px 16px;
|
||||
border: 1.5px solid #e2e8f0;
|
||||
border-radius: 14px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
background: #fff;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
input[type="text"]:focus,
|
||||
input[type="email"]:focus,
|
||||
input[type="password"]:focus,
|
||||
textarea:focus,
|
||||
select:focus {
|
||||
input[type="text"]:focus, input[type="email"]:focus, input[type="password"]:focus,
|
||||
textarea:focus, select:focus {
|
||||
outline: none;
|
||||
border-color: #6366f1;
|
||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
min-height: 100px;
|
||||
border-color: #818cf8;
|
||||
box-shadow: 0 0 0 4px rgba(99,102,241,0.1);
|
||||
}
|
||||
textarea { resize: vertical; min-height: 100px; }
|
||||
|
||||
.btn {
|
||||
padding: 10px 20px;
|
||||
padding: 10px 22px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
border-radius: 14px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #6366f1;
|
||||
background: linear-gradient(135deg, #6366f1, #818cf8);
|
||||
color: white;
|
||||
box-shadow: 0 2px 8px rgba(99,102,241,0.25);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #4f46e5;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #5a6268;
|
||||
}
|
||||
|
||||
.btn-primary:hover { box-shadow: 0 4px 16px rgba(99,102,241,0.35); transform: translateY(-1px); }
|
||||
.btn-secondary { background: #f1f5f9; color: #475569; }
|
||||
.btn-secondary:hover { background: #e2e8f0; }
|
||||
.btn-danger {
|
||||
background-color: #dc3545;
|
||||
background: linear-gradient(135deg, #f43f5e, #e11d48);
|
||||
color: white;
|
||||
box-shadow: 0 2px 8px rgba(244,63,94,0.25);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: #c82333;
|
||||
}
|
||||
|
||||
.btn-danger:hover { box-shadow: 0 4px 16px rgba(244,63,94,0.35); }
|
||||
.btn-success {
|
||||
background-color: #28a745;
|
||||
background: linear-gradient(135deg, #10b981, #059669);
|
||||
color: white;
|
||||
box-shadow: 0 2px 8px rgba(16,185,129,0.25);
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background-color: #218838;
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.btn-success:hover { box-shadow: 0 4px 16px rgba(16,185,129,0.35); }
|
||||
.btn-group { display: flex; gap: 10px; margin-top: 20px; }
|
||||
|
||||
.card {
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
padding: 20px;
|
||||
background: rgba(255,255,255,0.9);
|
||||
backdrop-filter: blur(8px);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.04), 0 4px 16px rgba(0,0,0,0.04);
|
||||
padding: 24px;
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid rgba(255,255,255,0.6);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 15px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 2px solid #f0f0f0;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 2px solid #f1f5f9;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
padding: 4px 14px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background-color: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.badge-danger {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.badge-info {
|
||||
background-color: #d1ecf1;
|
||||
color: #0c5460;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.badge-success { background: #ecfdf5; color: #059669; }
|
||||
.badge-warning { background: #fffbeb; color: #d97706; }
|
||||
.badge-danger { background: #fef2f2; color: #dc2626; }
|
||||
.badge-info { background: #eff6ff; color: #2563eb; }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 12px; }
|
||||
th {
|
||||
background-color: #f8f9fa;
|
||||
padding: 12px;
|
||||
background: #f8fafc;
|
||||
padding: 12px 16px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
border-bottom: 2px solid #dee2e6;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
}
|
||||
td { padding: 14px 16px; border-bottom: 1px solid #f1f5f9; font-size: 14px; }
|
||||
tr:hover { background: #f8fafc; }
|
||||
|
||||
td {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.grid-item {
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
padding: 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.grid-item:hover {
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.grid-item-title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.grid-item-subtitle {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.grid-item-status {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.pagination a,
|
||||
.pagination span {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
.pagination { display: flex; gap: 6px; justify-content: center; margin-top: 20px; }
|
||||
.pagination a, .pagination span {
|
||||
padding: 8px 14px;
|
||||
border: 1.5px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
text-decoration: none;
|
||||
color: #6366f1;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.pagination a:hover {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.pagination .active {
|
||||
background-color: #6366f1;
|
||||
color: white;
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
.pagination .disabled {
|
||||
color: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.pagination a:hover { background: #f1f5f9; }
|
||||
.pagination .active { background: #6366f1; color: white; border-color: #6366f1; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.navbar-menu {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
.navbar-menu { display: none; }
|
||||
.btn-group { flex-direction: column; }
|
||||
.btn { width: 100%; text-align: center; }
|
||||
}
|
||||
</style>
|
||||
{% block extra_css %}{% endblock %}
|
||||
@@ -371,12 +220,12 @@
|
||||
{% if session.get('user') %}
|
||||
<nav class="navbar">
|
||||
<div class="navbar-content">
|
||||
<a href="{{ url_for('dashboard') }}" class="navbar-brand">Weibo-HotSign</a>
|
||||
<a href="{{ url_for('dashboard') }}" class="navbar-brand">🔥 微博超话签到</a>
|
||||
<div class="navbar-menu">
|
||||
<a href="{{ url_for('dashboard') }}">Dashboard</a>
|
||||
<a href="{{ url_for('dashboard') }}">控制台</a>
|
||||
<div class="navbar-user">
|
||||
<span>{{ session.get('user').get('username') }}</span>
|
||||
<a href="{{ url_for('logout') }}" class="btn-logout">Logout</a>
|
||||
<span>👤 {{ session.get('user').get('username') }}</span>
|
||||
<a href="{{ url_for('logout') }}" class="btn-logout">退出</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -391,7 +240,6 @@
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</body>
|
||||
|
||||
@@ -1,40 +1,297 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Dashboard - Weibo-HotSign{% endblock %}
|
||||
{% block title %}控制台 - 微博超话签到{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.dash-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.dash-header h1 {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
}
|
||||
.dash-actions { display: flex; gap: 10px; }
|
||||
|
||||
/* 统计卡片 */
|
||||
.stats-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.stat-card {
|
||||
background: rgba(255,255,255,0.9);
|
||||
backdrop-filter: blur(8px);
|
||||
border-radius: 20px;
|
||||
padding: 22px 24px;
|
||||
border: 1px solid rgba(255,255,255,0.6);
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.04), 0 4px 16px rgba(0,0,0,0.04);
|
||||
}
|
||||
.stat-card .stat-icon { font-size: 28px; margin-bottom: 8px; }
|
||||
.stat-card .stat-value { font-size: 28px; font-weight: 700; color: #1e293b; }
|
||||
.stat-card .stat-label { font-size: 13px; color: #94a3b8; font-weight: 500; margin-top: 2px; }
|
||||
|
||||
/* 账号卡片网格 */
|
||||
.account-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
.account-card {
|
||||
background: rgba(255,255,255,0.92);
|
||||
backdrop-filter: blur(8px);
|
||||
border-radius: 20px;
|
||||
padding: 24px;
|
||||
border: 1px solid rgba(255,255,255,0.6);
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.04), 0 4px 16px rgba(0,0,0,0.04);
|
||||
cursor: pointer;
|
||||
transition: all 0.25s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.account-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0;
|
||||
height: 4px;
|
||||
border-radius: 20px 20px 0 0;
|
||||
}
|
||||
.account-card.status-active::before { background: linear-gradient(90deg, #10b981, #34d399); }
|
||||
.account-card.status-pending::before { background: linear-gradient(90deg, #f59e0b, #fbbf24); }
|
||||
.account-card.status-invalid_cookie::before { background: linear-gradient(90deg, #ef4444, #f87171); }
|
||||
.account-card.status-banned::before { background: linear-gradient(90deg, #6b7280, #9ca3af); }
|
||||
|
||||
.account-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 30px rgba(99,102,241,0.12);
|
||||
}
|
||||
.account-card-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.account-avatar {
|
||||
width: 48px; height: 48px;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(135deg, #6366f1, #a855f7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.account-name {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
margin-bottom: 4px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.account-remark {
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.account-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
}
|
||||
.account-date { font-size: 12px; color: #cbd5e1; }
|
||||
|
||||
/* 删除按钮 */
|
||||
.account-del-btn {
|
||||
width: 32px; height: 32px;
|
||||
border-radius: 10px;
|
||||
border: 1.5px solid #fecaca;
|
||||
background: #fff;
|
||||
color: #ef4444;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.account-del-btn:hover {
|
||||
background: #fef2f2;
|
||||
border-color: #f87171;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 80px 20px;
|
||||
background: rgba(255,255,255,0.7);
|
||||
border-radius: 24px;
|
||||
border: 2px dashed #e2e8f0;
|
||||
}
|
||||
.empty-state .empty-icon { font-size: 56px; margin-bottom: 16px; }
|
||||
.empty-state p { color: #94a3b8; margin-bottom: 24px; font-size: 16px; }
|
||||
|
||||
/* Cookie 批量验证 */
|
||||
.batch-verify-bar {
|
||||
background: rgba(255,255,255,0.9);
|
||||
backdrop-filter: blur(8px);
|
||||
border-radius: 20px;
|
||||
padding: 16px 24px;
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid rgba(255,255,255,0.6);
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.batch-verify-bar .info { font-size: 14px; color: #64748b; }
|
||||
.batch-verify-bar .info strong { color: #1e293b; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 30px;">
|
||||
<h1>Weibo Accounts</h1>
|
||||
<a href="{{ url_for('add_account') }}" class="btn btn-primary">+ Add Account</a>
|
||||
<div class="dash-header">
|
||||
<h1>👋 控制台</h1>
|
||||
<div class="dash-actions">
|
||||
<a href="{{ url_for('add_account') }}" class="btn btn-primary">+ 添加账号</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if accounts %}
|
||||
<div class="grid">
|
||||
<!-- 统计概览 -->
|
||||
<div class="stats-row">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">📊</div>
|
||||
<div class="stat-value">{{ accounts|length }}</div>
|
||||
<div class="stat-label">账号总数</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">✅</div>
|
||||
<div class="stat-value">{{ accounts|selectattr('status','equalto','active')|list|length }}</div>
|
||||
<div class="stat-label">正常运行</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">⚠️</div>
|
||||
<div class="stat-value">{{ accounts|selectattr('status','equalto','pending')|list|length + accounts|selectattr('status','equalto','invalid_cookie')|list|length }}</div>
|
||||
<div class="stat-label">需要关注</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 批量操作栏 -->
|
||||
<div class="batch-verify-bar">
|
||||
<div class="info">
|
||||
💡 系统每天 <strong>23:50</strong> 自动批量验证 Cookie,也可手动触发
|
||||
</div>
|
||||
<div style="display:flex; gap:10px;">
|
||||
<button class="btn btn-secondary" id="batch-verify-btn" onclick="batchVerify()">🔍 批量验证 Cookie</button>
|
||||
<button class="btn btn-primary" id="batch-signin-btn" onclick="batchSignin()">🚀 全部签到</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 账号卡片 -->
|
||||
<div class="account-grid">
|
||||
{% for account in accounts %}
|
||||
<div class="grid-item" onclick="window.location.href='{{ url_for('account_detail', account_id=account.id) }}'">
|
||||
<div class="grid-item-title">{{ account.weibo_user_id }}</div>
|
||||
<div class="grid-item-subtitle">{{ account.remark or 'No remark' }}</div>
|
||||
<div class="grid-item-status">
|
||||
<div class="account-card status-{{ account.status }}" onclick="window.location.href='{{ url_for('account_detail', account_id=account.id) }}'">
|
||||
<div class="account-card-top">
|
||||
<div style="flex:1; min-width:0;">
|
||||
<div class="account-name">{{ account.remark or account.weibo_user_id }}</div>
|
||||
<div class="account-remark">UID: {{ account.weibo_user_id }}</div>
|
||||
</div>
|
||||
<div class="account-avatar">{{ (account.remark or account.weibo_user_id)[:1] }}</div>
|
||||
</div>
|
||||
<div class="account-meta">
|
||||
<div>
|
||||
{% if account.status == 'active' %}
|
||||
<span class="badge badge-success">Active</span>
|
||||
<span class="badge badge-success">正常</span>
|
||||
{% elif account.status == 'pending' %}
|
||||
<span class="badge badge-warning">Pending</span>
|
||||
<span class="badge badge-warning">待验证</span>
|
||||
{% elif account.status == 'invalid_cookie' %}
|
||||
<span class="badge badge-danger">Invalid Cookie</span>
|
||||
<span class="badge badge-danger">Cookie 失效</span>
|
||||
{% elif account.status == 'banned' %}
|
||||
<span class="badge badge-danger">Banned</span>
|
||||
<span class="badge badge-danger">已封禁</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div style="font-size: 12px; color: #999; margin-top: 10px;">
|
||||
Created: {{ account.created_at[:10] }}
|
||||
<div style="display:flex; align-items:center; gap:10px;">
|
||||
<span class="account-date">{{ account.created_at[:10] }}</span>
|
||||
<button class="account-del-btn" title="删除账号" onclick="event.stopPropagation(); deleteAccount('{{ account.id }}', '{{ account.remark or account.weibo_user_id }}');">🗑</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<div class="card" style="text-align: center; padding: 60px 20px;">
|
||||
<p style="color: #999; margin-bottom: 20px;">No accounts yet</p>
|
||||
<a href="{{ url_for('add_account') }}" class="btn btn-primary">Add your first account</a>
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">📱</div>
|
||||
<p>暂无账号,扫码添加你的微博账号开始自动签到</p>
|
||||
<a href="{{ url_for('add_account') }}" class="btn btn-primary" style="font-size:16px; padding:14px 32px;">添加第一个账号</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
async function deleteAccount(accountId, name) {
|
||||
if (!confirm(`确定要删除账号「${name}」吗?此操作不可恢复。`)) return;
|
||||
try {
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = `/accounts/${accountId}/delete`;
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
} catch(e) {
|
||||
alert('删除失败: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function batchVerify() {
|
||||
const btn = document.getElementById('batch-verify-btn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⏳ 验证中...';
|
||||
try {
|
||||
const resp = await fetch('/api/batch/verify', {method: 'POST'});
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
const r = data.data;
|
||||
alert(`验证完成:${r.valid} 个有效,${r.invalid} 个失效,${r.errors} 个出错`);
|
||||
} else {
|
||||
alert('验证失败: ' + (data.message || '未知错误'));
|
||||
}
|
||||
} catch(e) {
|
||||
alert('请求失败: ' + e.message);
|
||||
}
|
||||
btn.disabled = false;
|
||||
btn.textContent = '🔍 批量验证 Cookie';
|
||||
location.reload();
|
||||
}
|
||||
|
||||
async function batchSignin() {
|
||||
const btn = document.getElementById('batch-signin-btn');
|
||||
if (!confirm('确定要对所有正常账号执行签到吗?')) return;
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⏳ 签到中...';
|
||||
try {
|
||||
const resp = await fetch('/api/batch/signin', {method: 'POST'});
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
const r = data.data;
|
||||
alert(`签到完成:${r.total_accounts} 个账号,${r.total_signed} 成功,${r.total_already} 已签,${r.total_failed} 失败`);
|
||||
} else {
|
||||
alert('签到失败: ' + (data.message || '未知错误'));
|
||||
}
|
||||
} catch(e) {
|
||||
alert('请求失败: ' + e.message);
|
||||
}
|
||||
btn.disabled = false;
|
||||
btn.textContent = '🚀 全部签到';
|
||||
location.reload();
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,29 +1,24 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Edit Account - Weibo-HotSign{% endblock %}
|
||||
{% block title %}编辑账号 - 微博超话签到{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div style="max-width: 600px; margin: 0 auto;">
|
||||
<h1 style="margin-bottom: 30px;">Edit Account</h1>
|
||||
|
||||
<div style="max-width: 560px; margin: 0 auto;">
|
||||
<h1 style="font-size:24px; font-weight:700; color:#1e293b; margin-bottom:24px;">✏️ 编辑账号</h1>
|
||||
<div class="card">
|
||||
<form method="POST">
|
||||
<div class="form-group">
|
||||
<label for="remark">Remark</label>
|
||||
<input type="text" id="remark" name="remark" value="{{ account.remark or '' }}" placeholder="e.g., My main account">
|
||||
<label for="remark">备注</label>
|
||||
<input type="text" id="remark" name="remark" value="{{ account.remark or '' }}" placeholder="例如:我的主账号">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="cookie">Cookie (Leave empty to keep current)</label>
|
||||
<textarea id="cookie" name="cookie" placeholder="Paste new cookie here if you want to update"></textarea>
|
||||
<small style="color: #999; display: block; margin-top: 8px;">
|
||||
Your cookie will be encrypted and stored securely.
|
||||
</small>
|
||||
<label for="cookie">Cookie(留空则保持不变)</label>
|
||||
<textarea id="cookie" name="cookie" placeholder="如需更新 Cookie,请在此粘贴新的 Cookie" style="border-radius:14px;"></textarea>
|
||||
<small style="color:#94a3b8; display:block; margin-top:8px; font-size:13px;">Cookie 将被加密存储</small>
|
||||
</div>
|
||||
|
||||
<div class="btn-group">
|
||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||
<a href="{{ url_for('account_detail', account_id=account.id) }}" class="btn btn-secondary">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary" style="flex:1; text-align:center;">保存修改</button>
|
||||
<a href="{{ url_for('account_detail', account_id=account.id) }}" class="btn btn-secondary" style="text-align:center;">取消</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,69 +1,47 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Login - Weibo-HotSign{% endblock %}
|
||||
{% block title %}登录 - 微博超话签到{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.auth-container {
|
||||
max-width: 400px;
|
||||
margin: 60px auto;
|
||||
}
|
||||
|
||||
.auth-container { max-width: 420px; margin: 60px auto; }
|
||||
.auth-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
padding: 40px;
|
||||
background: rgba(255,255,255,0.92);
|
||||
backdrop-filter: blur(12px);
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.06);
|
||||
padding: 44px 36px;
|
||||
border: 1px solid rgba(255,255,255,0.6);
|
||||
}
|
||||
|
||||
.auth-title {
|
||||
text-align: center;
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 30px;
|
||||
color: #6366f1;
|
||||
}
|
||||
|
||||
.auth-link {
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.auth-link a {
|
||||
color: #6366f1;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.auth-link a:hover {
|
||||
text-decoration: underline;
|
||||
text-align: center; font-size: 28px; font-weight: 700; margin-bottom: 8px;
|
||||
background: linear-gradient(135deg, #6366f1, #a855f7);
|
||||
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
|
||||
}
|
||||
.auth-subtitle { text-align: center; color: #94a3b8; font-size: 15px; margin-bottom: 32px; }
|
||||
.auth-link { text-align: center; margin-top: 24px; color: #94a3b8; font-size: 14px; }
|
||||
.auth-link a { color: #6366f1; text-decoration: none; font-weight: 600; }
|
||||
.auth-link a:hover { text-decoration: underline; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="auth-container">
|
||||
<div class="auth-card">
|
||||
<h1 class="auth-title">Login</h1>
|
||||
|
||||
<h1 class="auth-title">🔥 微博超话签到</h1>
|
||||
<p class="auth-subtitle">登录你的账号</p>
|
||||
<form method="POST">
|
||||
<div class="form-group">
|
||||
<label for="email">Email</label>
|
||||
<input type="email" id="email" name="email" required>
|
||||
<label for="email">邮箱</label>
|
||||
<input type="email" id="email" name="email" required placeholder="请输入邮箱">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" required>
|
||||
<label for="password">密码</label>
|
||||
<input type="password" id="password" name="password" required placeholder="请输入密码">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary" style="width: 100%;">Login</button>
|
||||
<button type="submit" class="btn btn-primary" style="width:100%; padding:14px; font-size:16px; border-radius:16px;">登录</button>
|
||||
</form>
|
||||
|
||||
<div class="auth-link">
|
||||
Don't have an account? <a href="{{ url_for('register') }}">Register</a>
|
||||
</div>
|
||||
<div class="auth-link">还没有账号?<a href="{{ url_for('register') }}">注册</a></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,133 +1,76 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Register - Weibo-HotSign{% endblock %}
|
||||
{% block title %}注册 - 微博超话签到{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.auth-container {
|
||||
max-width: 400px;
|
||||
margin: 60px auto;
|
||||
}
|
||||
|
||||
.auth-container { max-width: 420px; margin: 60px auto; }
|
||||
.auth-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
padding: 40px;
|
||||
background: rgba(255,255,255,0.92);
|
||||
backdrop-filter: blur(12px);
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.06);
|
||||
padding: 44px 36px;
|
||||
border: 1px solid rgba(255,255,255,0.6);
|
||||
}
|
||||
|
||||
.auth-title {
|
||||
text-align: center;
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 30px;
|
||||
color: #6366f1;
|
||||
}
|
||||
|
||||
.password-strength {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.strength-bar {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
background-color: #ddd;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.strength-bar.active {
|
||||
background-color: #28a745;
|
||||
}
|
||||
|
||||
.strength-text {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.auth-link {
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.auth-link a {
|
||||
color: #6366f1;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.auth-link a:hover {
|
||||
text-decoration: underline;
|
||||
text-align: center; font-size: 28px; font-weight: 700; margin-bottom: 8px;
|
||||
background: linear-gradient(135deg, #6366f1, #a855f7);
|
||||
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
|
||||
}
|
||||
.auth-subtitle { text-align: center; color: #94a3b8; font-size: 15px; margin-bottom: 32px; }
|
||||
.strength-bar-row { display: flex; gap: 4px; margin-top: 8px; }
|
||||
.strength-bar { flex: 1; height: 4px; background: #e2e8f0; border-radius: 4px; transition: background 0.3s; }
|
||||
.strength-bar.active { background: #10b981; }
|
||||
.auth-link { text-align: center; margin-top: 24px; color: #94a3b8; font-size: 14px; }
|
||||
.auth-link a { color: #6366f1; text-decoration: none; font-weight: 600; }
|
||||
.auth-link a:hover { text-decoration: underline; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="auth-container">
|
||||
<div class="auth-card">
|
||||
<h1 class="auth-title">Create Account</h1>
|
||||
|
||||
<form method="POST" id="registerForm">
|
||||
<h1 class="auth-title">🔥 微博超话签到</h1>
|
||||
<p class="auth-subtitle">创建你的账号</p>
|
||||
<form method="POST">
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" required>
|
||||
<label for="username">用户名</label>
|
||||
<input type="text" id="username" name="username" required placeholder="请输入用户名">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email">Email</label>
|
||||
<input type="email" id="email" name="email" required>
|
||||
<label for="email">邮箱</label>
|
||||
<input type="email" id="email" name="email" required placeholder="请输入邮箱">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" required onchange="checkPasswordStrength()">
|
||||
<div class="password-strength" id="strengthBars">
|
||||
<div class="strength-bar"></div>
|
||||
<div class="strength-bar"></div>
|
||||
<div class="strength-bar"></div>
|
||||
<div class="strength-bar"></div>
|
||||
<div class="strength-bar"></div>
|
||||
<label for="password">密码</label>
|
||||
<input type="password" id="password" name="password" required oninput="checkStrength()">
|
||||
<div class="strength-bar-row">
|
||||
<div class="strength-bar"></div><div class="strength-bar"></div>
|
||||
<div class="strength-bar"></div><div class="strength-bar"></div><div class="strength-bar"></div>
|
||||
</div>
|
||||
<div class="strength-text">
|
||||
Must contain: uppercase, lowercase, number, special character, 8+ chars
|
||||
<small style="color:#94a3b8; font-size:12px; margin-top:6px; display:block;">需包含大小写字母、数字、特殊字符,至少 8 位</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="confirm_password">Confirm Password</label>
|
||||
<input type="password" id="confirm_password" name="confirm_password" required>
|
||||
<label for="confirm_password">确认密码</label>
|
||||
<input type="password" id="confirm_password" name="confirm_password" required placeholder="再次输入密码">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary" style="width: 100%;">Register</button>
|
||||
<button type="submit" class="btn btn-primary" style="width:100%; padding:14px; font-size:16px; border-radius:16px;">注册</button>
|
||||
</form>
|
||||
|
||||
<div class="auth-link">
|
||||
Already have an account? <a href="{{ url_for('login') }}">Login</a>
|
||||
<div class="auth-link">已有账号?<a href="{{ url_for('login') }}">登录</a></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function checkPasswordStrength() {
|
||||
const password = document.getElementById('password').value;
|
||||
let strength = 0;
|
||||
|
||||
if (password.length >= 8) strength++;
|
||||
if (/[a-z]/.test(password)) strength++;
|
||||
if (/[A-Z]/.test(password)) strength++;
|
||||
if (/\d/.test(password)) strength++;
|
||||
if (/[!@#$%^&*]/.test(password)) strength++;
|
||||
|
||||
const bars = document.querySelectorAll('.strength-bar');
|
||||
bars.forEach((bar, index) => {
|
||||
if (index < strength) {
|
||||
bar.classList.add('active');
|
||||
} else {
|
||||
bar.classList.remove('active');
|
||||
}
|
||||
function checkStrength() {
|
||||
const pw = document.getElementById('password').value;
|
||||
let s = 0;
|
||||
if (pw.length >= 8) s++;
|
||||
if (/[a-z]/.test(pw)) s++;
|
||||
if (/[A-Z]/.test(pw)) s++;
|
||||
if (/\d/.test(pw)) s++;
|
||||
if (/[!@#$%^&*]/.test(pw)) s++;
|
||||
document.querySelectorAll('.strength-bar').forEach((b, i) => {
|
||||
b.classList.toggle('active', i < s);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>微博授权 - Weibo-HotSign</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
}
|
||||
.container {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 40px;
|
||||
max-width: 500px;
|
||||
width: 100%;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
||||
text-align: center;
|
||||
}
|
||||
.icon {
|
||||
font-size: 64px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.success { color: #28a745; }
|
||||
.error { color: #dc3545; }
|
||||
h1 {
|
||||
color: #333;
|
||||
margin-bottom: 15px;
|
||||
font-size: 24px;
|
||||
}
|
||||
p {
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.message {
|
||||
background: #f8f9fa;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
margin: 20px 0;
|
||||
color: #333;
|
||||
}
|
||||
.close-btn {
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 30px;
|
||||
border-radius: 6px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
.close-btn:hover {
|
||||
background: #5558dd;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
{% if status == 'success' %}
|
||||
<div class="icon success">✓</div>
|
||||
<h1>授权成功!</h1>
|
||||
<p>你的微博账号 <strong>{{ screen_name }}</strong> 已成功授权。</p>
|
||||
<p>请返回电脑端页面完成账号添加。</p>
|
||||
{% else %}
|
||||
<div class="icon error">✗</div>
|
||||
<h1>授权失败</h1>
|
||||
<div class="message">{{ message }}</div>
|
||||
<p>请返回电脑端页面重试。</p>
|
||||
{% endif %}
|
||||
|
||||
<button class="close-btn" onclick="window.close()">关闭此页面</button>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -5,8 +5,11 @@
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
hashed_password TEXT NOT NULL,
|
||||
email TEXT UNIQUE,
|
||||
hashed_password TEXT,
|
||||
wx_openid TEXT UNIQUE,
|
||||
wx_nickname TEXT,
|
||||
wx_avatar TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
is_active INTEGER DEFAULT 1
|
||||
);
|
||||
@@ -50,6 +53,7 @@ CREATE TABLE IF NOT EXISTS signin_logs (
|
||||
-- Create indexes for better performance
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_wx_openid ON users(wx_openid);
|
||||
CREATE INDEX IF NOT EXISTS idx_accounts_user_id ON accounts(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_accounts_status ON accounts(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_account_id ON tasks(account_id);
|
||||
|
||||
48
migrate_add_wx_fields.py
Normal file
48
migrate_add_wx_fields.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""
|
||||
数据库迁移:给 users 表添加微信登录字段
|
||||
运行方式: python migrate_add_wx_fields.py
|
||||
"""
|
||||
import sqlite3
|
||||
import os
|
||||
|
||||
DB_PATHS = [
|
||||
"weibo_hotsign.db",
|
||||
"backend/weibo_hotsign.db",
|
||||
]
|
||||
|
||||
def migrate(db_path):
|
||||
if not os.path.exists(db_path):
|
||||
print(f" 跳过 {db_path}(文件不存在)")
|
||||
return
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("PRAGMA table_info(users)")
|
||||
columns = {row[1] for row in cursor.fetchall()}
|
||||
|
||||
added = []
|
||||
for col in ["wx_openid", "wx_nickname", "wx_avatar"]:
|
||||
if col not in columns:
|
||||
cursor.execute(f"ALTER TABLE users ADD COLUMN {col} TEXT")
|
||||
added.append(col)
|
||||
|
||||
# 创建唯一索引
|
||||
try:
|
||||
cursor.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_wx_openid ON users(wx_openid)")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
if added:
|
||||
print(f" ✅ {db_path}: 添加了 {', '.join(added)}")
|
||||
else:
|
||||
print(f" ✅ {db_path}: 字段已存在,无需迁移")
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("正在迁移数据库...")
|
||||
for p in DB_PATHS:
|
||||
migrate(p)
|
||||
print("迁移完成!")
|
||||
BIN
qrcode.png
Normal file
BIN
qrcode.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
@@ -62,7 +62,7 @@ if not exist "venv" (
|
||||
exit /b 1
|
||||
)
|
||||
) else (
|
||||
call \Scripts\activate.bat
|
||||
call venv\Scripts\activate.bat
|
||||
)
|
||||
cd ..
|
||||
|
||||
@@ -74,12 +74,12 @@ echo.
|
||||
|
||||
REM 启动 Auth Service (端口 8001)
|
||||
echo [启动] Auth Service (端口 8001)...
|
||||
start "Auth Service" cmd /k "cd backend && venv\Scripts\activate.bat && set PYTHONPATH=%CD% && python -m uvicorn auth_service.app.main:app --host 0.0.0.0 --port 8001 --reload"
|
||||
start "Auth Service" cmd /k "cd backend && venv\Scripts\activate.bat && set PYTHONPATH=%CD% && python -m uvicorn auth_service.app.main:app --host 0.0.0.0 --port 8001"
|
||||
timeout /t 3 >nul
|
||||
|
||||
REM 启动 API Service (端口 8000)
|
||||
echo [启动] API Service (端口 8000)...
|
||||
start "API Service" cmd /k "cd backend && venv\Scripts\activate.bat && set PYTHONPATH=%CD% && python -m uvicorn api_service.app.main:app --host 0.0.0.0 --port 8000 --reload"
|
||||
start "API Service" cmd /k "cd backend && venv\Scripts\activate.bat && set PYTHONPATH=%CD% && python -m uvicorn api_service.app.main:app --host 0.0.0.0 --port 8000"
|
||||
timeout /t 3 >nul
|
||||
|
||||
REM 启动 Frontend (端口 5000)
|
||||
|
||||
BIN
weibo_hotsign.db
BIN
weibo_hotsign.db
Binary file not shown.
Reference in New Issue
Block a user