优化签到逻辑: 提前预加载+整点签到+实时推送签到结果含名次和耗时
This commit is contained in:
@@ -172,8 +172,25 @@ def sync_db_tasks():
|
|||||||
logger.warning(f"无效 cron: task={task_id}, expr={cron_expr}")
|
logger.warning(f"无效 cron: task={task_id}, expr={cron_expr}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# 提前 1 分钟触发,用于预加载超话列表,到整点再发签到请求
|
||||||
|
orig_minute = parts[0]
|
||||||
|
orig_hour = parts[1]
|
||||||
|
try:
|
||||||
|
m = int(orig_minute)
|
||||||
|
h = int(orig_hour) if orig_hour != "*" else None
|
||||||
|
if m == 0:
|
||||||
|
early_minute = "59"
|
||||||
|
early_hour = str(h - 1) if h is not None and h > 0 else "23" if h == 0 else "*"
|
||||||
|
else:
|
||||||
|
early_minute = str(m - 1)
|
||||||
|
early_hour = orig_hour
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
# 复杂 cron 表达式(如 */5),不做提前
|
||||||
|
early_minute = orig_minute
|
||||||
|
early_hour = orig_hour
|
||||||
|
|
||||||
trigger = CronTrigger(
|
trigger = CronTrigger(
|
||||||
minute=parts[0], hour=parts[1],
|
minute=early_minute, hour=early_hour,
|
||||||
day=parts[2], month=parts[3], day_of_week=parts[4],
|
day=parts[2], month=parts[3], day_of_week=parts[4],
|
||||||
timezone="Asia/Shanghai",
|
timezone="Asia/Shanghai",
|
||||||
)
|
)
|
||||||
@@ -181,12 +198,13 @@ def sync_db_tasks():
|
|||||||
run_signin,
|
run_signin,
|
||||||
trigger=trigger,
|
trigger=trigger,
|
||||||
id=job_id,
|
id=job_id,
|
||||||
args=[task_id, account_id],
|
args=[task_id, account_id, cron_expr],
|
||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
misfire_grace_time=300, # 5 分钟内的 misfire 仍然执行
|
misfire_grace_time=300,
|
||||||
)
|
)
|
||||||
_registered_tasks[task_id] = cron_expr
|
_registered_tasks[task_id] = cron_expr
|
||||||
logger.info(f"✅ 注册任务: task={task_id}, account={account_id}, cron={cron_expr}")
|
actual_cron = f"{early_minute} {early_hour} {parts[2]} {parts[3]} {parts[4]}"
|
||||||
|
logger.info(f"✅ 注册任务: task={task_id}, account={account_id}, 用户cron={cron_expr}, 实际触发={actual_cron}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"注册任务失败: task={task_id}, error={e}")
|
logger.error(f"注册任务失败: task={task_id}, error={e}")
|
||||||
|
|
||||||
@@ -227,24 +245,111 @@ async def _load_tasks_from_db():
|
|||||||
|
|
||||||
# =============== 签到入口 ===============
|
# =============== 签到入口 ===============
|
||||||
|
|
||||||
def run_signin(task_id: str, account_id: str):
|
def run_signin(task_id: str, account_id: str, cron_expr: str = ""):
|
||||||
"""APScheduler 调用的签到入口(同步函数,内部跑 async)。"""
|
"""APScheduler 调用的签到入口(同步函数,内部跑 async)。"""
|
||||||
logger.info(f"🎯 开始签到: task={task_id}, account={account_id}")
|
logger.info(f"🎯 开始签到: task={task_id}, account={account_id}, cron={cron_expr}")
|
||||||
|
start = _time.time()
|
||||||
try:
|
try:
|
||||||
result = _run_async(_async_do_signin(account_id))
|
result = _run_async(asyncio.wait_for(_async_do_signin(account_id, cron_expr), timeout=300))
|
||||||
logger.info(f"✅ 签到完成: task={task_id}, result={result}")
|
elapsed = _time.time() - start
|
||||||
|
result["elapsed_seconds"] = round(elapsed, 1)
|
||||||
|
logger.info(f"✅ 签到完成: task={task_id}, 耗时={elapsed:.1f}s, result={result}")
|
||||||
|
# 签到完成后立即推送通知
|
||||||
|
_push_signin_result(account_id, result, elapsed)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
elapsed = _time.time() - start
|
||||||
|
logger.error(f"⏰ 签到超时(5分钟): task={task_id}, account={account_id}")
|
||||||
|
_push_signin_result(account_id, {"status": "timeout"}, elapsed)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
elapsed = _time.time() - start
|
||||||
logger.error(f"❌ 签到失败: task={task_id}, error={e}")
|
logger.error(f"❌ 签到失败: task={task_id}, error={e}")
|
||||||
|
_push_signin_result(account_id, {"status": "error", "reason": str(e)}, elapsed)
|
||||||
|
|
||||||
|
|
||||||
async def _async_do_signin(account_id: str):
|
def _push_signin_result(account_id: str, result: dict, elapsed: float):
|
||||||
"""执行单个账号的全量超话签到。"""
|
"""签到完成后立即推送结果到 Webhook。"""
|
||||||
|
if not WEBHOOK_URL:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
# 获取账号备注名
|
||||||
|
remark = _run_async(_get_account_remark(account_id)) or account_id[:8]
|
||||||
|
|
||||||
|
status = result.get("status", "")
|
||||||
|
if status == "timeout":
|
||||||
|
lines = [f"⏰ {remark} 签到超时 ({elapsed:.1f}s)"]
|
||||||
|
elif status == "error":
|
||||||
|
lines = [f"❌ {remark} 签到异常: {result.get('reason', '未知')}"]
|
||||||
|
else:
|
||||||
|
signed = result.get("signed", 0)
|
||||||
|
already = result.get("already_signed", 0)
|
||||||
|
failed = result.get("failed", 0)
|
||||||
|
total = result.get("total", 0)
|
||||||
|
details = result.get("details", [])
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f"🎯 {remark} 签到完成",
|
||||||
|
f"⏱ 耗时: {elapsed:.1f}s | 超话: {total} 个",
|
||||||
|
f"✅ 成功: {signed} 📌 已签: {already} ❌ 失败: {failed}",
|
||||||
|
]
|
||||||
|
|
||||||
|
# 签到名次明细
|
||||||
|
if details:
|
||||||
|
lines.append("")
|
||||||
|
for d in details:
|
||||||
|
topic = d.get("topic", "")
|
||||||
|
msg = d.get("message", "")
|
||||||
|
st = d.get("status", "")
|
||||||
|
icon = "✅" if st == "success" else "📌" if st == "already_signed" else "❌"
|
||||||
|
lines.append(f" {icon} {topic}: {msg}")
|
||||||
|
|
||||||
|
_send_webhook("\n".join(lines))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"推送签到结果失败: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_account_remark(account_id: str) -> str:
|
||||||
|
from sqlalchemy import select
|
||||||
|
from shared.models.account import Account
|
||||||
|
|
||||||
|
SessionFactory, eng = _make_session()
|
||||||
|
try:
|
||||||
|
async with SessionFactory() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
select(Account.remark, Account.weibo_user_id).where(Account.id == account_id)
|
||||||
|
)
|
||||||
|
row = result.first()
|
||||||
|
return (row[0] or row[1]) if row else ""
|
||||||
|
finally:
|
||||||
|
await eng.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
async def _async_do_signin(account_id: str, cron_expr: str = ""):
|
||||||
|
"""
|
||||||
|
执行单个账号的全量超话签到。
|
||||||
|
流程:提前 1 分钟触发 → 预加载超话列表 + XSRF → 等到目标整点 → 快速签到
|
||||||
|
"""
|
||||||
import httpx
|
import httpx
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from shared.models.account import Account
|
from shared.models.account import Account
|
||||||
from shared.models.signin_log import SigninLog
|
from shared.models.signin_log import SigninLog
|
||||||
from shared.crypto import decrypt_cookie, derive_key
|
from shared.crypto import decrypt_cookie, derive_key
|
||||||
|
|
||||||
|
# 计算目标签到时间(用户设定的 cron 对应的时间点)
|
||||||
|
target_time = None
|
||||||
|
if cron_expr:
|
||||||
|
try:
|
||||||
|
from croniter import croniter
|
||||||
|
# 从当前时间往后算下一个 cron 触发点(因为我们提前了 1 分钟)
|
||||||
|
now = datetime.now()
|
||||||
|
cron = croniter(cron_expr, now)
|
||||||
|
target_time = cron.get_next(datetime)
|
||||||
|
# 如果下次触发超过 2 分钟,说明计算有误,忽略
|
||||||
|
if (target_time - now).total_seconds() > 120:
|
||||||
|
target_time = None
|
||||||
|
except Exception:
|
||||||
|
target_time = None
|
||||||
|
|
||||||
|
# ---- 阶段 1: 短事务读取账号信息 ----
|
||||||
SessionFactory, eng = _make_session()
|
SessionFactory, eng = _make_session()
|
||||||
try:
|
try:
|
||||||
async with SessionFactory() as session:
|
async with SessionFactory() as session:
|
||||||
@@ -263,18 +368,45 @@ async def _async_do_signin(account_id: str):
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
return {"status": "failed", "reason": "cookie decryption failed"}
|
return {"status": "failed", "reason": "cookie decryption failed"}
|
||||||
|
|
||||||
|
acc_id = str(account.id)
|
||||||
|
except Exception as e:
|
||||||
|
await eng.dispose()
|
||||||
|
raise e
|
||||||
|
|
||||||
cookies = _parse_cookies(cookie_str)
|
cookies = _parse_cookies(cookie_str)
|
||||||
|
|
||||||
# 获取超话列表
|
# ---- 阶段 2: 预加载超话列表 + XSRF token ----
|
||||||
|
try:
|
||||||
topics = await _fetch_topics(cookies)
|
topics = await _fetch_topics(cookies)
|
||||||
if not topics:
|
if not topics:
|
||||||
|
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"}
|
||||||
|
|
||||||
# 逐个签到
|
|
||||||
signed = already = failed = 0
|
signed = already = failed = 0
|
||||||
|
log_entries = []
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
|
||||||
|
# 预获取 XSRF token
|
||||||
|
await client.get("https://weibo.com/", headers=WEIBO_HEADERS, cookies=cookies)
|
||||||
|
xsrf = client.cookies.get("XSRF-TOKEN", "")
|
||||||
|
|
||||||
|
logger.info(f"📦 预加载完成: account={account_id}, topics={len(topics)}, xsrf={'有' if xsrf else '无'}")
|
||||||
|
|
||||||
|
# ---- 等到目标时间再开始签到 ----
|
||||||
|
if target_time:
|
||||||
|
wait_seconds = (target_time - datetime.now()).total_seconds()
|
||||||
|
if 0 < wait_seconds <= 90:
|
||||||
|
logger.info(f"⏳ 等待 {wait_seconds:.1f} 秒到目标时间 {target_time.strftime('%H:%M:%S')}")
|
||||||
|
await asyncio.sleep(wait_seconds)
|
||||||
|
|
||||||
|
# ---- 快速签到(间隔 0.5 秒) ----
|
||||||
for topic in topics:
|
for topic in topics:
|
||||||
await asyncio.sleep(1.5)
|
r = await _do_single_signin(client, cookies, topic, xsrf)
|
||||||
r = await _do_single_signin(cookies, topic)
|
|
||||||
if r["status"] == "success":
|
if r["status"] == "success":
|
||||||
s, signed = "success", signed + 1
|
s, signed = "success", signed + 1
|
||||||
elif r["status"] == "already_signed":
|
elif r["status"] == "already_signed":
|
||||||
@@ -282,33 +414,59 @@ async def _async_do_signin(account_id: str):
|
|||||||
else:
|
else:
|
||||||
s, failed = "failed_network", failed + 1
|
s, failed = "failed_network", failed + 1
|
||||||
|
|
||||||
session.add(SigninLog(
|
log_entries.append(SigninLog(
|
||||||
account_id=account.id, topic_title=topic["title"],
|
account_id=acc_id, topic_title=topic["title"],
|
||||||
status=s, reward_info={"message": r["message"]},
|
status=s, reward_info={"message": r["message"]},
|
||||||
signed_at=datetime.now(),
|
signed_at=datetime.now(),
|
||||||
))
|
))
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
account.last_checked_at = datetime.now()
|
# ---- 阶段 3: 短事务写日志 + 更新状态 ----
|
||||||
if account.status != "active":
|
async with SessionFactory() as session:
|
||||||
account.status = "active"
|
for log in log_entries:
|
||||||
|
session.add(log)
|
||||||
|
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()
|
||||||
|
if acc.status != "active":
|
||||||
|
acc.status = "active"
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"签到结果: account={account_id}, "
|
f"签到结果: account={account_id}, "
|
||||||
f"signed={signed}, already={already}, failed={failed}"
|
f"signed={signed}, already={already}, failed={failed}"
|
||||||
)
|
)
|
||||||
return {"signed": signed, "already_signed": already, "failed": failed, "total": len(topics)}
|
return {
|
||||||
|
"signed": signed, "already_signed": already, "failed": failed,
|
||||||
|
"total": len(topics),
|
||||||
|
"details": [
|
||||||
|
{"topic": e.topic_title, "status": e.status,
|
||||||
|
"message": (e.reward_info or {}).get("message", "")}
|
||||||
|
for e in log_entries
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"签到过程异常: account={account_id}, error={e}")
|
||||||
|
return {"status": "error", "reason": str(e)}
|
||||||
finally:
|
finally:
|
||||||
await eng.dispose()
|
await eng.dispose()
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_topics(cookies: dict) -> list:
|
async def _fetch_topics(cookies: dict) -> list:
|
||||||
"""获取关注的超话列表。"""
|
"""获取关注的超话列表。Cookie 失效时返回空列表。"""
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
topics = []
|
topics = []
|
||||||
|
try:
|
||||||
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
|
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
|
||||||
await client.get("https://weibo.com/", headers=WEIBO_HEADERS, cookies=cookies)
|
resp = await client.get("https://weibo.com/", headers=WEIBO_HEADERS, cookies=cookies)
|
||||||
|
# 检测是否被重定向到访客页(Cookie 失效)
|
||||||
|
if "passport.weibo.com" in str(resp.url) or "visitor" in str(resp.url).lower():
|
||||||
|
logger.warning("Cookie 已失效,被重定向到登录/访客页")
|
||||||
|
return []
|
||||||
|
|
||||||
xsrf = client.cookies.get("XSRF-TOKEN", "")
|
xsrf = client.cookies.get("XSRF-TOKEN", "")
|
||||||
headers = {**WEIBO_HEADERS, "X-Requested-With": "XMLHttpRequest"}
|
headers = {**WEIBO_HEADERS, "X-Requested-With": "XMLHttpRequest"}
|
||||||
if xsrf:
|
if xsrf:
|
||||||
@@ -321,7 +479,11 @@ async def _fetch_topics(cookies: dict) -> list:
|
|||||||
params={"tabid": "231093_-_chaohua", "page": str(page)},
|
params={"tabid": "231093_-_chaohua", "page": str(page)},
|
||||||
headers=headers, cookies=cookies,
|
headers=headers, cookies=cookies,
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
|
except Exception:
|
||||||
|
logger.warning(f"超话列表响应非 JSON: {resp.text[:200]}")
|
||||||
|
break
|
||||||
if data.get("ok") != 1:
|
if data.get("ok") != 1:
|
||||||
break
|
break
|
||||||
tlist = data.get("data", {}).get("list", [])
|
tlist = data.get("data", {}).get("list", [])
|
||||||
@@ -340,17 +502,14 @@ async def _fetch_topics(cookies: dict) -> list:
|
|||||||
if page >= data.get("data", {}).get("max_page", 1):
|
if page >= data.get("data", {}).get("max_page", 1):
|
||||||
break
|
break
|
||||||
page += 1
|
page += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取超话列表失败: {e}")
|
||||||
return topics
|
return topics
|
||||||
|
|
||||||
|
|
||||||
async def _do_single_signin(cookies: dict, topic: dict) -> dict:
|
async def _do_single_signin(client, cookies: dict, topic: dict, xsrf: str) -> dict:
|
||||||
"""签到单个超话。"""
|
"""签到单个超话(复用已有的 httpx client 和 xsrf token)。"""
|
||||||
import httpx
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
|
|
||||||
await client.get("https://weibo.com/", headers=WEIBO_HEADERS, cookies=cookies)
|
|
||||||
xsrf = client.cookies.get("XSRF-TOKEN", "")
|
|
||||||
h = {
|
h = {
|
||||||
**WEIBO_HEADERS,
|
**WEIBO_HEADERS,
|
||||||
"Referer": f"https://weibo.com/p/{topic['containerid']}/super_index",
|
"Referer": f"https://weibo.com/p/{topic['containerid']}/super_index",
|
||||||
@@ -377,7 +536,11 @@ async def _do_single_signin(cookies: dict, topic: dict) -> dict:
|
|||||||
},
|
},
|
||||||
headers=h, cookies=cookies,
|
headers=h, cookies=cookies,
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
|
except Exception:
|
||||||
|
return {"status": "failed", "message": f"非JSON响应: {resp.text[:100]}"}
|
||||||
|
|
||||||
code = str(data.get("code", ""))
|
code = str(data.get("code", ""))
|
||||||
msg = data.get("msg", "")
|
msg = data.get("msg", "")
|
||||||
|
|
||||||
@@ -446,7 +609,7 @@ async def _build_daily_report() -> str:
|
|||||||
failed_net = log_map.get("failed_network", 0)
|
failed_net = log_map.get("failed_network", 0)
|
||||||
total_logs = sum(log_map.values())
|
total_logs = sum(log_map.values())
|
||||||
|
|
||||||
# 3. 今日各账号签到明细
|
# 3. 今日各账号签到明细(含名次)
|
||||||
detail_rows = await session.execute(
|
detail_rows = await session.execute(
|
||||||
select(
|
select(
|
||||||
Account.remark,
|
Account.remark,
|
||||||
@@ -472,6 +635,28 @@ async def _build_daily_report() -> str:
|
|||||||
else:
|
else:
|
||||||
account_details[name]["failed"] += cnt
|
account_details[name]["failed"] += cnt
|
||||||
|
|
||||||
|
# 5. 今日签到名次明细(从 reward_info 提取)
|
||||||
|
rank_rows = await session.execute(
|
||||||
|
select(
|
||||||
|
Account.remark, Account.weibo_user_id,
|
||||||
|
SigninLog.topic_title, SigninLog.reward_info,
|
||||||
|
)
|
||||||
|
.join(Account, SigninLog.account_id == Account.id)
|
||||||
|
.where(SigninLog.signed_at >= today_start)
|
||||||
|
.where(SigninLog.status == "success")
|
||||||
|
.order_by(Account.remark, SigninLog.signed_at)
|
||||||
|
)
|
||||||
|
rank_details = []
|
||||||
|
for remark, uid, topic, reward in rank_rows.all():
|
||||||
|
name = remark or uid
|
||||||
|
msg = ""
|
||||||
|
if isinstance(reward, dict):
|
||||||
|
msg = reward.get("message", "")
|
||||||
|
elif isinstance(reward, str):
|
||||||
|
msg = reward
|
||||||
|
if msg:
|
||||||
|
rank_details.append({"name": name, "topic": topic, "message": msg})
|
||||||
|
|
||||||
# 4. Cookie 即将失效的账号(超过 3 天未检查)
|
# 4. Cookie 即将失效的账号(超过 3 天未检查)
|
||||||
stale_cutoff = now - timedelta(days=3)
|
stale_cutoff = now - timedelta(days=3)
|
||||||
stale_result = await session.execute(
|
stale_result = await session.execute(
|
||||||
@@ -515,6 +700,11 @@ async def _build_daily_report() -> str:
|
|||||||
for name, d in account_details.items():
|
for name, d in account_details.items():
|
||||||
lines.append(f" {name}: ✅{d['success']} 📌{d['already']} ❌{d['failed']}")
|
lines.append(f" {name}: ✅{d['success']} 📌{d['already']} ❌{d['failed']}")
|
||||||
|
|
||||||
|
if rank_details:
|
||||||
|
lines += ["", "🏆 签到名次"]
|
||||||
|
for r in rank_details:
|
||||||
|
lines.append(f" {r['name']} - {r['topic']}: {r['message']}")
|
||||||
|
|
||||||
if stale_accounts:
|
if stale_accounts:
|
||||||
lines += ["", "⚠️ 需要关注"]
|
lines += ["", "⚠️ 需要关注"]
|
||||||
for name, last in stale_accounts:
|
for name, last in stale_accounts:
|
||||||
|
|||||||
Reference in New Issue
Block a user