【扫码不完成】根因是判据本身。原先读页面里的 window.__INITIAL_STATE__,而那是 页面加载那一刻的快照:监控那边的同一探针能用,是因为那台浏览器的页面加载时就已经 登录了;而扫码是加载之后才登录的 —— SPA 内部确实登进去了,但初始快照不会翻转, 于是检测永远等不到。 改成拿 cookie 直接问创作者后台 /api/galaxy/user/info 我是谁。实测这个判据很干净: 游客也会拿到 a1(所以签名算得出来),但接口直接回 401 无登录信息;只有真正登录了 才返回 user_id。所以「有 a1」什么都证明不了,后台认了才算。 顺带按 5 秒节流 —— 前端每 2 秒问一次,没必要每次都打后台接口。 【弹窗不关】成功后不自动关闭,停在二维码上会让人以为没成功。现在显示账号卡片与原话 提示,1.6 秒后自动关闭并提供一个「完成」按钮。 【已完成结果会残留】take_cookie 取走 cookie 就拆会话,而在飞的轮询会看到 _current 为空 回报 idle,把已显示的成功能擦掉。现在把结果记在模块里重复返回,关闭弹窗时清掉 —— 否则下次打开会立刻显示上次的成功。 【布局】按用户要求改成左右两栏(左账号列表、右数据面板),与监控统一,取消二级菜单。 未选过时默认选中第一个,右栏不会一开始就是空的。 测试:tests/test_creator_login.py 新增 7 例,含「游客会话永不完成」「接口不打满每次轮询」 「临时上下文用完必须关掉」。
151 lines
5.6 KiB
Python
151 lines
5.6 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Copyright (c) 2025 [email protected]
|
|
#
|
|
# This file is part of MediaCrawler project.
|
|
# Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/api/routers/creator.py
|
|
# GitHub: https://github.com/NanmiCoder
|
|
# Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1
|
|
#
|
|
# 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则:
|
|
# 1. 不得用于任何商业用途。
|
|
# 2. 使用时应遵守目标平台的使用条款和robots.txt规则。
|
|
# 3. 不得进行大规模爬取或对平台造成运营干扰。
|
|
# 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。
|
|
# 5. 不得用于任何非法或不当的用途。
|
|
#
|
|
# 详细许可条款请参阅项目根目录下的LICENSE文件。
|
|
# 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。
|
|
|
|
"""运营模块的 HTTP 接口。"""
|
|
|
|
import asyncio
|
|
from typing import Set
|
|
|
|
from fastapi import APIRouter, HTTPException, Query
|
|
|
|
from ..creator import login as creator_login
|
|
from ..creator import service
|
|
from ..monitor.db import get_session
|
|
|
|
router = APIRouter(prefix="/creator", tags=["creator"])
|
|
|
|
# 后台同步任务要留强引用:asyncio 只持弱引用,否则任务可能在跑完前被回收。
|
|
_sync_tasks: Set[asyncio.Task] = set()
|
|
|
|
|
|
def _bad_request(exc: ValueError) -> HTTPException:
|
|
return HTTPException(status_code=400, detail=str(exc))
|
|
|
|
|
|
@router.get("/accounts")
|
|
async def list_accounts():
|
|
"""账号列表。**不含 cookie**,只给 ``has_cookie``。"""
|
|
async with get_session() as session:
|
|
return {"accounts": await service.list_accounts(session)}
|
|
|
|
|
|
@router.get("/accounts/{account_id}")
|
|
async def get_account_detail(account_id: int):
|
|
async with get_session() as session:
|
|
try:
|
|
return await service.account_detail(session, account_id)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=404, detail=str(exc))
|
|
|
|
|
|
@router.delete("/accounts/{account_id}")
|
|
async def delete_account(account_id: int):
|
|
async with get_session() as session:
|
|
try:
|
|
await service.delete_account(session, account_id)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=404, detail=str(exc))
|
|
return {"message": "账号已删除"}
|
|
|
|
|
|
@router.post("/accounts/{account_id}/check")
|
|
async def check_account(account_id: int):
|
|
"""重测登录态与数据权限。"""
|
|
async with get_session() as session:
|
|
try:
|
|
return await service.check_account(session, account_id)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=404, detail=str(exc))
|
|
|
|
|
|
@router.post("/accounts/{account_id}/sync")
|
|
async def sync_account(
|
|
account_id: int, days: int = Query(default=90, ge=1, le=730)
|
|
):
|
|
"""拉取作品数据。
|
|
|
|
**放后台跑**:要分页、还要按账号节流,几分钟很正常,而前端请求超时是 30 秒。
|
|
前端靠轮询账号列表里的 `last_synced_at` / `last_error` 看结果。
|
|
"""
|
|
async with get_session() as session:
|
|
try:
|
|
await service.get_account(session, account_id)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=404, detail=str(exc))
|
|
|
|
task = asyncio.create_task(_sync_in_background(account_id, days))
|
|
_sync_tasks.add(task)
|
|
task.add_done_callback(_sync_tasks.discard)
|
|
return {"message": "同步已开始", "days": days}
|
|
|
|
|
|
async def _sync_in_background(account_id: int, days: int) -> None:
|
|
try:
|
|
async with get_session() as session:
|
|
result = await service.sync_account(session, account_id, days)
|
|
print(f"[creator] 账号 {account_id} 同步完成,取回 {result['fetched']} 条")
|
|
except Exception as exc: # noqa: BLE001 - 后台任务不能让异常逃逸成静默失败
|
|
print(f"[creator] 账号 {account_id} 同步失败: {exc}")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 扫码新增账号
|
|
# ---------------------------------------------------------------------------
|
|
#
|
|
# 每次登录开一个**临时浏览器上下文**,扫完取出 cookie 就丢弃 —— 这样登第二个账号
|
|
# 不会把第一个顶掉,也不影响监控那个登录态。cookie 只在内存里从 login 模块传到
|
|
# 这里落库,**不进响应体**。
|
|
|
|
|
|
@router.post("/login")
|
|
async def start_login():
|
|
try:
|
|
return await creator_login.start()
|
|
except RuntimeError as exc:
|
|
raise HTTPException(status_code=502, detail=str(exc))
|
|
|
|
|
|
@router.get("/login")
|
|
async def poll_login():
|
|
"""轮询扫码结果;一旦成功就把账号落库并返回它。"""
|
|
snapshot = await creator_login.status()
|
|
|
|
if snapshot["status"] == creator_login.STATUS_SUCCESS:
|
|
# take_cookie 只在会话还在时返回 cookie,取走即拆会话;重复轮询拿到 None
|
|
# 就说明已经保存过了,直接返回上次的结果,不要退回 idle。
|
|
cookie = await creator_login.take_cookie()
|
|
if cookie:
|
|
try:
|
|
async with get_session() as session:
|
|
account = await service.upsert_account_from_cookie(session, cookie)
|
|
except ValueError as exc:
|
|
snapshot["status"] = creator_login.STATUS_ERROR
|
|
snapshot["message"] = f"扫码成功但保存账号失败:{exc}"
|
|
await creator_login.remember_result(snapshot)
|
|
return snapshot
|
|
snapshot["account"] = account
|
|
snapshot["message"] = f"已添加账号:{account['nickname']}"
|
|
await creator_login.remember_result(snapshot)
|
|
|
|
return snapshot
|
|
|
|
|
|
@router.delete("/login")
|
|
async def cancel_login():
|
|
return await creator_login.cancel()
|