Compare commits
4 Commits
c2bcde5e59
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| dccfae5227 | |||
| 4fea75c49f | |||
| 905125f23b | |||
| 420033c61f |
@@ -282,7 +282,7 @@ def _push_signin_result(account_id: str, result: dict, elapsed: float):
|
||||
elif status == "completed" and result.get("signed", 0) == 0 and result.get("total", 0) == 0:
|
||||
msg = result.get("message", "")
|
||||
if "no topics" in msg:
|
||||
lines = [f"⚠️ {remark} 签到失败: 获取超话列表失败(可能被风控或 Cookie 失效)"]
|
||||
lines = [f"⚠️ {remark} 获取超话失败,可能被风控或 Cookie 失效,请检查"]
|
||||
elif "no selected" in msg:
|
||||
lines = [f"⚠️ {remark} 签到跳过: 没有选中的超话"]
|
||||
else:
|
||||
@@ -388,13 +388,15 @@ async def _async_do_signin(account_id: str, cron_expr: str = ""):
|
||||
try:
|
||||
topics = await _fetch_topics(cookies)
|
||||
if not topics:
|
||||
# 获取超话失败,可能是风控或 Cookie 失效
|
||||
# 不立即标记 invalid_cookie,等用户手动验证确认
|
||||
async with SessionFactory() as session:
|
||||
result = await session.execute(select(Account).where(Account.id == acc_id))
|
||||
acc = result.scalar_one_or_none()
|
||||
if acc:
|
||||
acc.last_checked_at = datetime.now()
|
||||
await session.commit()
|
||||
return {"status": "completed", "signed": 0, "message": "no topics"}
|
||||
return {"status": "completed", "signed": 0, "message": "no topics - 获取超话失败"}
|
||||
|
||||
# 如果用户选择了特定超话,只签选中的
|
||||
if acc_selected_topics and isinstance(acc_selected_topics, list):
|
||||
@@ -476,7 +478,7 @@ async def _async_do_signin(account_id: str, cron_expr: str = ""):
|
||||
async def _fetch_topics(cookies: dict) -> list:
|
||||
"""
|
||||
获取关注的超话列表。
|
||||
带重试机制:微博凌晨可能触发风控(302 到通行证验证页),重试 2 次。
|
||||
带重试机制:微博可能触发风控,重试 3 次。
|
||||
返回空列表表示获取失败(Cookie 失效或风控)。
|
||||
"""
|
||||
import httpx
|
||||
@@ -485,32 +487,34 @@ async def _fetch_topics(cookies: dict) -> list:
|
||||
for attempt in range(max_retries):
|
||||
topics = []
|
||||
try:
|
||||
# 不自动跟随重定向,手动检测 302
|
||||
async with httpx.AsyncClient(timeout=15, follow_redirects=False) as client:
|
||||
async with httpx.AsyncClient(timeout=20, follow_redirects=True) as client:
|
||||
resp = await client.get("https://weibo.com/", headers=WEIBO_HEADERS, cookies=cookies)
|
||||
final_url = str(resp.url)
|
||||
|
||||
# 302 重定向到登录页 = Cookie 失效或风控
|
||||
if resp.status_code in (301, 302):
|
||||
location = resp.headers.get("location", "")
|
||||
if "login.sina.com.cn" in location or "passport" in location:
|
||||
if attempt < max_retries - 1:
|
||||
wait = (attempt + 1) * 3
|
||||
logger.warning(f"被重定向到登录页(尝试 {attempt+1}/{max_retries}),{wait}秒后重试...")
|
||||
await asyncio.sleep(wait)
|
||||
continue
|
||||
else:
|
||||
logger.warning(f"多次重试仍被重定向,Cookie 可能已失效")
|
||||
return []
|
||||
# 最终落地在登录页 = Cookie 失效或风控
|
||||
if "login.sina.com.cn" in final_url or "passport.weibo.com" in final_url:
|
||||
if attempt < max_retries - 1:
|
||||
wait = (attempt + 1) * 3
|
||||
logger.warning(f"被重定向到登录页(尝试 {attempt+1}/{max_retries}),{wait}秒后重试... url={final_url[:120]}")
|
||||
await asyncio.sleep(wait)
|
||||
continue
|
||||
else:
|
||||
logger.warning(f"多次重试仍被重定向到登录页,Cookie 已失效: {final_url[:120]}")
|
||||
return []
|
||||
|
||||
# 其他 302(如 weibo.com → www.weibo.com),跟随一次
|
||||
resp = await client.get(location, headers=WEIBO_HEADERS, cookies=cookies)
|
||||
|
||||
xsrf = ""
|
||||
for c in resp.cookies.jar:
|
||||
if c.name == "XSRF-TOKEN":
|
||||
xsrf = c.value
|
||||
break
|
||||
# 检查响应内容是否是通行证验证页(HTML 而非正常页面)
|
||||
content_type = resp.headers.get("content-type", "")
|
||||
if "text/html" in content_type and "通行证" in resp.text[:500]:
|
||||
if attempt < max_retries - 1:
|
||||
wait = (attempt + 1) * 3
|
||||
logger.warning(f"触发通行证验证(尝试 {attempt+1}/{max_retries}),{wait}秒后重试...")
|
||||
await asyncio.sleep(wait)
|
||||
continue
|
||||
else:
|
||||
logger.warning("多次重试仍触发通行证验证")
|
||||
return []
|
||||
|
||||
xsrf = client.cookies.get("XSRF-TOKEN", "")
|
||||
headers = {**WEIBO_HEADERS, "X-Requested-With": "XMLHttpRequest"}
|
||||
if xsrf:
|
||||
headers["X-XSRF-TOKEN"] = xsrf
|
||||
@@ -523,22 +527,25 @@ async def _fetch_topics(cookies: dict) -> list:
|
||||
headers=headers, cookies=cookies,
|
||||
)
|
||||
|
||||
# 超话接口也可能被 302
|
||||
if resp.status_code in (301, 302):
|
||||
# 超话接口被重定向到登录页
|
||||
final_url = str(resp.url)
|
||||
if "login.sina.com.cn" in final_url or "passport" in final_url:
|
||||
if attempt < max_retries - 1:
|
||||
wait = (attempt + 1) * 3
|
||||
logger.warning(f"超话接口被重定向(尝试 {attempt+1}/{max_retries}),{wait}秒后重试...")
|
||||
await asyncio.sleep(wait)
|
||||
break # break inner loop, retry outer
|
||||
logger.warning(f"超话接口被重定向(尝试 {attempt+1}/{max_retries}),重试...")
|
||||
await asyncio.sleep((attempt + 1) * 3)
|
||||
break
|
||||
else:
|
||||
logger.warning("超话接口多次重定向,放弃")
|
||||
return []
|
||||
|
||||
try:
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
logger.warning(f"超话列表响应非 JSON: {resp.text[:200]}")
|
||||
break
|
||||
logger.warning(f"超话列表响应非 JSON(尝试 {attempt+1}): {resp.text[:200]}")
|
||||
if attempt < max_retries - 1:
|
||||
await asyncio.sleep((attempt + 1) * 3)
|
||||
break
|
||||
return []
|
||||
|
||||
if data.get("ok") != 1:
|
||||
break
|
||||
tlist = data.get("data", {}).get("list", [])
|
||||
@@ -558,11 +565,9 @@ async def _fetch_topics(cookies: dict) -> list:
|
||||
break
|
||||
page += 1
|
||||
else:
|
||||
# while 正常结束(没有 break),说明成功
|
||||
if topics:
|
||||
return topics
|
||||
|
||||
# 如果有 topics 说明成功了
|
||||
if topics:
|
||||
return topics
|
||||
|
||||
@@ -646,6 +651,7 @@ async def _build_daily_report() -> str:
|
||||
from shared.models.account import Account
|
||||
from shared.models.signin_log import SigninLog
|
||||
from shared.models.user import User
|
||||
from shared.crypto import decrypt_cookie, derive_key
|
||||
|
||||
today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
now = datetime.now()
|
||||
@@ -724,19 +730,37 @@ async def _build_daily_report() -> str:
|
||||
if msg:
|
||||
rank_details.append({"name": name, "topic": topic, "message": msg})
|
||||
|
||||
# 4. Cookie 即将失效的账号(超过 3 天未检查)
|
||||
stale_cutoff = now - timedelta(days=3)
|
||||
stale_result = await session.execute(
|
||||
select(Account.remark, Account.weibo_user_id, Account.last_checked_at)
|
||||
.where(Account.status == "active")
|
||||
.where(
|
||||
(Account.last_checked_at < stale_cutoff) | (Account.last_checked_at == None)
|
||||
)
|
||||
# 4. Cookie 过期时间检测(从 ALF 字段解析)
|
||||
cookie_expiry = []
|
||||
all_accounts_result = await session.execute(
|
||||
select(Account.id, Account.remark, Account.weibo_user_id,
|
||||
Account.encrypted_cookies, Account.iv, Account.status)
|
||||
.where(Account.status.in_(["active", "pending"]))
|
||||
)
|
||||
stale_accounts = [
|
||||
(row[0] or row[1], row[2].strftime("%m-%d %H:%M") if row[2] else "从未")
|
||||
for row in stale_result.all()
|
||||
]
|
||||
key = derive_key(shared_settings.COOKIE_ENCRYPTION_KEY)
|
||||
for acc_row in all_accounts_result.all():
|
||||
acc_id, remark, uid, enc_cookies, iv, status = acc_row
|
||||
name = remark or uid
|
||||
try:
|
||||
cookie_str = decrypt_cookie(enc_cookies, iv, key)
|
||||
alf = ""
|
||||
for pair in cookie_str.split(";"):
|
||||
pair = pair.strip()
|
||||
if pair.startswith("ALF="):
|
||||
alf = pair.split("=", 1)[1].strip()
|
||||
break
|
||||
if alf and alf.isdigit():
|
||||
expire_dt = datetime.fromtimestamp(int(alf))
|
||||
remain_days = (expire_dt - now).days
|
||||
cookie_expiry.append({
|
||||
"name": name,
|
||||
"expire": expire_dt.strftime("%m-%d"),
|
||||
"remain": remain_days,
|
||||
})
|
||||
else:
|
||||
cookie_expiry.append({"name": name, "expire": "未知", "remain": -1})
|
||||
except Exception:
|
||||
cookie_expiry.append({"name": name, "expire": "解密失败", "remain": -1})
|
||||
|
||||
finally:
|
||||
await eng.dispose()
|
||||
@@ -772,10 +796,22 @@ async def _build_daily_report() -> str:
|
||||
for r in rank_details:
|
||||
lines.append(f" {r['name']} - {r['topic']}: {r['message']}")
|
||||
|
||||
if stale_accounts:
|
||||
lines += ["", "⚠️ 需要关注"]
|
||||
for name, last in stale_accounts:
|
||||
lines.append(f" {name} (上次检查: {last})")
|
||||
if cookie_expiry:
|
||||
lines += ["", "🍪 Cookie 有效期"]
|
||||
expiring_soon = []
|
||||
for ce in cookie_expiry:
|
||||
remain = ce["remain"]
|
||||
if remain < 0:
|
||||
lines.append(f" ⚠️ {ce['name']}: {ce['expire']}")
|
||||
elif remain <= 3:
|
||||
lines.append(f" 🔴 {ce['name']}: {ce['expire']} (剩 {remain} 天,即将过期!)")
|
||||
expiring_soon.append(ce["name"])
|
||||
elif remain <= 7:
|
||||
lines.append(f" 🟡 {ce['name']}: {ce['expire']} (剩 {remain} 天)")
|
||||
else:
|
||||
lines.append(f" 🟢 {ce['name']}: {ce['expire']} (剩 {remain} 天)")
|
||||
if expiring_soon:
|
||||
lines.append(f" ⚠️ 请尽快重新扫码: {', '.join(expiring_soon)}")
|
||||
|
||||
if total_logs == 0:
|
||||
lines += ["", "💤 今日暂无签到记录"]
|
||||
|
||||
124
test_fetch_topics.py
Normal file
124
test_fetch_topics.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""
|
||||
验证签到流程是否正常。
|
||||
在服务器上执行: docker exec -it weibo-scheduler python -m test_fetch_topics
|
||||
或本地: cd backend && python ../test_fetch_topics.py
|
||||
|
||||
会从数据库读取第一个 active 账号,解密 Cookie,模拟 _fetch_topics 流程。
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
import asyncio
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "backend"))
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__)))
|
||||
|
||||
# 兼容容器内运行(/app 目录)
|
||||
if os.path.exists("/app/shared"):
|
||||
sys.path.insert(0, "/app")
|
||||
|
||||
from shared.config import shared_settings
|
||||
from shared.crypto import decrypt_cookie, derive_key
|
||||
|
||||
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",
|
||||
}
|
||||
|
||||
|
||||
async def main():
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from shared.models.account import Account
|
||||
|
||||
engine = create_async_engine(shared_settings.DATABASE_URL, echo=False)
|
||||
Session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
async with Session() as session:
|
||||
result = await session.execute(
|
||||
select(Account).where(Account.status.in_(["active", "pending"])).limit(1)
|
||||
)
|
||||
account = result.scalar_one_or_none()
|
||||
if not account:
|
||||
print("❌ 没有 active/pending 账号")
|
||||
return
|
||||
|
||||
print(f"📱 账号: {account.remark or account.weibo_user_id} (status={account.status})")
|
||||
|
||||
key = derive_key(shared_settings.COOKIE_ENCRYPTION_KEY)
|
||||
cookie_str = decrypt_cookie(account.encrypted_cookies, account.iv, key)
|
||||
cookies = {}
|
||||
for pair in cookie_str.split(";"):
|
||||
pair = pair.strip()
|
||||
if "=" in pair:
|
||||
k, v = pair.split("=", 1)
|
||||
cookies[k.strip()] = v.strip()
|
||||
|
||||
print(f"🍪 Cookie 数量: {len(cookies)}, keys: {list(cookies.keys())}")
|
||||
|
||||
# 检查 Cookie 有效期
|
||||
from datetime import datetime
|
||||
alf = cookies.get("ALF", "")
|
||||
if alf and alf.isdigit():
|
||||
expire_time = datetime.fromtimestamp(int(alf))
|
||||
remain = (expire_time - datetime.now()).days
|
||||
print(f"📅 Cookie 过期时间: {expire_time.strftime('%Y-%m-%d %H:%M:%S')} (还剩 {remain} 天)")
|
||||
else:
|
||||
print(f"📅 ALF 字段: {alf or '无'} (无法判断过期时间)")
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
# 测试 1: 访问 weibo.com
|
||||
print("\n--- 测试 1: GET https://weibo.com/ ---")
|
||||
async with httpx.AsyncClient(timeout=20, follow_redirects=True) as client:
|
||||
resp = await client.get("https://weibo.com/", headers=WEIBO_HEADERS, cookies=cookies)
|
||||
final_url = str(resp.url)
|
||||
print(f" 状态码: {resp.status_code}")
|
||||
print(f" 最终URL: {final_url}")
|
||||
print(f" 是否登录页: {'login.sina.com.cn' in final_url or 'passport' in final_url}")
|
||||
|
||||
if "login.sina.com.cn" in final_url or "passport" in final_url:
|
||||
print("\n❌ Cookie 已失效,被重定向到登录页")
|
||||
return
|
||||
|
||||
xsrf = client.cookies.get("XSRF-TOKEN", "")
|
||||
print(f" XSRF-TOKEN: {'有' if xsrf else '无'}")
|
||||
|
||||
# 测试 2: 获取超话列表
|
||||
print("\n--- 测试 2: 获取超话列表 ---")
|
||||
headers = {**WEIBO_HEADERS, "X-Requested-With": "XMLHttpRequest"}
|
||||
if xsrf:
|
||||
headers["X-XSRF-TOKEN"] = xsrf
|
||||
|
||||
resp = await client.get(
|
||||
"https://weibo.com/ajax/profile/topicContent",
|
||||
params={"tabid": "231093_-_chaohua", "page": "1"},
|
||||
headers=headers, cookies=cookies,
|
||||
)
|
||||
print(f" 状态码: {resp.status_code}")
|
||||
print(f" 最终URL: {resp.url}")
|
||||
|
||||
try:
|
||||
data = resp.json()
|
||||
print(f" ok: {data.get('ok')}")
|
||||
topics = data.get("data", {}).get("list", [])
|
||||
print(f" 超话数量: {len(topics)}")
|
||||
for t in topics[:5]:
|
||||
title = t.get("topic_name", "") or t.get("title", "")
|
||||
print(f" - {title}")
|
||||
if topics:
|
||||
print("\n✅ Cookie 有效,超话获取正常")
|
||||
else:
|
||||
print("\n⚠️ Cookie 可能有效但没有关注超话")
|
||||
except Exception as e:
|
||||
print(f" ❌ 响应非 JSON: {resp.text[:300]}")
|
||||
print(f"\n❌ 获取超话失败: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Reference in New Issue
Block a user