From 2f2d5c3795531ca0fb6f81297123e5f663a6841a Mon Sep 17 00:00:00 2001 From: Jeason <1710884619@qq.com> Date: Mon, 16 Mar 2026 16:14:08 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E8=B7=91=E9=80=9A=EF=BC=8C?= =?UTF-8?q?=E5=9F=BA=E7=A1=80=E5=8A=9F=E8=83=BD=E5=85=A8=E9=83=A8=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/.env | 4 + .../__pycache__/accounts.cpython-311.pyc | Bin 7346 -> 22597 bytes backend/api_service/app/routers/accounts.py | 328 +++++- .../app/__pycache__/main.cpython-311.pyc | Bin 9732 -> 14079 bytes backend/auth_service/app/main.py | 100 +- .../schemas/__pycache__/user.cpython-311.pyc | Bin 5320 -> 6291 bytes backend/auth_service/app/schemas/user.py | 16 +- .../shared/__pycache__/config.cpython-311.pyc | Bin 1607 -> 1702 bytes backend/shared/config.py | 6 +- .../models/__pycache__/user.cpython-311.pyc | Bin 1976 -> 2241 bytes backend/shared/models/user.py | 7 +- backend/signin_executor/app/main.py | 2 + .../app/services/signin_service.py | 163 ++- .../app/services/weibo_client.py | 481 +++++---- backend/weibo_hotsign.db | Bin 40960 -> 45056 bytes debug_cookies.json | 13 + debug_full_signin.py | 276 +++++ debug_qrcode_flow.py | 509 +++++++++ frontend/app.py | 983 +++++++++++------- .../2029240f6d1128be89ddc32729463129 | Bin 9 -> 9 bytes .../63ef42ce753fadbb10b138d04f18d64d | Bin 0 -> 494 bytes .../9954d94905e0926ef31c3ae1f3a81f9f | Bin 1315 -> 7434 bytes frontend/templates/404.html | 12 +- frontend/templates/500.html | 12 +- frontend/templates/account_detail.html | 259 +++-- frontend/templates/add_account.html | 491 ++------- frontend/templates/add_task.html | 369 ++++++- frontend/templates/base.html | 392 +++---- frontend/templates/dashboard.html | 299 +++++- frontend/templates/edit_account.html | 25 +- frontend/templates/login.html | 68 +- frontend/templates/register.html | 147 +-- frontend/templates/weibo_callback.html | 82 -- init-db-sqlite.sql | 8 +- migrate_add_wx_fields.py | 48 + qrcode.png | Bin 0 -> 3350 bytes start_all.bat | 6 +- weibo_hotsign.db | Bin 81920 -> 90112 bytes 38 files changed, 3352 insertions(+), 1754 deletions(-) create mode 100644 debug_cookies.json create mode 100644 debug_full_signin.py create mode 100644 debug_qrcode_flow.py create mode 100644 frontend/flask_session/63ef42ce753fadbb10b138d04f18d64d delete mode 100644 frontend/templates/weibo_callback.html create mode 100644 migrate_add_wx_fields.py create mode 100644 qrcode.png diff --git a/backend/.env b/backend/.env index 742a7e7..b117239 100644 --- a/backend/.env +++ b/backend/.env @@ -15,5 +15,9 @@ JWT_EXPIRATION_HOURS=24 # Cookie 加密密钥 (32字节) COOKIE_ENCRYPTION_KEY=dev-cookie-encryption-key-32b +# 微信小程序配置 +WX_APPID= +WX_SECRET= + # 环境 ENVIRONMENT=development diff --git a/backend/api_service/app/routers/__pycache__/accounts.cpython-311.pyc b/backend/api_service/app/routers/__pycache__/accounts.cpython-311.pyc index 83e4255616e4d40348af2924e7be70cc3cf1c45a..e339a61951311180c3eeccfc1d100bd621cb66ea 100644 GIT binary patch literal 22597 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 delta 2121 zcmbtVO>7%Q6y8~T*WTD(Cw6QnY3w*@OXH9~QUa8QG@*@Dp-|E!AQ(WF>z$;gyKy?Z zK}|2IP!3e$g606C2e^P&9=4akfMC1SPE4vVyQN9RH`~58gjxk>}XUYh=*+5 ziO`7D!*tt@F}m;t!C5Vf*^pJrWOJtKBHGfs|aADn6U zdenB*H?W3S#|8mD_<|*|#8C{`+gWFWXF<0**0l{Vf7rOB&|Y9HA>+Hbqj&!U$6g+g z+lgcT`x^eso&Veap;|!>`C}Z<0!KgQP<=X%c9!3-Rn#gR;~4;+LFB1C9Z#cPy47`z zW5_DbDWz`FtJ&f^?d?>^*B6;rwyyb+$!fK}vgC< z@k}qOB@}%ie4R6Q#rC*#hh{}w;L?t_X)nOOTilIrG!@WRDG}bkbuRG*A!o%CbL3?m z(A&wq(2G^hDju^+ab5(RC-911t=DhV7>^+`jN&|sF)@+s$hDw~VV5HOCDenk#LZ-8 zcpUdKC`>~Jg<*m2n*mX|5|oTj0XQOlNd5|muQv;l&jSFVO3$jV4i5XI;^Nzti9Meh zA#aJrRNopVbvv~ezFXwX<2CjnWMJ??IO0J8Q5!;W76slc>Fl;-DMRfcQ{L@En`LFQT}F z0&lxf5SSmXz2>YeTQCJOrX&nBp9PZ|+U_V~x-=kI?mncOX#4vDM(67mvzf=Zj6;U- z1{xpD(hQ9D6MYj*J`&^YcV!E&l!pCCxop*|<+2}HsJLEbxyIpdM&am&2g47V-{ju3 zD|Yodb5><@{pq`r@Mc#~9a?c^W!{z#t%Yr_n#O13!BrR@SJ-yb7_cJK6 zctPj&6)2dmU0d~-E1T+vty3t{6MxO7#l&1z%*@@&r9W=p?&#lqZF6kfnAkBUo){B% V^j&=`G5@)0Dp4}Jivrjc bytes: return derive_key(shared_settings.COOKIE_ENCRYPTION_KEY) @@ -137,3 +154,312 @@ async def delete_account( await db.delete(account) await db.commit() return success_response(None, "Account deleted") + + +# ---- helpers for verify / signin ---- + +def _parse_cookie_str(cookie_str: str) -> Dict[str, str]: + """Parse 'k1=v1; k2=v2' into a dict.""" + cookies: Dict[str, str] = {} + for pair in cookie_str.split(";"): + pair = pair.strip() + if "=" in pair: + k, v = pair.split("=", 1) + cookies[k.strip()] = v.strip() + return cookies + + +async def _verify_weibo_cookie(cookie_str: str) -> dict: + """ + Verify cookie via weibo.com PC API. + Uses /ajax/side/cards which returns ok=1 when logged in. + Returns {"valid": bool, "uid": str|None, "screen_name": str|None}. + """ + cookies = _parse_cookie_str(cookie_str) + async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client: + # Step 1: check login via /ajax/side/cards + resp = await client.get( + "https://weibo.com/ajax/side/cards", + params={"count": "1"}, + headers=WEIBO_HEADERS, + cookies=cookies, + ) + data = resp.json() + if data.get("ok") != 1: + return {"valid": False, "uid": None, "screen_name": None} + + # Step 2: get user info via /ajax/profile/detail + uid = None + screen_name = None + try: + resp2 = await client.get( + "https://weibo.com/ajax/profile/info", + headers=WEIBO_HEADERS, + cookies=cookies, + ) + info = resp2.json() + if info.get("ok") == 1: + user = info.get("data", {}).get("user", {}) + uid = str(user.get("idstr", user.get("id", ""))) + screen_name = user.get("screen_name", "") + except Exception: + pass # profile info is optional, login check already passed + + return {"valid": True, "uid": uid, "screen_name": screen_name} + + +# ---- VERIFY COOKIE ---- + +@router.post("/{account_id}/verify") +async def verify_account( + account_id: str, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Verify the stored cookie is still valid and update account status.""" + account = await _get_owned_account(account_id, user, db) + key = _encryption_key() + + try: + cookie_str = decrypt_cookie(account.encrypted_cookies, account.iv, key) + except Exception: + account.status = "invalid_cookie" + await db.commit() + await db.refresh(account) + return success_response( + {**_account_to_dict(account), "cookie_valid": False}, + "Cookie decryption failed", + ) + + result = await _verify_weibo_cookie(cookie_str) + + if result["valid"]: + account.status = "active" + account.last_checked_at = datetime.utcnow() + else: + account.status = "invalid_cookie" + + await db.commit() + await db.refresh(account) + + return success_response( + {**_account_to_dict(account), "cookie_valid": result["valid"], + "weibo_screen_name": result.get("screen_name")}, + "Cookie verified" if result["valid"] else "Cookie is invalid or expired", + ) + + +# ---- MANUAL SIGNIN ---- + +async def _get_super_topics(cookie_str: str, weibo_uid: str = "") -> List[dict]: + """ + Fetch followed super topics via weibo.com PC API. + GET /ajax/profile/topicContent?tabid=231093_-_chaohua + Returns list of {"title": str, "containerid": str}. + """ + import re + cookies = _parse_cookie_str(cookie_str) + topics: List[dict] = [] + + async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client: + # First get XSRF-TOKEN by visiting weibo.com + await client.get("https://weibo.com/", headers=WEIBO_HEADERS, cookies=cookies) + xsrf = client.cookies.get("XSRF-TOKEN", "") + + headers = { + **WEIBO_HEADERS, + "X-Requested-With": "XMLHttpRequest", + } + if xsrf: + headers["X-XSRF-TOKEN"] = xsrf + + page = 1 + max_page = 10 + while page <= max_page: + params = {"tabid": "231093_-_chaohua", "page": str(page)} + resp = await client.get( + "https://weibo.com/ajax/profile/topicContent", + params=params, + headers=headers, + cookies=cookies, + ) + data = resp.json() + if data.get("ok") != 1: + break + + topic_list = data.get("data", {}).get("list", []) + if not topic_list: + break + + for item in topic_list: + title = item.get("topic_name", "") or item.get("title", "") + # Extract containerid from oid "1022:100808xxx" or scheme + containerid = "" + oid = item.get("oid", "") + if "100808" in oid: + m = re.search(r"100808[0-9a-fA-F]+", oid) + if m: + containerid = m.group(0) + if not containerid: + scheme = item.get("scheme", "") + m = re.search(r"100808[0-9a-fA-F]+", scheme) + if m: + containerid = m.group(0) + if title and containerid: + topics.append({"title": title, "containerid": containerid}) + + # Check pagination + api_max = data.get("data", {}).get("max_page", 1) + if page >= api_max: + break + page += 1 + + return topics + + +async def _do_signin(cookie_str: str, topic_title: str, containerid: str) -> dict: + """ + Sign in to a single super topic via weibo.com PC API. + GET /p/aj/general/button with full browser-matching parameters. + Returns {"status": "success"|"already_signed"|"failed", "message": str}. + """ + import time as _time + cookies = _parse_cookie_str(cookie_str) + + async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client: + # Get XSRF-TOKEN + await client.get("https://weibo.com/", headers=WEIBO_HEADERS, cookies=cookies) + xsrf = client.cookies.get("XSRF-TOKEN", "") + + headers = { + **WEIBO_HEADERS, + "Referer": f"https://weibo.com/p/{containerid}/super_index", + "X-Requested-With": "XMLHttpRequest", + } + if xsrf: + headers["X-XSRF-TOKEN"] = xsrf + + try: + resp = await client.get( + "https://weibo.com/p/aj/general/button", + params={ + "ajwvr": "6", + "api": "http://i.huati.weibo.com/aj/super/checkin", + "texta": "签到", + "textb": "已签到", + "status": "0", + "id": containerid, + "location": "page_100808_super_index", + "timezone": "GMT+0800", + "lang": "zh-cn", + "plat": "Win32", + "ua": WEIBO_HEADERS["User-Agent"], + "screen": "1920*1080", + "__rnd": str(int(_time.time() * 1000)), + }, + headers=headers, + cookies=cookies, + ) + data = resp.json() + code = str(data.get("code", "")) + msg = data.get("msg", "") + + if code == "100000": + tip = "" + if isinstance(data.get("data"), dict): + tip = data["data"].get("alert_title", "") or data["data"].get("tipMessage", "") + return {"status": "success", "message": tip or "签到成功"} + elif code == "382004": + return {"status": "already_signed", "message": msg or "今日已签到"} + elif code == "382003": + return {"status": "failed", "message": msg or "非超话成员"} + else: + return {"status": "failed", "message": f"code={code}, msg={msg}"} + except Exception as e: + return {"status": "failed", "message": str(e)} + + +@router.post("/{account_id}/signin") +async def manual_signin( + account_id: str, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + Manually trigger sign-in for all followed super topics. + Verifies cookie first, fetches topic list, signs each one, writes logs. + """ + account = await _get_owned_account(account_id, user, db) + key = _encryption_key() + + # Decrypt cookie + try: + cookie_str = decrypt_cookie(account.encrypted_cookies, account.iv, key) + except Exception: + account.status = "invalid_cookie" + await db.commit() + return error_response("Cookie decryption failed", "COOKIE_ERROR", status_code=400) + + # Verify cookie + verify = await _verify_weibo_cookie(cookie_str) + if not verify["valid"]: + account.status = "invalid_cookie" + await db.commit() + return error_response("Cookie is invalid or expired", "COOKIE_EXPIRED", status_code=400) + + # Activate account if pending + if account.status != "active": + account.status = "active" + account.last_checked_at = datetime.utcnow() + + # Get super topics + topics = await _get_super_topics(cookie_str, account.weibo_user_id) + if not topics: + await db.commit() + return success_response( + {"signed": 0, "already_signed": 0, "failed": 0, "topics": []}, + "No super topics found for this account", + ) + + # Sign each topic + results = [] + signed = already = failed = 0 + for topic in topics: + import asyncio + await asyncio.sleep(1.5) # anti-bot delay + r = await _do_signin(cookie_str, topic["title"], topic["containerid"]) + r["topic"] = topic["title"] + results.append(r) + + # Write signin log + log = SigninLog( + account_id=account.id, + topic_title=topic["title"], + status="success" if r["status"] == "success" + else "failed_already_signed" if r["status"] == "already_signed" + else "failed_network", + reward_info={"message": r["message"]}, + signed_at=datetime.utcnow(), + ) + db.add(log) + + if r["status"] == "success": + signed += 1 + elif r["status"] == "already_signed": + already += 1 + else: + failed += 1 + + account.last_checked_at = datetime.utcnow() + await db.commit() + + return success_response( + { + "signed": signed, + "already_signed": already, + "failed": failed, + "total_topics": len(topics), + "details": results, + }, + f"Signed {signed} topics, {already} already signed, {failed} failed", + ) diff --git a/backend/auth_service/app/__pycache__/main.cpython-311.pyc b/backend/auth_service/app/__pycache__/main.cpython-311.pyc index 5ba401727244a6e1df3fe2632bb5d21537802860..c9eefad259f0bb4014734fcca0a3888b99f4b7ea 100644 GIT binary patch delta 5898 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 h4W}Z)Vc6JMkGE&{D)q1ig(CkF_zyayZ7Ki& delta 1843 zcmcIk&2Jk;6yI5Y){gD?qjlo1b>cY5#%W?Fp&@D2rU^<~R4Jeq0Sm}-JQK&M*N$eL zAWq4_2dD(18i_;2fm1-#0wMnc6%`y&&PGBU!>N(V0dWA{^CpEjxia?r_U*jiym`O3 z`}S|WamV|m$K#SU%t20C_6Wl&FPDSb#A($iX6AJ&HT5p6^t)kcXVODwRdVtgEIY@bNdW0>a9GT@h~ zhkB`x_ELW{ux|rDOv&A0Xya`Bi_5|v4TYGK25G1y(~I;{iQsI8#U4pHCLo5LO^B~a zI^4(Hw4ZrsghkiwG}^FflMo<_0Y+);^x-t#koMt}$7U{oS`NrlCOOeXR?d*8PsqbG z*+m{WL!M#dA}NRcgG^zz%_}EIHdEq>oK}WdKc>BIrRj#PE2mj=r)`)i%@L8bq?J(? zIcBS8*e@Tm$5_l{4`f*4m_2@mJ$EAEReG64np<%hwBQ6=tXo@yMpNtoA*teG+2xta#u`*~id~h?Z-NgvBqe zm2dzS+Rk-e)AKd1s}9~_WW+W1u5A%~Q>`cN2V{O3_bnmZ#0?&!Uf}iZtvuUd)jD5+ zwRd3}9)Lsm?MX*2$#!Ay6}}`Io+~Q}+~nZf)oQs~;tJfUmbSxSe5YJw+z$7A5TSES z*I8CxtJV1(a2Eyfs$@xIyvvK{F?%*xp;F(-7dKe(K3|0$_hA}-05hpydqSU<58TNE zYZ4M`d56hltL+V0$h3IoPmn3$4QOORlmiM>=c7P^+!J2~CKCM+plvbgye*e0kHQ_l zgn+f})@FTQ zLaPtC&aHxBykFzA?b|3A8;s^p7Wf>J<`GcyCSOCdh;R>~TRN<%cD{)&Q#)D<2rr}? z_;5|xrf65CRw`Fb#WnCff@$0q9|!weq5c^{mRh$Xf5`Ae7UQYib7ZU5e}i*;>G*p9 zs*lIe#%gzq#scwKgpRy8S}5;abwO}I7i^(F<3qfTa06jMJddkBYyk5dJnV;7Cb0}z z3$APi@ugG=ufxT_4{nm*6wj2XZ3pZ_;Y)_?*h1=6Va+}jKW7JtPyCflS$knoBy$OI zE2qMX$8+fjJ}5KE2wI)OcLc`8cey@i+YVJMF)n_}B^7MbwzF3KPcw4-+I4z{uL{pp zY!=@wj^#Ep$Arvgd(qQ>IiMtuKxJ|Q8yD*YM85;+thFS=AGfgrJpI_H%R!PCMG`bTSy(9chCFF@1E!P zzW3bw{_ar#A9UQd+j$1A-|rO@oBXW9#YSg4@3V&46=lDHd5IBSR~gYQ8E(}v%mR$c z6>SvvjWB`cr8n7|CiWqIVE16J#gY|K)FhcDi+e}NNSw&^!;{1VGg4*+W+DvKHc%TM zQ=7Ld_L_=iyW*&+@T3CWwlbm>+O^#>K(jC^SJWvYXx5HLEC)}>0wjm!2EY;@m!()* z631d`xq~Du6O&`(Z%N67Sh505CgK;9vC9(8$KHvhV=}&B$#OlQ?ZR%p-FN_4K)|Q? zww|Ye(q&~MVTgc{Ht+q)@)OPN^mNuJL;7oWoT+h_jV|Q z(|x(%vb9x39S|Do&;^%@NNC8VI~$dd5}FF>Z9e7f^kly7+IuSUL1@UQxBHcsbFRGa zI!yLMXvqI0-3DU0whe||dc^<5_8%f$(NAy@c9_8P1o{9)b^a8eXt+u9t2ggfwB--JU0p@yV@o`Za{zC+w zvF)rrzncGH?RFtxkR6bcokVEf@qA*7Wf)(EY$I)DzWn8#@|78H z70ln1<;Vgr5~!X)kUY}OKTFuI%^xH=0_R|)t^g?ixpnGXU1-X2xwzUHQiZT4gqMY| za%LKQ9){3RSoiqy(%hNCNaUBK>WOHc$g(G*1gGD~rLJ98Q3OIm5#8&bS<6SesPxb@~7R delta 540 zcmZXQK}Z5Y6ozMP*VQq1S4~|DlOiz{v5g9&lnzmsP$IfS5Y>7Rp}-{QltFY(-l0R6 zI#h?C0;^&*de0MQ4r*@c^l!O$IQn+^ZxgEGoar_Vu-uMu%227Ip_N~7Ho#Zt8DOM z*If;^TFNh%^TyJ0xzxuEFw3^ZI2U1fU_DZTt8CN`&rYw`#&tU#iw>Et zK+zHEP_=sjS6$E^6;CBs$?fE7O$W7%51`$TFk9%Th7iLXjkdyCxfH--C!!0H{6jws z%O^AmPx86fY-1J=V*0lO94c|EDM#Q=(O3#ohyi$4R%r_K_5c*sLjemRBnGJtX)(yg z(1#d>oNEeZ)lRr{HA5M^ZjGwqaROmQjNp@58YLnFukI}gRW|B@8}Gbo&TWoh_D@5^ u7i5#%K>6OV*RsA|8k1<~gZ#&S>Jx9jS}8Xi`F(KZKbDC=yFU=-AN>H?ns0mn diff --git a/backend/auth_service/app/schemas/user.py b/backend/auth_service/app/schemas/user.py index 9755de5..b2c2e1f 100644 --- a/backend/auth_service/app/schemas/user.py +++ b/backend/auth_service/app/schemas/user.py @@ -28,12 +28,17 @@ class UserUpdate(BaseModel): email: Optional[EmailStr] = None is_active: Optional[bool] = None -class UserResponse(UserBase): +class UserResponse(BaseModel): """Schema for user response data""" id: UUID + username: str + email: Optional[EmailStr] = None created_at: datetime is_active: bool - + wx_openid: Optional[str] = None + wx_nickname: Optional[str] = None + wx_avatar: Optional[str] = None + class Config: from_attributes = True # Enable ORM mode @@ -64,3 +69,10 @@ class TokenData(BaseModel): sub: str = Field(..., description="Subject (user ID)") username: str = Field(..., description="Username") exp: Optional[int] = None + + +class WxLoginRequest(BaseModel): + """微信小程序登录请求""" + code: str = Field(..., description="wx.login() 获取的临时登录凭证") + nickname: Optional[str] = Field(None, max_length=100, description="微信昵称") + avatar_url: Optional[str] = Field(None, max_length=500, description="微信头像 URL") diff --git a/backend/shared/__pycache__/config.cpython-311.pyc b/backend/shared/__pycache__/config.cpython-311.pyc index d2cd4b58e995bad9efc66407548b11a0c75e63ac..8d0a160ae20542f326ad93e7ced9a8c48b0ef85e 100644 GIT binary patch delta 359 zcmX@kvy7K_IWI340}!m+wLLRqBd<0ijQut#{856btf_1%f;K?e6rm^~Fi#l7 zV`6Y;ND*maND*De#K5o`h#??KIE5vcK~rq<3&#Iq47WJKBjOzc0z6%AaROPvuFgTO zA(NjmJ!TaHDp#I-j#*J$7RV}+2NIehMdBc~1c;EF%*GO}D+FSSfC!L+A{h_?Rxbx) zvB3zC>x;inZef`&FDl;P(UE+CMGS~8vWVRP@;y6>FR+LM(M1;V$r7xtd|(qmn*B7{ zixehjv)W6cn*58yCO1E&G$+-rNMrI5R%ZukMyU@Bn8bvbk08-62q_x|VZ{l07lo9r M2q`u2gCN+w06G#+@c;k- delta 266 zcmZ3+dz^=NIWI340}zPht;8Qf(WoG zX%LGIMt~epe0B0cmg$qLSe^L5DnOEcn(RfglTWeQOQGug#bJ}1pHiBWYFDH>S((k* mL7Y+Q0|O>8A?71U^b11Dgh5zwg5E_Tr7J>84g4Sob`Jo@ZZx$3 diff --git a/backend/shared/config.py b/backend/shared/config.py index b44cfbd..e6e676f 100644 --- a/backend/shared/config.py +++ b/backend/shared/config.py @@ -24,7 +24,11 @@ class SharedSettings(BaseSettings): # Cookie encryption COOKIE_ENCRYPTION_KEY: str = "change-me-in-production" - + + # 微信小程序 + WX_APPID: str = "" + WX_SECRET: str = "" + # Environment ENVIRONMENT: str = "development" diff --git a/backend/shared/models/__pycache__/user.cpython-311.pyc b/backend/shared/models/__pycache__/user.cpython-311.pyc index 9ebac97f106c61c32dba3aff4d5c7e6e9daa6a0a..257278c61c7cf51c974a63698adc4b80a58f3598 100644 GIT binary patch delta 625 zcmdnNe^8KjIWI340}w3RwLSCSLS9D3W0Uz9dl;D~&t}xEXI{g#jER9^H4sBU6ekly zDhm*^rf@G~1j+z01f(#7;2Mr)EI?5phJYw8xOyIR^%!Pz!`1Ra)Yij{0UC_%5FU`? zR9+y-m&FfLw+zTw4dXDZ;X`x009cP8&@_HD86lvIaEd^RV2aQh;bqJ~Q-GL37;IFO zNQy`cLzHNWXfT7O*yKQ_K1S`y517Oz|7V)Y`_ciZ=4A?y$o#@Mc@pyvM(N4jEP9M$ zlXtP$v8e&Yi_|CoW06+A#aUhvpI?xgmzi>l8_3MdOwP_r%uNN0Czd6aBoydKIMSQ1s?Z{JnmO`+#6hPh{;Syn-Ml6Y=QI4=sD3FOfHC7Tokjo zB4*Lxe?vfQ0^5YL31u^qCRRoxPzZ}IWI340}wnkUzh2%ke89sZZaQZ&*Usdk;&^ArR3Sya4lnEU|0>r5D>-5 z#E{C8$^|63vv?-wGl>Utqp9EptKdsvUc-YX!w-}ZNa0Q4OW|K5u#6dKE)YY_6-*Iq zVTck+5ejC|6rOyOsZZX-#VRH_KP5G$JT)^ZKPD+LIXg8kC8i{`xTH8nPj7N9^R&rI zEZ-O List[WeiboSuperTopic]: - """Get list of super topics for account""" + """ + Fetch the real list of followed super topics from Weibo API. + Delegates to WeiboClient.get_super_topics(). + """ try: - # Mock implementation - in real system, fetch from Weibo API - # Simulate API call delay - await asyncio.sleep(1) - - # Return mock super topics - return [ - WeiboSuperTopic( - id="topic_001", - title="Python编程", - url="https://weibo.com/p/100808xxx", - is_signed=False, - sign_url="https://weibo.com/p/aj/general/button", - reward_exp=2, - reward_credit=1 - ), - WeiboSuperTopic( - id="topic_002", - title="人工智能", - url="https://weibo.com/p/100808yyy", - is_signed=False, - sign_url="https://weibo.com/p/aj/general/button", - reward_exp=2, - reward_credit=1 - ), - WeiboSuperTopic( - id="topic_003", - title="机器学习", - url="https://weibo.com/p/100808zzz", - is_signed=True, # Already signed - sign_url="https://weibo.com/p/aj/general/button", - reward_exp=2, - reward_credit=1 - ) - ] + topics = await self.weibo_client.get_super_topics(account) + logger.info(f"Fetched {len(topics)} super topics for account {account.weibo_user_id}") + return topics except Exception as e: - logger.error(f"Error fetching super topics: {e}") + logger.error(f"Error fetching super topics for {account.weibo_user_id}: {e}") return [] async def _execute_topic_signin(self, account: WeiboAccount, topics: List[WeiboSuperTopic], task_id: str) -> Dict[str, List[str]]: - """Execute sign-in for each super topic""" - signed = [] - already_signed = [] - errors = [] - - for topic in topics: + """ + Execute sign-in for each super topic with retry logic and + per-topic progress updates. + """ + signed: List[str] = [] + already_signed: List[str] = [] + errors: List[str] = [] + max_retries = 2 + + total = len(topics) if topics else 1 + for idx, topic in enumerate(topics): + # Update progress: 50% -> 80% spread across topics + pct = 50 + int((idx / total) * 30) + await self._update_task_progress(task_id, pct) + try: - # Add small delay between requests - await asyncio.sleep(random.uniform(0.5, 1.5)) - if topic.is_signed: already_signed.append(topic.title) - # Write log for already signed await self._write_signin_log( account_id=str(account.id), topic_title=topic.title, status="failed_already_signed", reward_info=None, - error_message="Already signed today" + error_message="Already signed today", ) continue - - # Execute signin for this topic - success, reward_info, error_msg = await self.weibo_client.sign_super_topic( - account=account, - topic=topic, - task_id=task_id - ) - - if success: - signed.append(topic.title) - logger.info(f"✅ Successfully signed topic: {topic.title}") - - # Write success log - await self._write_signin_log( - account_id=str(account.id), - topic_title=topic.title, - status="success", - reward_info=reward_info, - error_message=None + + # Retry loop + last_error: Optional[str] = None + succeeded = False + for attempt in range(1, max_retries + 1): + # Inter-request delay (longer for retries) + delay = random.uniform(1.0, 3.0) * attempt + await asyncio.sleep(delay) + + success, reward_info, error_msg = await self.weibo_client.sign_super_topic( + account=account, + topic=topic, + task_id=task_id, ) - else: - errors.append(f"Failed to sign topic: {topic.title}") - - # Write failure log + + if success: + # "Already signed" from the API is still a success + if error_msg and "already" in error_msg.lower(): + already_signed.append(topic.title) + await self._write_signin_log( + account_id=str(account.id), + topic_title=topic.title, + status="failed_already_signed", + reward_info=None, + error_message=error_msg, + ) + else: + signed.append(topic.title) + logger.info(f"✅ Signed topic: {topic.title}") + await self._write_signin_log( + account_id=str(account.id), + topic_title=topic.title, + status="success", + reward_info=reward_info, + error_message=None, + ) + succeeded = True + break + + last_error = error_msg + logger.warning( + f"Attempt {attempt}/{max_retries} failed for " + f"{topic.title}: {error_msg}" + ) + + if not succeeded: + errors.append(f"{topic.title}: {last_error}") await self._write_signin_log( account_id=str(account.id), topic_title=topic.title, status="failed_network", reward_info=None, - error_message=error_msg + error_message=last_error, ) - + except Exception as e: - error_msg = f"Error signing topic {topic.title}: {str(e)}" - logger.error(error_msg) - errors.append(error_msg) - - # Write error log + err = f"Error signing topic {topic.title}: {e}" + logger.error(err) + errors.append(err) await self._write_signin_log( account_id=str(account.id), topic_title=topic.title, status="failed_network", reward_info=None, - error_message=str(e) + error_message=str(e), ) - + return { "signed": signed, - "already_signed": already_signed, - "errors": errors + "already_signed": already_signed, + "errors": errors, } async def _write_signin_log( diff --git a/backend/signin_executor/app/services/weibo_client.py b/backend/signin_executor/app/services/weibo_client.py index ff36d23..e8ec90b 100644 --- a/backend/signin_executor/app/services/weibo_client.py +++ b/backend/signin_executor/app/services/weibo_client.py @@ -1,6 +1,13 @@ """ Weibo API Client -Handles all interactions with Weibo.com, including login, sign-in, and data fetching +Handles all interactions with Weibo.com, including cookie verification, +super topic listing, and sign-in execution. + +Key Weibo API endpoints used: +- Cookie验证: GET https://m.weibo.cn/api/config +- 超话列表: GET https://m.weibo.cn/api/container/getIndex (containerid=100803_-_followsuper) +- 超话签到: GET https://m.weibo.cn/api/container/getIndex (containerid=100808{topic_id}) + POST https://huati.weibo.cn/aj/super/checkin (actual sign-in) """ import os @@ -9,6 +16,9 @@ import httpx import asyncio import logging import random +import json +import re +import time from typing import Dict, Any, Optional, List, Tuple # Add parent directory to path for imports @@ -23,200 +33,313 @@ from app.services.antibot import antibot logger = logging.getLogger(__name__) +# --------------------------------------------------------------------------- +# Constants – Weibo mobile API base URLs +# --------------------------------------------------------------------------- +WEIBO_PC_API = "https://weibo.com" +WEIBO_HUATI_CHECKIN = "http://i.huati.weibo.com/aj/super/checkin" + + class WeiboClient: - """Client for interacting with Weibo API""" - + """Client for interacting with Weibo mobile API.""" + def __init__(self): - # Use antibot module for dynamic headers - self.base_headers = antibot.build_headers() - - async def verify_cookies(self, account: WeiboAccount) -> bool: - """Verify if Weibo cookies are still valid""" - try: - # Decrypt cookies using shared crypto module - cookies_dict = self._decrypt_cookies(account.encrypted_cookies, account.iv) - - if not cookies_dict: - logger.error(f"Failed to decrypt cookies for account {account.weibo_user_id}") - return False - - # Get proxy (with fallback to direct connection) - proxy = await antibot.get_proxy() - - # Use dynamic headers with random User-Agent - headers = antibot.build_headers() - - # Add random delay before request - delay = antibot.get_random_delay() - await asyncio.sleep(delay) - - async with httpx.AsyncClient( - cookies=cookies_dict, - headers=headers, - proxies=proxy, - timeout=10.0 - ) as client: - response = await client.get("https://weibo.com/mygroups", follow_redirects=True) - - if response.status_code == 200 and "我的首页" in response.text: - logger.info(f"Cookies for account {account.weibo_user_id} are valid") - return True - else: - logger.warning(f"Cookies for account {account.weibo_user_id} are invalid") - return False - except Exception as e: - logger.error(f"Error verifying cookies: {e}") - return False - - async def get_super_topics(self, account: WeiboAccount) -> List[WeiboSuperTopic]: - """Get list of super topics for an account""" - try: - # Mock implementation - in real system, this would involve complex API calls - # Simulate API call delay - await asyncio.sleep(random.uniform(1.0, 2.0)) - - # Return mock data - return [ - WeiboSuperTopic(id="topic_001", title="Python编程", url="...", is_signed=False), - WeiboSuperTopic(id="topic_002", title="人工智能", url="...", is_signed=False), - WeiboSuperTopic(id="topic_003", title="机器学习", url="...", is_signed=True) - ] - except Exception as e: - logger.error(f"Error fetching super topics: {e}") - return [] - - async def sign_super_topic( - self, - account: WeiboAccount, - topic: WeiboSuperTopic, - task_id: str - ) -> Tuple[bool, Optional[Dict[str, Any]], Optional[str]]: - """ - Execute sign-in for a single super topic - Returns: (success, reward_info, error_message) - """ - try: - # Decrypt cookies using shared crypto module - cookies_dict = self._decrypt_cookies(account.encrypted_cookies, account.iv) - - if not cookies_dict: - error_msg = "Failed to decrypt cookies" - logger.error(error_msg) - return False, None, error_msg - - # Get proxy (with fallback to direct connection) - proxy = await antibot.get_proxy() - - # Use dynamic headers with random User-Agent - headers = antibot.build_headers() - - # Add random delay before request (anti-bot protection) - delay = antibot.get_random_delay() - await asyncio.sleep(delay) - - # Prepare request payload - payload = { - "ajwvr": "6", - "api": "http://i.huati.weibo.com/aj/super/checkin", - "id": topic.id, - "location": "page_100808_super_index", - "refer_flag": "100808_-_1", - "refer_lflag": "100808_-_1", - "ua": headers["User-Agent"], - "is_new": "1", - "is_from_ad": "0", - "ext": "mi_898_1_0_0" - } - - # In a real scenario, we might need to call browser automation service - # to get signed parameters or handle JS challenges - - # Simulate API call - await asyncio.sleep(random.uniform(0.5, 1.5)) - - # Mock response - assume success - response_data = { - "code": "100000", - "msg": "签到成功", - "data": { - "tip": "签到成功", - "alert_title": "签到成功", - "alert_subtitle": "恭喜你成为今天第12345位签到的人", - "reward": {"exp": 2, "credit": 1} - } - } - - if response_data.get("code") == "100000": - logger.info(f"Successfully signed topic: {topic.title}") - reward_info = response_data.get("data", {}).get("reward", {}) - return True, reward_info, None - elif response_data.get("code") == "382004": - logger.info(f"Topic {topic.title} already signed today") - return True, None, "Already signed" - else: - error_msg = response_data.get("msg", "Unknown error") - logger.error(f"Failed to sign topic {topic.title}: {error_msg}") - return False, None, error_msg - - except Exception as e: - error_msg = f"Exception signing topic {topic.title}: {str(e)}" - logger.error(error_msg) - return False, None, error_msg - + self.timeout = httpx.Timeout(15.0, connect=10.0) + + # ------------------------------------------------------------------ + # Cookie helpers + # ------------------------------------------------------------------ def _decrypt_cookies(self, encrypted_cookies: str, iv: str) -> Dict[str, str]: """ Decrypt cookies using AES-256-GCM from shared crypto module. Returns dict of cookie key-value pairs. """ try: - # Derive encryption key from shared settings key = derive_key(shared_settings.COOKIE_ENCRYPTION_KEY) - - # Decrypt using shared crypto module plaintext = decrypt_cookie(encrypted_cookies, iv, key) - - # Parse cookie string into dict - # Expected format: "key1=value1; key2=value2; ..." - cookies_dict = {} - for cookie_pair in plaintext.split(";"): - cookie_pair = cookie_pair.strip() - if "=" in cookie_pair: - key, value = cookie_pair.split("=", 1) - cookies_dict[key.strip()] = value.strip() - + + cookies_dict: Dict[str, str] = {} + for pair in plaintext.split(";"): + pair = pair.strip() + if "=" in pair: + k, v = pair.split("=", 1) + cookies_dict[k.strip()] = v.strip() return cookies_dict - except Exception as e: logger.error(f"Failed to decrypt cookies: {e}") return {} - - async def get_proxy(self) -> Optional[Dict[str, str]]: - """Get a proxy from the proxy pool service""" + + def _build_client( + self, + cookies: Dict[str, str], + headers: Optional[Dict[str, str]] = None, + proxy: Optional[Dict[str, str]] = None, + ) -> httpx.AsyncClient: + """Create a configured httpx.AsyncClient for weibo.com PC API.""" + hdrs = headers or antibot.build_headers() + hdrs["Referer"] = "https://weibo.com/" + hdrs["Accept"] = "*/*" + hdrs["Accept-Language"] = "zh-CN,zh;q=0.9,en;q=0.8" + return httpx.AsyncClient( + cookies=cookies, + headers=hdrs, + proxies=proxy, + timeout=self.timeout, + follow_redirects=True, + ) + + # ------------------------------------------------------------------ + # 1. Cookie verification + # ------------------------------------------------------------------ + async def verify_cookies(self, account: WeiboAccount) -> bool: + """ + Verify if Weibo cookies are still valid using the PC API. + GET https://weibo.com/ajax/side/cards?count=1 + Returns ok=1 when logged in. + """ + cookies_dict = self._decrypt_cookies(account.encrypted_cookies, account.iv) + if not cookies_dict: + logger.error(f"Failed to decrypt cookies for account {account.weibo_user_id}") + return False + + proxy = await antibot.get_proxy() + headers = antibot.build_headers() + delay = antibot.get_random_delay() + await asyncio.sleep(delay) + try: - async with httpx.AsyncClient(timeout=5.0) as client: - response = await client.get(f"{settings.PROXY_POOL_URL}/get") - if response.status_code == 200: - proxy_info = response.json() - return { - "http://": f"http://{proxy_info['proxy']}", - "https://": f"https://{proxy_info['proxy']}" - } + async with self._build_client(cookies_dict, headers, proxy) as client: + resp = await client.get( + f"{WEIBO_PC_API}/ajax/side/cards", + params={"count": "1"}, + ) + if resp.status_code != 200: + logger.warning( + f"Side cards API returned {resp.status_code} for {account.weibo_user_id}" + ) + return False + + data = resp.json() + if data.get("ok") == 1: + logger.info(f"Cookies valid for account {account.weibo_user_id}") + return True else: - return None + logger.warning(f"Cookies invalid for account {account.weibo_user_id}") + return False except Exception as e: - logger.error(f"Failed to get proxy: {e}") - return None - - async def get_browser_fingerprint(self) -> Dict[str, Any]: - """Get a browser fingerprint from the generator service""" + logger.error(f"Error verifying cookies for {account.weibo_user_id}: {e}") + return False + + # ------------------------------------------------------------------ + # 2. Fetch super topic list + # ------------------------------------------------------------------ + async def get_super_topics(self, account: WeiboAccount) -> List[WeiboSuperTopic]: + """ + Fetch the list of super topics the account has followed. + + Uses the PC API: + GET https://weibo.com/ajax/profile/topicContent?tabid=231093_-_chaohua&page={n} + + Response contains data.list[] with topic objects including: + - topic_name: super topic name + - oid: "1022:100808xxx" (containerid) + - scheme: "sinaweibo://pageinfo?containerid=100808xxx" + """ + cookies_dict = self._decrypt_cookies(account.encrypted_cookies, account.iv) + if not cookies_dict: + return [] + + proxy = await antibot.get_proxy() + headers = antibot.build_headers() + all_topics: List[WeiboSuperTopic] = [] + page = 1 + max_pages = 10 + try: - # Mock implementation - return { - "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", - "screen_resolution": "1920x1080", - "timezone": "Asia/Shanghai", - "plugins": ["PDF Viewer", "Chrome PDF Viewer", "Native Client"] - } + async with self._build_client(cookies_dict, headers, proxy) as client: + # Get XSRF-TOKEN first + await client.get(f"{WEIBO_PC_API}/", params={}) + xsrf = client.cookies.get("XSRF-TOKEN", "") + if xsrf: + client.headers["X-XSRF-TOKEN"] = xsrf + client.headers["X-Requested-With"] = "XMLHttpRequest" + + while page <= max_pages: + delay = antibot.get_random_delay() + await asyncio.sleep(delay) + + resp = await client.get( + f"{WEIBO_PC_API}/ajax/profile/topicContent", + params={ + "tabid": "231093_-_chaohua", + "page": str(page), + }, + ) + + if resp.status_code != 200: + logger.warning(f"Topic list API returned {resp.status_code}") + break + + body = resp.json() + if body.get("ok") != 1: + break + + topic_list = body.get("data", {}).get("list", []) + if not topic_list: + break + + for item in topic_list: + topic = self._parse_pc_topic(item) + if topic: + all_topics.append(topic) + + logger.info( + f"Page {page}: found {len(topic_list)} topics " + f"(total so far: {len(all_topics)})" + ) + + api_max = body.get("data", {}).get("max_page", 1) + if page >= api_max: + break + page += 1 + except Exception as e: - logger.error(f"Failed to get browser fingerprint: {e}") - return {} + logger.error(f"Error fetching super topics: {e}") + + logger.info( + f"Fetched {len(all_topics)} super topics for account {account.weibo_user_id}" + ) + return all_topics + + def _parse_pc_topic(self, item: Dict[str, Any]) -> Optional[WeiboSuperTopic]: + """Parse a topic item from /ajax/profile/topicContent response.""" + title = item.get("topic_name", "") or item.get("title", "") + if not title: + return None + + # Extract containerid from oid "1022:100808xxx" or scheme + containerid = "" + oid = item.get("oid", "") + if "100808" in oid: + match = re.search(r"100808[0-9a-fA-F]+", oid) + if match: + containerid = match.group(0) + if not containerid: + scheme = item.get("scheme", "") + match = re.search(r"100808[0-9a-fA-F]+", scheme) + if match: + containerid = match.group(0) + if not containerid: + return None + + return WeiboSuperTopic( + id=containerid, + title=title, + url=f"https://weibo.com/p/{containerid}/super_index", + is_signed=False, + sign_url=f"{WEIBO_PC_API}/p/aj/general/button", + ) + + # ------------------------------------------------------------------ + # 3. Execute sign-in for a single super topic + # ------------------------------------------------------------------ + async def sign_super_topic( + self, + account: WeiboAccount, + topic: WeiboSuperTopic, + task_id: str, + ) -> Tuple[bool, Optional[Dict[str, Any]], Optional[str]]: + """ + Execute sign-in for a single super topic via weibo.com PC API. + + GET https://weibo.com/p/aj/general/button + ?api=http://i.huati.weibo.com/aj/super/checkin&id={topic_id} + + Returns: (success, reward_info, error_message) + """ + cookies_dict = self._decrypt_cookies(account.encrypted_cookies, account.iv) + if not cookies_dict: + return False, None, "Failed to decrypt cookies" + + proxy = await antibot.get_proxy() + headers = antibot.build_headers() + + # Anti-bot delay + delay = antibot.get_random_delay() + await asyncio.sleep(delay) + + try: + headers["Referer"] = f"https://weibo.com/p/{topic.id}/super_index" + headers["X-Requested-With"] = "XMLHttpRequest" + + async with httpx.AsyncClient( + cookies=cookies_dict, + headers=headers, + proxies=proxy, + timeout=self.timeout, + follow_redirects=True, + ) as client: + # Get XSRF-TOKEN + await client.get(f"{WEIBO_PC_API}/") + xsrf = client.cookies.get("XSRF-TOKEN", "") + if xsrf: + client.headers["X-XSRF-TOKEN"] = xsrf + + resp = await client.get( + f"{WEIBO_PC_API}/p/aj/general/button", + params={ + "ajwvr": "6", + "api": WEIBO_HUATI_CHECKIN, + "texta": "签到", + "textb": "已签到", + "status": "0", + "id": topic.id, + "location": "page_100808_super_index", + "timezone": "GMT+0800", + "lang": "zh-cn", + "plat": "Win32", + "ua": headers.get("User-Agent", ""), + "screen": "1920*1080", + "__rnd": str(int(time.time() * 1000)), + }, + ) + + if resp.status_code != 200: + return False, None, f"HTTP {resp.status_code}" + + body = resp.json() + code = str(body.get("code", "")) + msg = body.get("msg", "") + data = body.get("data", {}) + + if code == "100000": + tip = "" + if isinstance(data, dict): + tip = data.get("alert_title", "") or data.get("tipMessage", "签到成功") + logger.info(f"Checkin success for {topic.title}: {tip}") + reward_info = {} + if isinstance(data, dict): + reward_info = { + "tip": data.get("tipMessage", ""), + "alert_title": data.get("alert_title", ""), + "alert_subtitle": data.get("alert_subtitle", ""), + } + return True, reward_info, None + + elif code == "382004": + logger.info(f"Topic {topic.title} already signed today") + return False, None, "Already signed today" + + elif code == "382003": + logger.warning(f"Not a member of topic {topic.title}") + return False, None, "Not a member of this super topic" + + else: + logger.warning(f"Checkin unexpected code={code} msg={msg} for {topic.title}") + return False, None, f"Unexpected: code={code}, msg={msg}" + + except httpx.TimeoutException: + return False, None, "Request timeout" + except Exception as e: + logger.error(f"Checkin error for {topic.title}: {e}") + return False, None, str(e) diff --git a/backend/weibo_hotsign.db b/backend/weibo_hotsign.db index f8316944f2f34654426633043ac979aa39940f0f..5b8b19a457dc4c97566bf0c6d1b3363f292fef6a 100644 GIT binary patch delta 242 zcmZoTz|`=7X@ayM7Xt$WHxR=B*F+s-MJ@)tyq&y!`x&@6Y#2Cf_|Nhs@$vDP^WNpU z%EQB1!=JseaSuCJlPx2=xVSiDOXTE}d>;(TE8_DDQu8uX6hd4hLUa^>{JhNM?7YO> zRD?)kSz<|I5tu)D1Gfkhm*(agPG?pY{vQl$CLiWop`O6SE^aK&*vOojmy%kMnNks7 nTAW%`91k}g%;lb}&nK#iDXHM^rvO%<0aw5I48O^QMG67{G3H9i delta 99 zcmZp8z|?SnX@ayM8v_Fa7ZAe$+e95>c{T>Uyq&!KKNvVUYZ& {rc}"); last = rc + if rc == 20000000 and isinstance(d.get('data'), dict) and d['data'].get('alt'): + alt_token = d['data']['alt']; break + if rc in (50114004, 50050002): sys.exit(1) + if not alt_token: sys.exit(1) + sso = requests.Session() + sso.headers.update(WEIBO_HEADERS) + resp = sso.get( + f"https://login.sina.com.cn/sso/login.php?entry=weibo&returntype=TEXT" + f"&crossdomain=1&cdult=3&domain=weibo.com&alt={alt_token}&savestate=30" + f"&callback=STK_{int(time.time()*1000)}", + allow_redirects=True, timeout=15) + sso_data = parse_jsonp(resp.text) + uid = str(sso_data.get('uid', '')) + nick = sso_data.get('nick', '') + for u in sso_data.get('crossDomainUrlList', []): + if isinstance(u, str) and u.startswith('http'): + try: sso.get(u, allow_redirects=True, timeout=10) + except: pass + cookies = {} + for c in sso.cookies: + if c.domain and 'weibo.com' in c.domain: + cookies[c.name] = c.value + print(f" ✅ uid={uid}, nick={nick}") + save_cookies(cookies, uid, nick) + return cookies, uid, nick + + +def get_super_topics(sess, xsrf, uid): + """获取关注的超话列表""" + topics = [] + page = 1 + while page <= 10: + r = sess.get( + 'https://weibo.com/ajax/profile/topicContent', + params={'tabid': '231093_-_chaohua', 'page': str(page)}, + headers={ + 'Referer': f'https://weibo.com/u/page/follow/{uid}/231093_-_chaohua', + 'X-XSRF-TOKEN': xsrf, + 'X-Requested-With': 'XMLHttpRequest', + }, + timeout=10, + ) + d = r.json() + if d.get('ok') != 1: + break + topic_list = d.get('data', {}).get('list', []) + if not topic_list: + break + for item in topic_list: + title = item.get('topic_name', '') or item.get('title', '') + # 从 oid "1022:100808xxx" 提取 containerid + containerid = '' + oid = item.get('oid', '') + m = re.search(r'100808[0-9a-fA-F]+', oid) + if m: + containerid = m.group(0) + if not containerid: + scheme = item.get('scheme', '') + m = re.search(r'100808[0-9a-fA-F]+', scheme) + if m: + containerid = m.group(0) + if title and containerid: + topics.append({'title': title, 'containerid': containerid}) + max_page = d.get('data', {}).get('max_page', 1) + if page >= max_page: + break + page += 1 + return topics + + +def do_signin(sess, xsrf, containerid, topic_title): + """ + 签到单个超话 + 完整参数来自浏览器抓包: + GET /p/aj/general/button?ajwvr=6&api=http://i.huati.weibo.com/aj/super/checkin + &texta=签到&textb=已签到&status=0&id={containerid} + &location=page_100808_super_index&... + """ + r = sess.get( + 'https://weibo.com/p/aj/general/button', + params={ + 'ajwvr': '6', + 'api': 'http://i.huati.weibo.com/aj/super/checkin', + 'texta': '签到', + 'textb': '已签到', + 'status': '0', + 'id': containerid, + 'location': 'page_100808_super_index', + 'timezone': 'GMT+0800', + 'lang': 'zh-cn', + 'plat': 'Win32', + 'ua': WEIBO_HEADERS['User-Agent'], + 'screen': '1920*1080', + '__rnd': str(int(time.time() * 1000)), + }, + headers={ + 'Referer': f'https://weibo.com/p/{containerid}/super_index', + 'X-Requested-With': 'XMLHttpRequest', + 'X-XSRF-TOKEN': xsrf, + }, + timeout=10, + ) + try: + d = r.json() + code = str(d.get('code', '')) + msg = d.get('msg', '') + data = d.get('data', {}) + if code == '100000': + tip = '' + if isinstance(data, dict): + tip = data.get('alert_title', '') or data.get('tipMessage', '') + return {'status': 'success', 'message': tip or '签到成功', 'data': data} + elif code == '382004': + return {'status': 'already_signed', 'message': msg or '今日已签到'} + elif code == '382003': + return {'status': 'failed', 'message': msg or '非超话成员'} + else: + return {'status': 'failed', 'message': f'code={code}, msg={msg}'} + except Exception as e: + return {'status': 'failed', 'message': f'非 JSON: {r.text[:200]}'} + + +# ================================================================ +# Main +# ================================================================ +print("=" * 60) +print("微博超话自动签到 - 完整流程验证") +print("=" * 60) + +# Step 1: 获取 cookie +print("\n--- Step 1: 初始化 ---") +cookies, uid, nick = load_cookies() +if not cookies: + cookies, uid, nick = qrcode_login() + +# 建立 session +sess = requests.Session() +sess.headers.update(WEIBO_HEADERS) +for k, v in cookies.items(): + sess.cookies.set(k, v, domain='.weibo.com') +sess.get('https://weibo.com/', timeout=10) +xsrf = sess.cookies.get('XSRF-TOKEN', '') +print(f" XSRF: {'✅' if xsrf else '❌ MISSING'}") + +# Step 2: 获取超话列表 +print(f"\n--- Step 2: 获取超话列表 ---") +topics = get_super_topics(sess, xsrf, uid) +print(f" 找到 {len(topics)} 个超话:") +for i, t in enumerate(topics): + print(f" [{i+1}] {t['title']} ({t['containerid'][:20]}...)") + +if not topics: + print(" ❌ 没有找到超话,退出") + sys.exit(1) + +# Step 3: 逐个签到 +print(f"\n--- Step 3: 签到 ({len(topics)} 个超话) ---") +signed = already = failed = 0 +results = [] +for i, t in enumerate(topics): + print(f"\n [{i+1}/{len(topics)}] {t['title']}", end=' ... ') + r = do_signin(sess, xsrf, t['containerid'], t['title']) + results.append({**r, 'topic': t['title']}) + + if r['status'] == 'success': + signed += 1 + print(f"✅ {r['message']}") + elif r['status'] == 'already_signed': + already += 1 + print(f"ℹ️ {r['message']}") + else: + failed += 1 + print(f"❌ {r['message']}") + + if i < len(topics) - 1: + time.sleep(1.5) # 防封间隔 + +# Step 4: 汇总 +print(f"\n{'=' * 60}") +print(f"签到完成!") +print(f" ✅ 成功: {signed}") +print(f" ℹ️ 已签: {already}") +print(f" ❌ 失败: {failed}") +print(f" 📊 总计: {len(topics)} 个超话") +print(f"{'=' * 60}") diff --git a/debug_qrcode_flow.py b/debug_qrcode_flow.py new file mode 100644 index 0000000..9eddf48 --- /dev/null +++ b/debug_qrcode_flow.py @@ -0,0 +1,509 @@ +""" +微博扫码登录 + 完整业务流程调试脚本。 + +模式: + python debug_qrcode_flow.py # 直连模式:扫码 + 添加 + 验证 + 签到 + python debug_qrcode_flow.py --frontend # 前端模式:通过 Flask 路由 + python debug_qrcode_flow.py --api-only # 仅测试后端 API(跳过扫码,用已有账号) +""" + +import re +import json +import time +import base64 +import requests +import sys + +# ---- 配置 ---- +AUTH_BASE_URL = 'http://localhost:8001' +API_BASE_URL = 'http://localhost:8000' +FRONTEND_URL = 'http://localhost:5000' +TEST_EMAIL = 'admin@example.com' +TEST_PASSWORD = 'Admin123!' + +WEIBO_HEADERS = { + 'User-Agent': ( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' + '(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' + ), + 'Referer': 'https://weibo.com/', + 'Accept': '*/*', + 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', +} + +WEIBO_SESSION = requests.Session() +WEIBO_SESSION.headers.update(WEIBO_HEADERS) + + +def parse_jsonp(text): + m = re.search(r'\((.*)\)', text, re.DOTALL) + if m: + return json.loads(m.group(1)) + try: + return json.loads(text) + except Exception: + return None + + +def api_headers(token): + return {'Content-Type': 'application/json', 'Authorization': f'Bearer {token}'} + + +# ================================================================ +# STEP 0: 登录系统 +# ================================================================ +def step0_login(): + print("=" * 60) + print("STEP 0: 登录系统") + print("=" * 60) + resp = requests.post( + f'{AUTH_BASE_URL}/auth/login', + json={'email': TEST_EMAIL, 'password': TEST_PASSWORD}, + timeout=10, + ) + print(f" Status: {resp.status_code}") + if resp.status_code != 200: + print(f" [ERROR] 登录失败: {resp.text[:300]}") + return None + data = resp.json() + token = data['access_token'] + user = data.get('user', {}) + print(f" 用户: {user.get('username')} ({user.get('email')})") + print(f" token: {token[:50]}...") + + # 验证 token 对 API 服务有效 + verify = requests.get( + f'{API_BASE_URL}/api/v1/accounts', + headers=api_headers(token), + timeout=10, + ) + print(f" 验证 API 服务: GET /accounts -> {verify.status_code}") + if verify.status_code != 200: + print(f" [ERROR] API 服务 token 无效: {verify.text[:300]}") + return None + print(f" ✅ token 有效") + return token + + +# ================================================================ +# STEP 1: 清理重复账号 +# ================================================================ +def step1_cleanup(token): + print("\n" + "=" * 60) + print("STEP 1: 清理重复账号") + print("=" * 60) + resp = requests.get(f'{API_BASE_URL}/api/v1/accounts', headers=api_headers(token), timeout=10) + accounts = resp.json().get('data', []) + print(f" 当前账号数: {len(accounts)}") + + if len(accounts) <= 1: + print(" 无需清理") + return accounts[0]['id'] if accounts else None + + # 按 weibo_user_id 分组,每组只保留最新的 + groups = {} + for acc in accounts: + wid = acc['weibo_user_id'] + if wid not in groups: + groups[wid] = [] + groups[wid].append(acc) + + keep_id = None + for wid, accs in groups.items(): + accs.sort(key=lambda a: a['created_at'], reverse=True) + keep = accs[0] + keep_id = keep['id'] + print(f" 保留: {keep['id'][:8]}... ({keep['remark']}) created={keep['created_at']}") + for dup in accs[1:]: + print(f" 删除: {dup['id'][:8]}... ({dup['remark']}) created={dup['created_at']}") + del_resp = requests.delete( + f'{API_BASE_URL}/api/v1/accounts/{dup["id"]}', + headers=api_headers(token), + timeout=10, + ) + print(f" -> {del_resp.status_code}") + + # 验证 + resp2 = requests.get(f'{API_BASE_URL}/api/v1/accounts', headers=api_headers(token), timeout=10) + remaining = resp2.json().get('data', []) + print(f" 清理后账号数: {len(remaining)}") + return keep_id + + +# ================================================================ +# STEP 2: 验证 Cookie(POST /accounts/{id}/verify) +# ================================================================ +def step2_verify(token, account_id): + print("\n" + "=" * 60) + print("STEP 2: 验证 Cookie") + print("=" * 60) + resp = requests.post( + f'{API_BASE_URL}/api/v1/accounts/{account_id}/verify', + headers=api_headers(token), + timeout=20, + ) + print(f" Status: {resp.status_code}") + print(f" Body: {resp.text[:500]}") + + if resp.status_code == 200: + data = resp.json().get('data', {}) + valid = data.get('cookie_valid') + status = data.get('status') + name = data.get('weibo_screen_name', '') + print(f" cookie_valid: {valid}") + print(f" status: {status}") + print(f" screen_name: {name}") + if valid: + print(" ✅ Cookie 有效,账号已激活") + else: + print(" ❌ Cookie 无效") + return valid + else: + print(f" ❌ 验证失败") + return False + + +# ================================================================ +# STEP 3: 手动签到(POST /accounts/{id}/signin) +# ================================================================ +def step3_signin(token, account_id): + print("\n" + "=" * 60) + print("STEP 3: 手动签到") + print("=" * 60) + print(" 调用 POST /accounts/{id}/signin ...") + print(" (这可能需要一些时间,每个超话间隔 1.5 秒)") + resp = requests.post( + f'{API_BASE_URL}/api/v1/accounts/{account_id}/signin', + headers=api_headers(token), + timeout=300, # 签到可能很慢 + ) + print(f" Status: {resp.status_code}") + + if resp.status_code == 200: + result = resp.json() + data = result.get('data', {}) + msg = result.get('message', '') + print(f" 消息: {msg}") + print(f" 签到成功: {data.get('signed', 0)}") + print(f" 已签过: {data.get('already_signed', 0)}") + print(f" 失败: {data.get('failed', 0)}") + print(f" 总超话数: {data.get('total_topics', 0)}") + details = data.get('details', []) + for d in details[:10]: # 最多显示 10 条 + icon = '✅' if d['status'] == 'success' else '⏭️' if d['status'] == 'already_signed' else '❌' + print(f" {icon} {d.get('topic', '?')}: {d.get('message', '')}") + if len(details) > 10: + print(f" ... 还有 {len(details) - 10} 条") + return True + else: + print(f" ❌ 签到失败: {resp.text[:500]}") + return False + + +# ================================================================ +# STEP 4: 查看签到日志 +# ================================================================ +def step4_logs(token, account_id): + print("\n" + "=" * 60) + print("STEP 4: 查看签到日志") + print("=" * 60) + resp = requests.get( + f'{API_BASE_URL}/api/v1/accounts/{account_id}/signin-logs', + params={'page': 1, 'size': 10}, + headers=api_headers(token), + timeout=10, + ) + print(f" Status: {resp.status_code}") + if resp.status_code == 200: + data = resp.json().get('data', {}) + items = data.get('items', []) + total = data.get('total', 0) + print(f" 总日志数: {total}") + for log in items[:5]: + print(f" [{log.get('status')}] {log.get('topic_title', '?')} @ {log.get('signed_at', '?')[:19]}") + if not items: + print(" (暂无日志)") + else: + print(f" ❌ 获取日志失败: {resp.text[:300]}") + + +# ================================================================ +# STEP 5: 查看账号详情(验证前端会用到的接口) +# ================================================================ +def step5_detail(token, account_id): + print("\n" + "=" * 60) + print("STEP 5: 验证账号详情接口") + print("=" * 60) + + # 账号详情 + r1 = requests.get(f'{API_BASE_URL}/api/v1/accounts/{account_id}', headers=api_headers(token), timeout=10) + print(f" GET /accounts/{{id}} -> {r1.status_code}") + if r1.status_code == 200: + acc = r1.json().get('data', {}) + print(f" status={acc.get('status')}, remark={acc.get('remark')}") + else: + print(f" ❌ {r1.text[:200]}") + + # 任务列表 + r2 = requests.get(f'{API_BASE_URL}/api/v1/accounts/{account_id}/tasks', headers=api_headers(token), timeout=10) + print(f" GET /accounts/{{id}}/tasks -> {r2.status_code}") + if r2.status_code == 200: + tasks = r2.json().get('data', []) + print(f" 任务数: {len(tasks)}") + else: + print(f" ❌ {r2.text[:200]}") + + # 签到日志 + r3 = requests.get( + f'{API_BASE_URL}/api/v1/accounts/{account_id}/signin-logs', + params={'page': 1, 'size': 5}, + headers=api_headers(token), + timeout=10, + ) + print(f" GET /accounts/{{id}}/signin-logs -> {r3.status_code}") + if r3.status_code == 200: + logs = r3.json().get('data', {}) + print(f" 日志数: {logs.get('total', 0)}, items: {len(logs.get('items', []))}") + else: + print(f" ❌ {r3.text[:200]}") + + if all(r.status_code == 200 for r in [r1, r2, r3]): + print(" ✅ 所有详情接口正常") + else: + print(" ❌ 部分接口异常") + + +# ================================================================ +# 扫码流程(直连模式用) +# ================================================================ +def qrcode_flow(token): + """生成二维码 → 轮询 → SSO 登录 → 添加账号,返回 account_id""" + + # 生成二维码 + print("\n" + "=" * 60) + print("QRCODE: 生成二维码") + print("=" * 60) + url = 'https://login.sina.com.cn/sso/qrcode/image' + params = {'entry': 'weibo', 'size': '180', 'callback': f'STK_{int(time.time() * 1000)}'} + resp = WEIBO_SESSION.get(url, params=params, timeout=10) + data = parse_jsonp(resp.text) + if not data or data.get('retcode') != 20000000: + print(" [ERROR] 生成二维码失败") + return None + qr_data = data.get('data', {}) + qrid = qr_data.get('qrid') + image = qr_data.get('image', '') + if image.startswith('//'): + image = 'https:' + image + if image.startswith('http'): + img_resp = WEIBO_SESSION.get(image, timeout=10) + with open('qrcode.png', 'wb') as f: + f.write(img_resp.content) + print(f" qrid: {qrid}") + print(f" 二维码已保存到 qrcode.png") + + # 轮询 + print("\n 请用手机微博扫描 qrcode.png ...") + check_url = 'https://login.sina.com.cn/sso/qrcode/check' + last_retcode = None + alt_token = None + for i in range(120): + time.sleep(2) + params = {'entry': 'weibo', 'qrid': qrid, 'callback': f'STK_{int(time.time() * 1000)}'} + resp = WEIBO_SESSION.get(check_url, params=params, timeout=10) + data = parse_jsonp(resp.text) + retcode = data.get('retcode') if data else None + if retcode != last_retcode: + msg = data.get('msg', '') if data else '' + print(f" [{i+1}] retcode: {last_retcode} -> {retcode} ({msg})") + last_retcode = retcode + else: + print(f" [{i+1}] retcode={retcode}", end='\r') + if not data: + continue + nested = data.get('data') + alt = nested.get('alt', '') if isinstance(nested, dict) else '' + if retcode == 20000000 and alt: + print(f"\n ✅ 登录成功! alt={alt[:40]}...") + alt_token = alt + break + if retcode in (50114004, 50050002): + print(f"\n ❌ 二维码失效") + return None + + if not alt_token: + print("\n ❌ 轮询超时") + return None + + # SSO 登录 + print("\n SSO 登录...") + sso_url = ( + f"https://login.sina.com.cn/sso/login.php" + f"?entry=weibo&returntype=TEXT&crossdomain=1&cdult=3" + f"&domain=weibo.com&alt={alt_token}&savestate=30" + f"&callback=STK_{int(time.time() * 1000)}" + ) + sso_session = requests.Session() + sso_session.headers.update(WEIBO_HEADERS) + resp = sso_session.get(sso_url, allow_redirects=True, timeout=15) + sso_data = parse_jsonp(resp.text) + uid = str(sso_data.get('uid', '')) if sso_data else '' + nick = sso_data.get('nick', '') if sso_data else '' + cross_urls = sso_data.get('crossDomainUrlList', []) if sso_data else [] + print(f" uid={uid}, nick={nick}, crossDomainUrls={len(cross_urls)}") + for u in cross_urls: + if isinstance(u, str) and u.startswith('http'): + try: + sso_session.get(u, allow_redirects=True, timeout=10) + except Exception: + pass + all_cookies = {} + for c in sso_session.cookies: + if c.domain and 'weibo.com' in c.domain: + all_cookies[c.name] = c.value + cookie_str = '; '.join(f'{k}={v}' for k, v in all_cookies.items()) + has_sub = 'SUB' in all_cookies + print(f" weibo.com Cookie 字段 ({len(all_cookies)}): {list(all_cookies.keys())}") + print(f" 包含 SUB: {'✅' if has_sub else '❌'}") + if not has_sub: + print(" [ERROR] 缺少 SUB cookie") + return None + + # 添加账号 + print("\n 添加账号到后端...") + remark = f"{nick} (调试脚本)" if nick else "调试脚本添加" + resp = requests.post( + f'{API_BASE_URL}/api/v1/accounts', + json={'weibo_user_id': uid, 'cookie': cookie_str, 'remark': remark}, + headers=api_headers(token), + timeout=10, + ) + print(f" Status: {resp.status_code}") + if resp.status_code in (200, 201): + acc = resp.json().get('data', {}) + account_id = acc.get('id') + print(f" ✅ 账号添加成功: {account_id}") + return account_id + else: + print(f" ❌ 添加失败: {resp.text[:300]}") + return None + + +# ================================================================ +# 前端模式 +# ================================================================ +def frontend_flow(): + fe = requests.Session() + + print("=" * 60) + print("FRONTEND: 登录") + print("=" * 60) + resp = fe.post(f'{FRONTEND_URL}/login', data={'email': TEST_EMAIL, 'password': TEST_PASSWORD}, allow_redirects=False, timeout=10) + print(f" POST /login -> {resp.status_code}") + if resp.status_code in (301, 302): + fe.get(f'{FRONTEND_URL}{resp.headers["Location"]}', timeout=10) + dash = fe.get(f'{FRONTEND_URL}/dashboard', timeout=10) + if dash.status_code != 200: + print(" [ERROR] 登录失败") + return + print(" ✅ 登录成功") + + print("\n" + "=" * 60) + print("FRONTEND: 生成二维码") + print("=" * 60) + resp = fe.post(f'{FRONTEND_URL}/api/weibo/qrcode/generate', headers={'Content-Type': 'application/json'}, timeout=15) + print(f" Status: {resp.status_code}, Body: {resp.text[:300]}") + gen = resp.json() + if not gen.get('success'): + print(" [ERROR] 生成失败") + return + qrid = gen['qrid'] + qr_image = gen.get('qr_image', '') + if qr_image.startswith('http'): + img = requests.get(qr_image, headers=WEIBO_HEADERS, timeout=10) + with open('qrcode.png', 'wb') as f: + f.write(img.content) + print(" 二维码已保存到 qrcode.png") + + print("\n 请用手机微博扫描 qrcode.png ...") + last_status = None + for i in range(120): + time.sleep(2) + resp = fe.get(f'{FRONTEND_URL}/api/weibo/qrcode/check/{qrid}', timeout=15) + data = resp.json() + st = data.get('status') + if st != last_status: + print(f" [{i+1}] status: {last_status} -> {st} | {json.dumps(data, ensure_ascii=False)[:200]}") + last_status = st + else: + print(f" [{i+1}] status={st}", end='\r') + if st == 'success': + print(f"\n ✅ 扫码成功! uid={data.get('weibo_uid')}") + break + if st in ('expired', 'cancelled', 'error'): + print(f"\n ❌ {st}: {data.get('error', '')}") + return + else: + print("\n ❌ 超时") + return + + # 添加账号 + print("\n 添加账号...") + time.sleep(0.5) + resp = fe.post( + f'{FRONTEND_URL}/api/weibo/qrcode/add-account', + json={'qrid': qrid}, + headers={'Content-Type': 'application/json'}, + timeout=15, + ) + print(f" Status: {resp.status_code}, Body: {resp.text[:500]}") + result = resp.json() + if result.get('success'): + print(f" ✅ 账号添加成功!") + else: + print(f" ❌ 添加失败: {result.get('message')}") + + +# ================================================================ +# MAIN +# ================================================================ +if __name__ == '__main__': + mode = 'direct' + if '--frontend' in sys.argv: + mode = 'frontend' + elif '--api-only' in sys.argv: + mode = 'api-only' + + if mode == 'frontend': + print("🌐 前端模式") + frontend_flow() + else: + # 直连模式 或 api-only 模式 + token = step0_login() + if not token: + sys.exit(1) + + if mode == 'api-only': + print("\n📡 API-only 模式:跳过扫码,使用已有账号") + account_id = step1_cleanup(token) + if not account_id: + print("\n没有账号,请先用默认模式添加") + sys.exit(1) + else: + print("\n🔗 直连模式:扫码 + 完整业务流程") + # 先清理 + step1_cleanup(token) + # 扫码添加 + account_id = qrcode_flow(token) + if not account_id: + sys.exit(1) + + # 后续业务验证 + step2_verify(token, account_id) + step3_signin(token, account_id) + step4_logs(token, account_id) + step5_detail(token, account_id) + + print("\n" + "=" * 60) + print("全部流程完成!") + print("=" * 60) diff --git a/frontend/app.py b/frontend/app.py index eb18708..5918e0b 100644 --- a/frontend/app.py +++ b/frontend/app.py @@ -1,4 +1,10 @@ import os +import re +import json +import time +import uuid +import logging +import traceback from flask import Flask, render_template, request, redirect, url_for, session, flash, jsonify from flask_session import Session import requests @@ -16,19 +22,79 @@ Session(app) API_BASE_URL = os.getenv('API_BASE_URL', 'http://localhost:8000') AUTH_BASE_URL = os.getenv('AUTH_BASE_URL', 'http://localhost:8001') +logger = logging.getLogger(__name__) + def get_headers(): - """获取请求头,包含认证令牌""" + """获取请求头,包含认证令牌。如果 token 过期会自动刷新。""" headers = {'Content-Type': 'application/json'} if 'access_token' in session: headers['Authorization'] = f"Bearer {session['access_token']}" return headers + +def _try_refresh_token(): + """ + 尝试用 refresh_token 刷新 access_token。 + 成功返回 True 并更新 session,失败返回 False。 + """ + refresh_token = session.get('refresh_token') + if not refresh_token: + logger.warning("Token 刷新失败: session 中没有 refresh_token") + return False + try: + resp = requests.post( + f'{AUTH_BASE_URL}/auth/refresh', + json={'refresh_token': refresh_token}, + timeout=10, + ) + if resp.status_code == 200: + data = resp.json() + session['access_token'] = data['access_token'] + session['refresh_token'] = data['refresh_token'] + session.modified = True + logger.info("Token 刷新成功") + return True + else: + logger.warning(f"Token 刷新失败: HTTP {resp.status_code}, body={resp.text[:200]}") + except Exception as e: + logger.warning(f"Token 刷新异常: {e}") + return False + + +def api_request(method, url, **kwargs): + """ + 封装 API 请求,自动处理 token 过期刷新。 + 如果收到 401,尝试刷新 token 后重试一次。 + """ + headers = kwargs.pop('headers', None) or get_headers() + token_preview = headers.get('Authorization', 'NONE')[:30] + logger.info(f"api_request: {method} {url} token={token_preview}...") + + resp = requests.request(method, url, headers=headers, timeout=10, **kwargs) + + if resp.status_code == 401: + logger.warning(f"api_request: 收到 401, 尝试刷新 token...") + if _try_refresh_token(): + # Token 已刷新,用新 token 重试 + headers['Authorization'] = f"Bearer {session['access_token']}" + logger.info(f"api_request: 刷新成功,重试请求...") + resp = requests.request(method, url, headers=headers, timeout=10, **kwargs) + else: + logger.error(f"api_request: token 刷新失败,清除 session 让用户重新登录") + # 清除无效的 token,下次访问会被 login_required 拦截 + session.pop('access_token', None) + session.pop('refresh_token', None) + session.pop('user', None) + session.modified = True + + return resp + def login_required(f): """登录验证装饰器""" @wraps(f) def decorated_function(*args, **kwargs): if 'user' not in session: - flash('Please login first', 'warning') + flash('请先登录', 'warning') return redirect(url_for('login')) return f(*args, **kwargs) return decorated_function @@ -48,7 +114,7 @@ def register(): confirm_password = request.form.get('confirm_password') if password != confirm_password: - flash('Passwords do not match', 'danger') + flash('两次输入的密码不一致', 'danger') return redirect(url_for('register')) try: @@ -63,13 +129,13 @@ def register(): session['user'] = data['user'] session['access_token'] = data['access_token'] session['refresh_token'] = data['refresh_token'] - flash('Registration successful!', 'success') + flash('注册成功', 'success') return redirect(url_for('dashboard')) else: error_data = response.json() - flash(error_data.get('detail', 'Registration failed'), 'danger') + flash(error_data.get('detail', '注册失败'), 'danger') except requests.RequestException as e: - flash(f'Connection error: {str(e)}', 'danger') + flash(f'连接错误: {str(e)}', 'danger') return render_template('register.html') @@ -91,354 +157,391 @@ def login(): session['user'] = data['user'] session['access_token'] = data['access_token'] session['refresh_token'] = data['refresh_token'] - flash('Login successful!', 'success') + flash('登录成功', 'success') return redirect(url_for('dashboard')) else: error_data = response.json() - flash(error_data.get('detail', 'Login failed'), 'danger') + flash(error_data.get('detail', '登录失败'), 'danger') except requests.RequestException as e: - flash(f'Connection error: {str(e)}', 'danger') + flash(f'连接错误: {str(e)}', 'danger') return render_template('login.html') + +@app.route('/api/auth/wx-login', methods=['POST']) +def wx_login_proxy(): + """ + 微信小程序登录代理。 + 小程序端调这个接口,转发到 auth_service 的 /auth/wx-login。 + Web 端也可以用(未来微信扫码登录)。 + """ + try: + data = request.json + response = requests.post( + f'{AUTH_BASE_URL}/auth/wx-login', + json=data, + timeout=15, + ) + + if response.status_code == 200: + result = response.json() + # 写入 session(Web 端用) + session['user'] = result.get('user') + session['access_token'] = result.get('access_token') + session['refresh_token'] = result.get('refresh_token') + session.modified = True + return jsonify({'success': True, 'data': result}) + else: + detail = response.json().get('detail', '微信登录失败') + return jsonify({'success': False, 'message': detail}), response.status_code + except Exception as e: + logger.exception("微信登录代理异常") + return jsonify({'success': False, 'message': str(e)}), 500 + + @app.route('/logout') def logout(): session.clear() - flash('Logged out successfully', 'success') + flash('已退出登录', 'success') return redirect(url_for('login')) @app.route('/dashboard') @login_required def dashboard(): try: - response = requests.get( + response = api_request( + 'GET', f'{API_BASE_URL}/api/v1/accounts', - headers=get_headers(), - timeout=10 ) data = response.json() accounts = data.get('data', []) if data.get('success') else [] except requests.RequestException: accounts = [] - flash('Failed to load accounts', 'warning') + flash('加载账号列表失败', 'warning') return render_template('dashboard.html', accounts=accounts, user=session.get('user')) -@app.route('/accounts/new', methods=['GET', 'POST']) +@app.route('/accounts/new') @login_required def add_account(): - if request.method == 'POST': - login_method = request.form.get('login_method', 'manual') - - if login_method == 'manual': - weibo_user_id = request.form.get('weibo_user_id') - cookie = request.form.get('cookie') - remark = request.form.get('remark') + return render_template('add_account.html') - try: - response = requests.post( - f'{API_BASE_URL}/api/v1/accounts', - json={ - 'weibo_user_id': weibo_user_id, - 'cookie': cookie, - 'remark': remark - }, - headers=get_headers(), - timeout=10 - ) - data = response.json() - if response.status_code == 200 and data.get('success'): - flash('Account added successfully!', 'success') - return redirect(url_for('dashboard')) - else: - flash(data.get('message', 'Failed to add account'), 'danger') - except requests.RequestException as e: - flash(f'Connection error: {str(e)}', 'danger') +WEIBO_HEADERS = { + 'User-Agent': ( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' + '(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' + ), + 'Referer': 'https://weibo.com/', + 'Accept': '*/*', + 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', + 'Connection': 'keep-alive', +} - # 扫码授权功能总是启用(使用微博网页版接口) - weibo_qrcode_enabled = True - - return render_template('add_account.html', - weibo_qrcode_enabled=weibo_qrcode_enabled) + +def _parse_jsonp(text): + """Strip JSONP wrapper and return parsed dict, or None.""" + m = re.search(r'\((.*)\)', text, re.DOTALL) + if m: + return json.loads(m.group(1)) + # Maybe it's already plain JSON + try: + return json.loads(text) + except (json.JSONDecodeError, ValueError): + return None @app.route('/api/weibo/qrcode/generate', methods=['POST']) @login_required def generate_weibo_qrcode(): - """生成微博扫码登录二维码(模拟网页版)""" - import uuid - import time - import traceback - + """ + 生成微博扫码登录二维码。 + 调用 https://login.sina.com.cn/sso/qrcode/image 获取 qrid + 二维码图片。 + """ try: - # 模拟微博网页版的二维码生成接口 - # 实际接口:https://login.sina.com.cn/sso/qrcode/image - - # 生成唯一的 qrcode_id - qrcode_id = str(uuid.uuid4()) - - # 调用微博的二维码生成接口 qr_api_url = 'https://login.sina.com.cn/sso/qrcode/image' params = { 'entry': 'weibo', 'size': '180', - 'callback': f'STK_{int(time.time() * 1000)}' + 'callback': f'STK_{int(time.time() * 1000)}', } - - # 添加浏览器请求头,模拟真实浏览器 - headers = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', - 'Referer': 'https://weibo.com/', - 'Accept': '*/*', - 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', - 'Accept-Encoding': 'gzip, deflate, br', - 'Connection': 'keep-alive' + + resp = requests.get(qr_api_url, params=params, headers=WEIBO_HEADERS, timeout=10) + logger.debug(f"qrcode/image status={resp.status_code} body={resp.text[:300]}") + + data = _parse_jsonp(resp.text) + if not data or data.get('retcode') != 20000000: + return jsonify({'success': False, 'error': '生成二维码失败'}), 500 + + qr_data = data.get('data', {}) + qrid = qr_data.get('qrid') + qr_image_url = qr_data.get('image', '') + + if not qrid or not qr_image_url: + return jsonify({'success': False, 'error': '二维码数据不完整'}), 500 + + if qr_image_url.startswith('//'): + qr_image_url = 'https:' + qr_image_url + + # 存储二维码状态 + if 'weibo_qrcodes' not in session: + session['weibo_qrcodes'] = {} + session['weibo_qrcodes'][qrid] = { + 'status': 'waiting', + 'created_at': str(datetime.now()), } - - print(f"[DEBUG] 请求微博 API: {qr_api_url}") - response = requests.get(qr_api_url, params=params, headers=headers, timeout=10) - print(f"[DEBUG] 响应状态码: {response.status_code}") - print(f"[DEBUG] 响应内容: {response.text[:200]}") - - # 微博返回的是 JSONP 格式,需要解析 - # 格式:STK_xxx({"retcode":20000000,"qrid":"xxx","image":"data:image/png;base64,xxx"}) - import re - import json - - match = re.search(r'\((.*)\)', response.text) - if match: - data = json.loads(match.group(1)) - print(f"[DEBUG] 解析的数据: retcode={data.get('retcode')}, data={data.get('data')}") - - if data.get('retcode') == 20000000: - # 微博返回的数据结构:{"retcode":20000000,"data":{"qrid":"...","image":"..."}} - qr_data = data.get('data', {}) - qrid = qr_data.get('qrid') - qr_image_url = qr_data.get('image') - - if not qrid or not qr_image_url: - print(f"[ERROR] 缺少 qrid 或 image: qrid={qrid}, image={qr_image_url}") - return jsonify({'success': False, 'error': '二维码数据不完整'}), 500 - - # 如果 image 是相对 URL,补全为完整 URL - if qr_image_url.startswith('//'): - qr_image_url = 'https:' + qr_image_url - - print(f"[DEBUG] 二维码 URL: {qr_image_url}") - - # 存储二维码状态 - if 'weibo_qrcodes' not in session: - session['weibo_qrcodes'] = {} - session['weibo_qrcodes'][qrid] = { - 'status': 'waiting', - 'created_at': str(datetime.now()), - 'qrcode_id': qrcode_id - } - session.modified = True - - print(f"[DEBUG] 二维码生成成功: qrid={qrid}") - return jsonify({ - 'success': True, - 'qrid': qrid, - 'qr_image': qr_image_url, # 返回二维码图片 URL - 'expires_in': 180 - }) - - print("[DEBUG] 未能解析响应或 retcode 不正确") - return jsonify({'success': False, 'error': '生成二维码失败'}), 500 - + session.modified = True + + return jsonify({ + 'success': True, + 'qrid': qrid, + 'qr_image': qr_image_url, + 'expires_in': 180, + }) + except Exception as e: - print(f"[ERROR] 生成二维码异常: {str(e)}") - print(f"[ERROR] 堆栈跟踪:\n{traceback.format_exc()}") + logger.exception("生成二维码异常") return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/weibo/qrcode/check/', methods=['GET']) @login_required def check_weibo_qrcode(qrid): - """检查微博扫码状态(模拟网页版)""" - import time - + """ + 轮询微博扫码状态。 + + 微博 check 接口必须用 JSONP(带 callback),否则返回非 JSON 内容。 + 成功时 retcode=20000001 会携带 alt 跳转 URL。 + 用 requests.Session 跟踪 alt 的完整 SSO 重定向链来收集 Cookie。 + """ try: - # 检查二维码是否存在 qrcodes = session.get('weibo_qrcodes', {}) if qrid not in qrcodes: return jsonify({'status': 'expired'}) - - # 调用微博的轮询接口 - # 实际接口:https://login.sina.com.cn/sso/qrcode/check - check_api_url = 'https://login.sina.com.cn/sso/qrcode/check' + + # 如果之前已经成功处理过,直接返回缓存结果 + qr_info = qrcodes[qrid] + if qr_info.get('status') == 'success': + return jsonify({ + 'status': 'success', + 'weibo_uid': qr_info.get('weibo_uid'), + 'screen_name': qr_info.get('screen_name'), + }) + + # ---- 1. 调用 check 接口(JSONP 模式)---- + check_url = 'https://login.sina.com.cn/sso/qrcode/check' params = { 'entry': 'weibo', 'qrid': qrid, - 'callback': f'STK_{int(time.time() * 1000)}' + 'callback': f'STK_{int(time.time() * 1000)}', } - - # 添加浏览器请求头 - headers = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', - 'Referer': 'https://weibo.com/', - 'Accept': '*/*', - 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', - 'Connection': 'keep-alive' - } - - response = requests.get(check_api_url, params=params, headers=headers, timeout=10) - - print(f"[DEBUG] 检查状态 - qrid: {qrid}") - print(f"[DEBUG] 检查状态 - 响应状态码: {response.status_code}") - print(f"[DEBUG] 检查状态 - 响应内容: {response.text[:500]}") - - # 解析 JSONP 响应 - import re - match = re.search(r'\((.*)\)', response.text) - if match: - import json - data = json.loads(match.group(1)) - - retcode = data.get('retcode') - print(f"[DEBUG] 检查状态 - retcode: {retcode}, data: {data}") - - # 微博扫码状态码: - # 20000000: 等待扫码 - # 50050001: 已扫码,等待确认 - # 20000001: 确认成功 - # 50050002: 二维码过期 - # 50050004: 取消授权 - # 50114001: 未使用(等待扫码) - # 50114004: 该二维码已登录(可能是成功状态) - - if retcode == 20000000 or retcode == 50114001: - # 等待扫码 - return jsonify({'status': 'waiting'}) - elif retcode == 50050001: - # 已扫码,等待确认 - return jsonify({'status': 'scanned'}) - elif retcode == 20000001 or retcode == 50114004: - # 登录成功,获取跳转 URL - # 50114004 也表示已登录成功 - alt_url = data.get('alt') - - # 如果没有 alt 字段,尝试从 data 中获取 - if not alt_url and data.get('data'): - alt_url = data.get('data', {}).get('alt') - - print(f"[DEBUG] 登录成功 - retcode: {retcode}, alt_url: {alt_url}, full_data: {data}") - - # 如果没有 alt_url,尝试构造登录 URL - if not alt_url: - # 尝试使用 qrid 构造登录 URL - # 微博可能使用不同的 URL 格式 - possible_urls = [ - f"https://login.sina.com.cn/sso/login.php?entry=weibo&qrid={qrid}", - f"https://passport.weibo.com/sso/login?qrid={qrid}", - f"https://login.sina.com.cn/sso/qrcode/login?qrid={qrid}" - ] - - print(f"[DEBUG] 尝试构造登录 URL") - for url in possible_urls: - try: - print(f"[DEBUG] 尝试 URL: {url}") - test_response = requests.get(url, headers=headers, allow_redirects=False, timeout=5) - print(f"[DEBUG] 响应状态码: {test_response.status_code}") - if test_response.status_code in [200, 302, 301]: - alt_url = url - print(f"[DEBUG] 找到有效 URL: {alt_url}") - break - except Exception as e: - print(f"[DEBUG] URL 失败: {str(e)}") - continue - - if alt_url: - # 添加浏览器请求头 - headers = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', - 'Referer': 'https://weibo.com/', - 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', - 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', - 'Connection': 'keep-alive' - } - - print(f"[DEBUG] 访问跳转 URL: {alt_url}") - # 访问跳转 URL 获取 Cookie - cookie_response = requests.get(alt_url, headers=headers, allow_redirects=True, timeout=10) - cookies = cookie_response.cookies - - print(f"[DEBUG] 获取到的 Cookies: {dict(cookies)}") - - # 构建 Cookie 字符串 - cookie_str = '; '.join([f'{k}={v}' for k, v in cookies.items()]) - - if not cookie_str: - print("[ERROR] 未获取到任何 Cookie") - return jsonify({'status': 'error', 'error': '未获取到 Cookie'}) - - print(f"[DEBUG] Cookie 字符串长度: {len(cookie_str)}") - - # 获取用户信息 - # 可以通过 Cookie 访问微博 API 获取 uid - user_info_url = 'https://weibo.com/ajax/profile/info' - user_headers = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', - 'Referer': 'https://weibo.com/', - 'Accept': 'application/json, text/plain, */*', - 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8' - } - - print(f"[DEBUG] 请求用户信息: {user_info_url}") - user_response = requests.get(user_info_url, cookies=cookies, headers=user_headers, timeout=10) - - print(f"[DEBUG] 用户信息响应状态码: {user_response.status_code}") - print(f"[DEBUG] 用户信息响应内容: {user_response.text[:500]}") - - user_data = user_response.json() - - if user_data.get('ok') == 1: - user_info = user_data.get('data', {}).get('user', {}) - weibo_uid = user_info.get('idstr', '') - screen_name = user_info.get('screen_name', 'Weibo User') - - print(f"[DEBUG] 获取用户信息成功: uid={weibo_uid}, name={screen_name}") - - # 更新状态 - session['weibo_qrcodes'][qrid]['status'] = 'success' - session['weibo_qrcodes'][qrid]['cookie'] = cookie_str - session['weibo_qrcodes'][qrid]['weibo_uid'] = weibo_uid - session['weibo_qrcodes'][qrid]['screen_name'] = screen_name - session.modified = True - - return jsonify({ - 'status': 'success', - 'weibo_uid': weibo_uid, - 'screen_name': screen_name - }) - else: - print(f"[ERROR] 获取用户信息失败: {user_data}") - return jsonify({'status': 'error', 'error': '获取用户信息失败'}) - else: - print("[ERROR] 未获取到跳转 URL") - - return jsonify({'status': 'error', 'error': '获取登录信息失败'}) - elif retcode == 50050002: - return jsonify({'status': 'expired'}) - elif retcode == 50050004: - return jsonify({'status': 'cancelled'}) + + resp = requests.get(check_url, params=params, headers=WEIBO_HEADERS, timeout=10) + logger.info(f"qrcode/check status={resp.status_code} body={resp.text[:500]}") + + data = _parse_jsonp(resp.text) + if not data: + logger.error(f"无法解析 check 响应: {resp.text[:300]}") + return jsonify({'status': 'error', 'error': '解析响应失败'}) + + retcode = data.get('retcode') + logger.info(f"check retcode={retcode}") + + # 等待扫码(50114001 = "未使用") + if retcode == 50114001: + return jsonify({'status': 'waiting'}) + + # 已扫码,等待确认 + if retcode in (50050001, 50114002): + return jsonify({'status': 'scanned'}) + + # 二维码过期 + if retcode == 50050002: + return jsonify({'status': 'expired'}) + + # 取消授权 + if retcode == 50050004: + return jsonify({'status': 'cancelled'}) + + # 50114004 = 二维码已被消费(重复轮询),不带 alt + if retcode == 50114004: + if qr_info.get('status') == 'success': + return jsonify({ + 'status': 'success', + 'weibo_uid': qr_info.get('weibo_uid'), + 'screen_name': qr_info.get('screen_name'), + }) + return jsonify({'status': 'error', 'error': '二维码已失效,请重新生成'}) + + # ---- 2. 登录成功 ---- + # retcode=20000000 + data.alt 存在 = 扫码确认成功 + # retcode=20000001 = 旧版成功状态 + # alt 是一个 token(如 "ALT-xxx"),不是 URL,需要拼接成 SSO 登录 URL + alt_token = '' + nested = data.get('data') + if isinstance(nested, dict): + alt_token = nested.get('alt', '') + + if retcode == 20000000 and not alt_token: + # 20000000 无 alt = 正常的等待扫码状态 + return jsonify({'status': 'waiting'}) + + if not alt_token: + if retcode in (20000001, 50114003): + alt_token = data.get('alt', '') else: - # 未知状态码,记录日志 - print(f"[WARN] 未知的 retcode: {retcode}, msg: {data.get('msg')}") - return jsonify({'status': 'waiting'}) # 默认继续等待 - - print("[DEBUG] 未能解析响应") - return jsonify({'status': 'error', 'error': '检查状态失败'}) - + logger.warning(f"未知 retcode: {retcode}, data: {data}") + return jsonify({'status': 'waiting'}) + + if not alt_token: + return jsonify({'status': 'error', 'error': '微博未返回登录凭证,请重新扫码'}) + + logger.info(f"获取到 alt token: {alt_token}") + + # 将 alt token 拼接成完整的 SSO 登录 URL + alt_url = ( + f"https://login.sina.com.cn/sso/login.php" + f"?entry=weibo&returntype=TEXT&crossdomain=1&cdult=3" + f"&domain=weibo.com&alt={alt_token}&savestate=30" + f"&callback=STK_{int(time.time() * 1000)}" + ) + logger.info(f"构造 SSO URL: {alt_url}") + + # ---- 3. 执行 SSO 登录,收集 Cookie 和用户信息 ---- + cookie_str, uid, nick = _execute_sso_login(alt_url) + + if not cookie_str: + return jsonify({ + 'status': 'error', + 'error': 'Cookie 获取失败,请重新扫码', + }) + + screen_name = nick or f'用户{uid}' + + # 存储结果到 session(防止重复轮询时丢失) + session['weibo_qrcodes'][qrid]['status'] = 'success' + session['weibo_qrcodes'][qrid]['cookie'] = cookie_str + session['weibo_qrcodes'][qrid]['weibo_uid'] = uid + session['weibo_qrcodes'][qrid]['screen_name'] = screen_name + session.modified = True + logger.info(f"check 成功: qrid={qrid}, uid={uid}, cookie长度={len(cookie_str)}, session已写入") + + return jsonify({ + 'status': 'success', + 'weibo_uid': uid, + 'screen_name': screen_name, + }) + except Exception as e: - print(f"[ERROR] 检查二维码状态异常: {str(e)}") - import traceback - print(f"[ERROR] 堆栈跟踪:\n{traceback.format_exc()}") + logger.exception("检查二维码状态异常") return jsonify({'status': 'error', 'error': str(e)}) +def _execute_sso_login(sso_url): + """ + 执行微博 SSO 登录流程,收集 Cookie 并提取用户信息。 + + 流程: + 1. GET sso_url → 返回 JSONP,包含: + - uid, nick(用户信息,直接可用) + - crossDomainUrlList(跨域种 cookie 的 URL) + - Set-Cookie: SUB, SUBP, ALF 等 + 2. 逐个访问 crossDomainUrlList 中的 URL(种跨域 cookie) + 3. 汇总所有 cookie + + Returns: (cookie_str, uid, nick) or (None, None, None) + """ + sso_session = requests.Session() + sso_session.headers.update({ + 'User-Agent': WEIBO_HEADERS['User-Agent'], + 'Referer': 'https://weibo.com/', + 'Accept': '*/*', + 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', + }) + + try: + # Step 1: 访问 SSO 登录 URL + resp = sso_session.get(sso_url, allow_redirects=True, timeout=15) + logger.info(f"SSO login status={resp.status_code}, cookies={len(sso_session.cookies)}") + + # 解析 JSONP 响应 + sso_data = _parse_jsonp(resp.text) + uid = '' + nick = '' + cross_urls = [] + + if sso_data: + # 直接从 SSO 响应中提取用户信息 + uid = str(sso_data.get('uid', '')) + nick = sso_data.get('nick', '') + cross_urls = sso_data.get('crossDomainUrlList', []) + logger.info(f"SSO 响应: uid={uid}, nick={nick}, crossDomainUrls={len(cross_urls)}") + else: + logger.warning(f"无法解析 SSO 响应: {resp.text[:500]}") + + # Step 2: 逐个访问跨域 URL 种 cookie + for url in cross_urls: + if not isinstance(url, str) or not url.startswith('http'): + continue + try: + logger.debug(f"访问跨域 URL: {url[:120]}") + sso_session.get(url, allow_redirects=True, timeout=10) + except Exception as e: + logger.debug(f"跨域 URL 访问失败: {e}") + + # Step 3: 只提取 weibo.com 域名的 Cookie(签到 API 只需要这些) + weibo_com_cookies = {} + for cookie in sso_session.cookies: + if cookie.domain and 'weibo.com' in cookie.domain: + weibo_com_cookies[cookie.name] = cookie.value + + cookie_str = '; '.join(f'{k}={v}' for k, v in weibo_com_cookies.items()) + logger.info(f"weibo.com Cookie ({len(weibo_com_cookies)} 个): {list(weibo_com_cookies.keys())}") + + if not cookie_str or 'SUB' not in weibo_com_cookies: + logger.error(f"Cookie 不完整,缺少 SUB。获取到: {list(weibo_com_cookies.keys())}") + return None, None, None + + if not uid: + logger.warning("SSO 响应中没有 uid,尝试从 API 获取...") + uid, nick = _fetch_weibo_user_info(sso_session) + + if not uid: + logger.error("无法获取用户 uid") + return None, None, None + + return cookie_str, uid, nick + + except Exception as e: + logger.exception(f"SSO 登录流程失败: {e}") + return None, None, None + + +def _fetch_weibo_user_info(sso_session): + """用已登录的 session 获取当前用户 uid 和昵称(PC 端接口)。""" + try: + resp = sso_session.get( + 'https://weibo.com/ajax/profile/info', + headers={ + 'User-Agent': WEIBO_HEADERS['User-Agent'], + 'Referer': 'https://weibo.com/', + 'Accept': '*/*', + }, + timeout=10, + ) + data = resp.json() + if data.get('ok') == 1: + user = data.get('data', {}).get('user', {}) + uid = user.get('idstr', '') + screen_name = user.get('screen_name', f'用户{uid}') + if uid: + logger.info(f"weibo.com/ajax/profile/info: uid={uid}, name={screen_name}") + return uid, screen_name + except Exception as e: + logger.warning(f"weibo.com/ajax/profile/info 失败: {e}") + return None, None + + @app.route('/api/weibo/qrcode/add-account', methods=['POST']) @login_required def add_account_from_qrcode(): @@ -447,68 +550,79 @@ def add_account_from_qrcode(): data = request.json qrid = data.get('qrid') remark = data.get('remark', '') - - print(f"[DEBUG] 添加账号 - qrid: {qrid}") - - # 获取扫码结果 + qrcodes = session.get('weibo_qrcodes', {}) qr_info = qrcodes.get(qrid) - - print(f"[DEBUG] 添加账号 - qr_info: {qr_info}") - + + logger.info(f"add-account: qrid={qrid}, qrcodes keys={list(qrcodes.keys())}") + logger.info(f"add-account: qr_info={json.dumps(qr_info, default=str)[:500] if qr_info else None}") + if not qr_info or qr_info.get('status') != 'success': - print(f"[ERROR] 添加账号失败 - 二维码状态不正确: {qr_info.get('status') if qr_info else 'None'}") + logger.error(f"add-account 失败: qr_info={qr_info}, status={qr_info.get('status') if qr_info else 'N/A'}") return jsonify({'success': False, 'message': '二维码未完成授权'}), 400 - + cookie = qr_info.get('cookie') weibo_uid = qr_info.get('weibo_uid') - screen_name = qr_info.get('screen_name', 'Weibo User') - - print(f"[DEBUG] 添加账号 - uid: {weibo_uid}, name: {screen_name}, cookie_len: {len(cookie) if cookie else 0}") - + screen_name = qr_info.get('screen_name', '微博用户') + + if not cookie: + logger.error(f"add-account: cookie 为空! qr_info keys={list(qr_info.keys())}") + return jsonify({'success': False, 'message': 'Cookie 数据丢失,请重新扫码'}), 400 + if not remark: remark = f"{screen_name} (扫码添加)" - - # 添加账号到系统 - print(f"[DEBUG] 调用后端 API 添加账号: {API_BASE_URL}/api/v1/accounts") - response = requests.post( + + response = api_request( + 'POST', f'{API_BASE_URL}/api/v1/accounts', json={ 'weibo_user_id': weibo_uid, 'cookie': cookie, - 'remark': remark + 'remark': remark, }, - headers=get_headers(), - timeout=10 ) - - print(f"[DEBUG] 后端响应状态码: {response.status_code}") - print(f"[DEBUG] 后端响应内容: {response.text[:500]}") - + result = response.json() - - if response.status_code == 200 and result.get('success'): - # 清除已使用的二维码 + + if response.status_code in (200, 201) and result.get('success'): + account_data = result.get('data', {}) + account_id = account_data.get('id') + + # 扫码添加后自动触发 Cookie 验证,激活账号 + if account_id: + try: + verify_resp = api_request('POST', f'{API_BASE_URL}/api/v1/accounts/{account_id}/verify') + verify_data = verify_resp.json() + if verify_data.get('success') and verify_data.get('data', {}).get('cookie_valid'): + logger.info(f"扫码添加后自动验证成功: account_id={account_id}") + else: + logger.warning(f"扫码添加后自动验证失败: {verify_data}") + except Exception as e: + logger.warning(f"扫码添加后自动验证异常: {e}") + session['weibo_qrcodes'].pop(qrid, None) session.modified = True - - print(f"[DEBUG] 账号添加成功") return jsonify({ 'success': True, - 'message': 'Account added successfully', - 'account': result.get('data', {}) + 'message': '账号添加成功', + 'account': account_data, }) - else: - print(f"[ERROR] 后端返回失败: {result}") + elif response.status_code == 401: + logger.error(f"add-account 后端返回 401: {response.text[:500]}") return jsonify({ 'success': False, - 'message': result.get('message', 'Failed to add account') + 'message': '登录已过期,请重新登录后再试', + 'need_login': True, + }), 401 + else: + logger.error(f"add-account 后端返回失败: status={response.status_code}, body={response.text[:500]}") + return jsonify({ + 'success': False, + 'message': result.get('message', result.get('detail', '添加账号失败')), }), 400 - + except Exception as e: - print(f"[ERROR] 添加账号异常: {str(e)}") - import traceback - print(f"[ERROR] 堆栈跟踪:\n{traceback.format_exc()}") + logger.exception("添加账号异常") return jsonify({'success': False, 'message': str(e)}), 500 @app.route('/accounts/') @@ -516,36 +630,35 @@ def add_account_from_qrcode(): def account_detail(account_id): try: # 获取账号详情 - response = requests.get( - f'{API_BASE_URL}/api/v1/accounts/{account_id}', - headers=get_headers(), - timeout=10 - ) + response = api_request('GET', f'{API_BASE_URL}/api/v1/accounts/{account_id}') account_data = response.json() account = account_data.get('data') if account_data.get('success') else None # 获取任务列表 - tasks_response = requests.get( - f'{API_BASE_URL}/api/v1/accounts/{account_id}/tasks', - headers=get_headers(), - timeout=10 - ) + tasks_response = api_request('GET', f'{API_BASE_URL}/api/v1/accounts/{account_id}/tasks') tasks_data = tasks_response.json() tasks = tasks_data.get('data', []) if tasks_data.get('success') else [] # 获取签到日志 page = request.args.get('page', 1, type=int) - logs_response = requests.get( + logs_response = api_request( + 'GET', f'{API_BASE_URL}/api/v1/accounts/{account_id}/signin-logs', params={'page': page, 'size': 20}, - headers=get_headers(), - timeout=10 ) logs_data = logs_response.json() logs = logs_data.get('data', {}) if logs_data.get('success') else {} + # 确保 logs 有默认结构,避免模板报错 + if not isinstance(logs, dict): + logs = {} + logs.setdefault('items', []) + logs.setdefault('total', 0) + logs.setdefault('page', page) + logs.setdefault('size', 20) + logs.setdefault('total_pages', 0) if not account: - flash('Account not found', 'danger') + flash('账号不存在', 'danger') return redirect(url_for('dashboard')) return render_template( @@ -556,9 +669,46 @@ def account_detail(account_id): user=session.get('user') ) except requests.RequestException as e: - flash(f'Connection error: {str(e)}', 'danger') + flash(f'连接错误: {str(e)}', 'danger') return redirect(url_for('dashboard')) + +@app.route('/accounts//verify', methods=['POST']) +@login_required +def verify_account(account_id): + """验证账号 Cookie 有效性""" + try: + response = api_request('POST', f'{API_BASE_URL}/api/v1/accounts/{account_id}/verify') + data = response.json() + if data.get('success') and data.get('data', {}).get('cookie_valid'): + flash('Cookie 验证通过,账号已激活', 'success') + else: + flash(data.get('message', 'Cookie 无效或已过期'), 'warning') + except requests.RequestException as e: + flash(f'连接错误: {str(e)}', 'danger') + return redirect(url_for('account_detail', account_id=account_id)) + + +@app.route('/accounts//signin', methods=['POST']) +@login_required +def manual_signin(account_id): + """手动触发签到""" + try: + response = api_request('POST', f'{API_BASE_URL}/api/v1/accounts/{account_id}/signin') + data = response.json() + if data.get('success'): + result = data.get('data', {}) + signed = result.get('signed', 0) + already = result.get('already_signed', 0) + failed = result.get('failed', 0) + flash(f'签到完成: {signed} 成功, {already} 已签, {failed} 失败', 'success') + else: + flash(data.get('message', '签到失败'), 'danger') + except requests.RequestException as e: + flash(f'连接错误: {str(e)}', 'danger') + return redirect(url_for('account_detail', account_id=account_id)) + + @app.route('/accounts//edit', methods=['GET', 'POST']) @login_required def edit_account(account_id): @@ -571,57 +721,48 @@ def edit_account(account_id): if cookie: data['cookie'] = cookie - response = requests.put( + response = api_request( + 'PUT', f'{API_BASE_URL}/api/v1/accounts/{account_id}', json=data, - headers=get_headers(), - timeout=10 ) result = response.json() if response.status_code == 200 and result.get('success'): - flash('Account updated successfully!', 'success') + flash('账号更新成功', 'success') return redirect(url_for('account_detail', account_id=account_id)) else: - flash(result.get('message', 'Failed to update account'), 'danger') + flash(result.get('message', '更新账号失败'), 'danger') except requests.RequestException as e: - flash(f'Connection error: {str(e)}', 'danger') + flash(f'连接错误: {str(e)}', 'danger') try: - response = requests.get( - f'{API_BASE_URL}/api/v1/accounts/{account_id}', - headers=get_headers(), - timeout=10 - ) + response = api_request('GET', f'{API_BASE_URL}/api/v1/accounts/{account_id}') data = response.json() account = data.get('data') if data.get('success') else None if not account: - flash('Account not found', 'danger') + flash('账号不存在', 'danger') return redirect(url_for('dashboard')) return render_template('edit_account.html', account=account) except requests.RequestException as e: - flash(f'Connection error: {str(e)}', 'danger') + flash(f'连接错误: {str(e)}', 'danger') return redirect(url_for('dashboard')) @app.route('/accounts//delete', methods=['POST']) @login_required def delete_account(account_id): try: - response = requests.delete( - f'{API_BASE_URL}/api/v1/accounts/{account_id}', - headers=get_headers(), - timeout=10 - ) + response = api_request('DELETE', f'{API_BASE_URL}/api/v1/accounts/{account_id}') data = response.json() if response.status_code == 200 and data.get('success'): - flash('Account deleted successfully!', 'success') + flash('账号删除成功', 'success') else: - flash(data.get('message', 'Failed to delete account'), 'danger') + flash(data.get('message', '删除账号失败'), 'danger') except requests.RequestException as e: - flash(f'Connection error: {str(e)}', 'danger') + flash(f'连接错误: {str(e)}', 'danger') return redirect(url_for('dashboard')) @@ -632,21 +773,20 @@ def add_task(account_id): cron_expression = request.form.get('cron_expression') try: - response = requests.post( + response = api_request( + 'POST', f'{API_BASE_URL}/api/v1/accounts/{account_id}/tasks', json={'cron_expression': cron_expression}, - headers=get_headers(), - timeout=10 ) data = response.json() - if response.status_code == 200 and data.get('success'): - flash('Task created successfully!', 'success') + if response.status_code in (200, 201) and data.get('success'): + flash('任务创建成功', 'success') return redirect(url_for('account_detail', account_id=account_id)) else: - flash(data.get('message', 'Failed to create task'), 'danger') + flash(data.get('message', '创建任务失败'), 'danger') except requests.RequestException as e: - flash(f'Connection error: {str(e)}', 'danger') + flash(f'连接错误: {str(e)}', 'danger') return render_template('add_task.html', account_id=account_id) @@ -656,20 +796,19 @@ def toggle_task(task_id): is_enabled = request.form.get('is_enabled') == 'true' try: - response = requests.put( + response = api_request( + 'PUT', f'{API_BASE_URL}/api/v1/tasks/{task_id}', json={'is_enabled': not is_enabled}, - headers=get_headers(), - timeout=10 ) data = response.json() if response.status_code == 200 and data.get('success'): - flash('Task updated successfully!', 'success') + flash('任务更新成功', 'success') else: - flash(data.get('message', 'Failed to update task'), 'danger') + flash(data.get('message', '更新任务失败'), 'danger') except requests.RequestException as e: - flash(f'Connection error: {str(e)}', 'danger') + flash(f'连接错误: {str(e)}', 'danger') account_id = request.form.get('account_id') return redirect(url_for('account_detail', account_id=account_id)) @@ -680,22 +819,89 @@ def delete_task(task_id): account_id = request.form.get('account_id') try: - response = requests.delete( - f'{API_BASE_URL}/api/v1/tasks/{task_id}', - headers=get_headers(), - timeout=10 - ) + response = api_request('DELETE', f'{API_BASE_URL}/api/v1/tasks/{task_id}') data = response.json() if response.status_code == 200 and data.get('success'): - flash('Task deleted successfully!', 'success') + flash('任务删除成功', 'success') else: - flash(data.get('message', 'Failed to delete task'), 'danger') + flash(data.get('message', '删除任务失败'), 'danger') except requests.RequestException as e: - flash(f'Connection error: {str(e)}', 'danger') + flash(f'连接错误: {str(e)}', 'danger') return redirect(url_for('account_detail', account_id=account_id)) +@app.route('/api/batch/verify', methods=['POST']) +@login_required +def batch_verify(): + """批量验证所有账号的 Cookie 有效性""" + try: + response = api_request('GET', f'{API_BASE_URL}/api/v1/accounts') + data = response.json() + accounts = data.get('data', []) if data.get('success') else [] + + valid = invalid = errors = 0 + for account in accounts: + try: + resp = api_request('POST', f'{API_BASE_URL}/api/v1/accounts/{account["id"]}/verify') + result = resp.json() + if result.get('success') and result.get('data', {}).get('cookie_valid'): + valid += 1 + else: + invalid += 1 + except Exception: + errors += 1 + + return jsonify({ + 'success': True, + 'data': {'valid': valid, 'invalid': invalid, 'errors': errors, 'total': len(accounts)}, + }) + except Exception as e: + logger.exception("批量验证异常") + return jsonify({'success': False, 'message': str(e)}), 500 + + +@app.route('/api/batch/signin', methods=['POST']) +@login_required +def batch_signin(): + """批量签到所有正常状态的账号""" + try: + response = api_request('GET', f'{API_BASE_URL}/api/v1/accounts') + data = response.json() + accounts = data.get('data', []) if data.get('success') else [] + + total_signed = total_already = total_failed = 0 + processed = 0 + for account in accounts: + if account.get('status') != 'active': + continue + try: + resp = api_request('POST', f'{API_BASE_URL}/api/v1/accounts/{account["id"]}/signin') + result = resp.json() + if result.get('success'): + d = result.get('data', {}) + total_signed += d.get('signed', 0) + total_already += d.get('already_signed', 0) + total_failed += d.get('failed', 0) + processed += 1 + except Exception as e: + logger.warning(f"批量签到账号 {account['id']} 失败: {e}") + total_failed += 1 + + return jsonify({ + 'success': True, + 'data': { + 'total_accounts': processed, + 'total_signed': total_signed, + 'total_already': total_already, + 'total_failed': total_failed, + }, + }) + except Exception as e: + logger.exception("批量签到异常") + return jsonify({'success': False, 'message': str(e)}), 500 + + @app.errorhandler(404) def not_found(error): return render_template('404.html'), 404 @@ -705,4 +911,5 @@ def server_error(error): return render_template('500.html'), 500 if __name__ == '__main__': - app.run(debug=True, port=5000) + # use_reloader=False 避免 Windows 终端 QuickEdit 模式导致进程挂起 + app.run(debug=True, port=5000, use_reloader=False) diff --git a/frontend/flask_session/2029240f6d1128be89ddc32729463129 b/frontend/flask_session/2029240f6d1128be89ddc32729463129 index 7f5741f13017ee705ea34021d222a06a6ee2a6c9..ffb2cd9bfd75610267f6ba499787709ee5ac010d 100644 GIT binary patch literal 9 QcmZQzU|?uq^=8%s00XiC0ssI2 literal 9 QcmZQzU|?uq^=8ro00XcA0RR91 diff --git a/frontend/flask_session/63ef42ce753fadbb10b138d04f18d64d b/frontend/flask_session/63ef42ce753fadbb10b138d04f18d64d new file mode 100644 index 0000000000000000000000000000000000000000..40132ebef8c6866e7ada1d5732229ae5176d8c7b GIT binary patch literal 494 zcmXw0%Wj)M6m6?UYNKXR^%uJ625YbhHd)2Ru``Z6@q_xcLBnvV2N-Yw*9NIlmRZbl z@;&{O_8}hxz(9~6 zL3$~mOG0y)NCXL|&G8c^g<=p41K>+T7ZAb4z?GQ~1QJM)266}k^16D&96?kNXr{+U zgbZQ81=COj1TqnLnEHUZlEfqegFp;kOAo$;EQb(R15caH@#9?>dC6dzKJ_KQ)}+5k z=g3u5M{MMmbumZ^?UCo~;>E&g2NQK}?0ZO__UpOa>li_$ODN?irZP9;_`MtDgD5y1 zKf#jgOAJbYNDu>phzmRx1i)pU6jCa>GWNND0Is4cw~=a!+HNn#jU}BsTX%ZE=3rZi z3(DeQHnK|rUJ7DPz%qfiZ4CUOhma?Oq3=ULNI-xmKp!ARd;v+46!`dTWAET_8^sd0 zamZ>za~$J%01OMvYl2V(-h*w#mq^HANfr>f?Igo%*x!O&Gf6Nr)5L{U`${PdAh$zSm= zV1>2q@H^#1hozb$+3l(quNAM-_2g+st~R=uY#Z~! zsIbkiR%v-sn}=a(yralA*DJkvmTzAPe1Ue-W_M{{4=oYV&PtvvN0vb;MY1hxa%b$+ zML7=k?j&i{sZp^~D8el@7hPIDjN7A1&PbMvFj0qLS=B>IH=68@yoy3%s>Wl=Tzw(cxeLrN@<_`O1t6qU$pHtkxk=3MMIR>3P) z^eQxwHWBH-%=}0Sbt;L5~iOiE~y1MK6tfuLu z5=ru z$@bol7(|3Zg0OHpet*S-qHsQ#yK#6r{`g;i|Lx;{{GR{*=Zh~N|LM>F{^PG6|Lu2= z|N56RORPzivt-rhEMdb4)DB8u&93+6cvDxVt#JELmQZquF^^f8EaGQa@|#z(ga|}B zvqa6SRM9D-c?vOR8b*R_=unh3RX{q;-^Y?00R%w4EkTj@5`c>t^I0g0i10WC5zZ8j z5s4u>6rY93x$AkcKY__wxF05E={cA8qqRZs%z@o6MkK=bhTdf zqPf{`4&T8fgVkLyISKVc$ck_9Fj6*Z;^s;DEGaHgm_6(Cjx9cnbs_(AQSz=Q~y01gvEu#7M#3UC7+ zp}4*~^r;(Q4+b8`i%Ue`r+gEB8kwBCJ`a;Uz8@y);5nGIySpbaX^)p}b>Zy#f|=j- zHHEe{woxkH+$+CrFyY?juk>PMk~x#|C$}S$jhVh0CW^Oz5hivIKL?Ybm)N8@x5G}$ zjAf0-cCk{kZA)ve6p~17zgT()llx)9hvyB!#Oh?H9IWs}23suIi20~DDF%hfHXu!Y z>Ul6s=q2TcTf$fUvysWGPdPlPK;;Z3q97wdQe{m-ihv+yJWAnb136C(4NJOlFHFt? z2oNdbSrm*#K!}-T2TQu?vn(O?rse2% zOTo=iIqp;(UDaDyO(U>3+O{sYL2><#CHJx<8=g1Bl3Jmku_Q~1;(CbcTXE!0=e~%m zY1rJy{RgvzWl2$f`oc0d`ANp)8=P`@-s;Q}O)xZ>U;`3Eg1W>LZVd7aQyytp7Y%-P zxi2s|3*dnXMT`kh=Gj-7=SoD^_X8G)SVD3b%9y9)crIN;w_qaOh6#KLOyuWbvX7p? z#Hp2a+u2UHh3b4MRa#3}X;aKrJ4`3tDCIO`)&|N^V9MD zFm|Ka>G;F*|Ifl}H(t!boS(#}){F z^1(lPT1RUMyapii1T=SEv>LenI@*S(&o{>(aKi9R@?f1zLvF{HA`mmF5lV=S!?KU38tiDd%{jL(nN@jiOpBm{=WW(%bWTN^FcPtvad3rFAdvp=w@$ zyht>w7WAE^^(Xz-D5~U5=p$$E#m2hdUCzDQnZWu-SMt8Nv^90|4==Yh}EhK z!+LHzRoW7ZfY~%?8B87DPDbNNu)y&`Qwv>p-mUh44El=BD~TVc;V=zXhV+D delta 190 zcmeCOTFj*o7<)alfpw|~D+3tRPSNP$iZ4hl%1z8m%`2JGvDr`BfU({;#M96}B)K9j zP|q?cDL~i2!>`1zsx%_S$-^%^&AZ6ZB-_igxWKvCE6B|}H`u(uI5jXYc}fp&QEFOI zYH>z;>xVR3f<63 -

404

-

Page Not Found

-

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

- Go to Dashboard +
+
🔍
+

404

+

页面未找到

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

500

-

Server Error

-

Something went wrong on our end.

- Go to Dashboard +
+
⚠️
+

500

+

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

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

{{ account.weibo_user_id }}

-
- Edit -
- +
+
+
+

{{ account.remark or account.weibo_user_id }}

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

No tasks yet

+

暂无定时任务

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

Add Weibo Account

+
+
+

📱 添加微博账号

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

如何获取微博 Cookie

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

⚠️ 重要提示

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

微博扫码登录

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

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

-

使用步骤:

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

💡 说明

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

⚠️ 注意事项

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

手动添加账号

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

💡 快速提示

-

Weibo User ID 在哪里找?

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

使用说明

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

Add Signin Task

+
+
+

⏰ 添加签到任务

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

Weibo Accounts

- + Add Account +
+

👋 控制台

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