Files
MediaCrawler/api/routers/creator.py
T
butubb c2b310c7bf
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s
feat(creator): 新增「运营」模块 —— 多账号扫码登录与创作者后台数据
侧边栏在「监控」右边加了「运营」:账号列表 → 点进二级详情看该账号的数据。

【为什么是独立模块而不是监控的子视图】两者形状不同:监控是公开数据(点赞/收藏/评论/分享)的每轮快照+差分;运营是创作者后台按日期给出的曝光/观看/完播率/涨粉。凭据不同、采集方式也不同 —— 那边要浏览器登录态,这边是纯请求。硬塞进同一个模型会同时污染两边。

【扫码登录的关键差异】监控的扫码把登录态写进浏览器默认 profile(爬虫要复用)。运营要的是 cookie 字符串(纯请求够用),所以每次登录开一个**临时上下文**,扫完取出 cookie 就丢弃 —— 登第二个账号不会把第一个顶掉,也不影响监控那个登录态,十个账号互不干扰。

【决策依据】tools/probe_creator_api.py 的 Phase 0 实测:签名可自造(XYW_:MD5 → base64 → AES-128-CBC,与 xhshow 内置实现常量逐字节一致);主站 cookie 即可认证创作者后台;接口与参数已与真实页面对齐。

后端:
- api/creator/models.py: creator_account / creator_note_stat。**复用 MonitorBase**,这样 create_all 与上一轮改成元数据驱动的 _ensure_columns 会自动覆盖新表
- api/creator/signing.py: XYW_ 签名,带三条实测结论(url= 前缀、appId=ugc、401 与 406 的区别)
- api/creator/client.py: 纯 httpx 客户端。字段名尚未亲眼验证过,所以写成多别名匹配;解析不出来存 None 而非 0
- api/creator/service.py: 账号 CRUD 与同步。cookie 绝不进入对外结构,只给 has_cookie
- api/creator/login.py: 临时上下文的扫码登录
- api/routers/creator.py: 8 条路由,全部带鉴权

前端:
- 侧边栏「运营」+ OperationView(账号列表 → 二级详情)+ AddAccountDialog
- 权限状态显眼呈现:pending 时照抄后台原话「已为您申请数据权限,次日可查看」,并说明此时同步返回 0 条是正常的,不是采集失败

测试:tests/test_creator_client.py 新增 48 例,含「cookie 不得出现在对外结构里」这条不变量,以及权限未生效时空壳响应的处理。
2026-10-07 16:30:45 +08:00

147 lines
5.4 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 and snapshot.get("account") is None:
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}"
return snapshot
snapshot["account"] = account
snapshot["message"] = f"已添加账号:{account['nickname']}"
return snapshot
@router.delete("/login")
async def cancel_login():
return await creator_login.cancel()