侧边栏在「监控」右边加了「运营」:账号列表 → 点进二级详情看该账号的数据。 【为什么是独立模块而不是监控的子视图】两者形状不同:监控是公开数据(点赞/收藏/评论/分享)的每轮快照+差分;运营是创作者后台按日期给出的曝光/观看/完播率/涨粉。凭据不同、采集方式也不同 —— 那边要浏览器登录态,这边是纯请求。硬塞进同一个模型会同时污染两边。 【扫码登录的关键差异】监控的扫码把登录态写进浏览器默认 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 不得出现在对外结构里」这条不变量,以及权限未生效时空壳响应的处理。
130 lines
6.2 KiB
Python
130 lines
6.2 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/creator/models.py
|
|
# GitHub: https://github.com/NanmiCoder
|
|
# Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1
|
|
#
|
|
# 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则:
|
|
# 1. 不得用于任何商业用途。
|
|
# 2. 使用时应遵守目标平台的使用条款和robots.txt规则。
|
|
# 3. 不得进行大规模爬取或对平台造成运营干扰。
|
|
# 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。
|
|
# 5. 不得用于任何非法或不当的用途。
|
|
#
|
|
# 详细许可条款请参阅项目根目录下的LICENSE文件。
|
|
# 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。
|
|
|
|
"""运营模块的数据模型。
|
|
|
|
**刻意复用 `MonitorBase`**:这样 `init_db` 的 `create_all` 会顺手建出新表,而
|
|
`_ensure_columns`(已改为按模型元数据推导)也会自动给新表补字段 —— 不必再维护一份
|
|
建表语句。表落在同一个库里,与监控互不干扰。
|
|
"""
|
|
|
|
from typing import Optional
|
|
|
|
from sqlalchemy import BigInteger, Float, ForeignKey, Index, Integer, String, Text
|
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
|
|
from ..monitor.models import MonitorBase
|
|
|
|
# 创作者后台的数据权限是「首次访问时自动申请、次日生效」。这个状态必须如实呈现:
|
|
# 显示成"没数据"会让人以为采集坏了,实际是在等审批。
|
|
PERMISSION_UNKNOWN = "unknown"
|
|
PERMISSION_PENDING = "pending" # 已申请,未生效(提示语:"次日可查看")
|
|
PERMISSION_ACTIVE = "active"
|
|
PERMISSION_MISSING = "missing" # 接口明确说没有权限
|
|
|
|
# 账号自身的可用性。
|
|
ACCOUNT_OK = "ok"
|
|
ACCOUNT_EXPIRED = "expired" # cookie 失效,需要重新扫码
|
|
ACCOUNT_ERROR = "error"
|
|
|
|
|
|
class CreatorAccount(MonitorBase):
|
|
"""一个自己的小红书账号。
|
|
|
|
纯请求路线下,**一个账号的全部身份就是一份 cookie** —— 没有浏览器 profile、
|
|
没有独立目录。所以"多账号"在这里只是表里的多行,不是多套运行环境。
|
|
|
|
`cookie` 是凭证:与监控的 cookie 同样对待,只存库、绝不回显接口。
|
|
"""
|
|
|
|
__tablename__ = "creator_account"
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
# 展示名。优先用后台返回的昵称,用户可以改。
|
|
nickname: Mapped[str] = mapped_column(String(128), nullable=False, default="")
|
|
# 创作者后台的账号标识,由 /api/galaxy/user/info 返回,用于去重。
|
|
user_id: Mapped[str] = mapped_column(String(64), nullable=False, default="", index=True)
|
|
red_id: Mapped[str] = mapped_column(String(64), nullable=False, default="")
|
|
avatar: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
|
|
|
cookie: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
|
|
|
status: Mapped[str] = mapped_column(String(16), nullable=False, default=ACCOUNT_OK)
|
|
permission_status: Mapped[str] = mapped_column(
|
|
String(16), nullable=False, default=PERMISSION_UNKNOWN
|
|
)
|
|
# 后台原话,例如"已为您申请数据权限,次日可查看"。照抄,不改写。
|
|
permission_tip: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
|
|
|
last_checked_at: Mapped[Optional[int]] = mapped_column(BigInteger)
|
|
last_synced_at: Mapped[Optional[int]] = mapped_column(BigInteger)
|
|
last_error: Mapped[Optional[str]] = mapped_column(Text)
|
|
|
|
created_at: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
|
updated_at: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
|
|
|
notes: Mapped[list["CreatorNoteStat"]] = relationship(
|
|
back_populates="account", cascade="all, delete-orphan"
|
|
)
|
|
|
|
|
|
class CreatorNoteStat(MonitorBase):
|
|
"""一篇作品在某个采集时点的运营数据。
|
|
|
|
创作者后台给的是**累计值**(截至查询时点),所以反复采集天然形成时间序列 ——
|
|
与监控的"快照 + 差分"是同一个思路,因此这里保留 `captured_at` 而不是覆盖写。
|
|
"""
|
|
|
|
__tablename__ = "creator_note_stat"
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
account_id: Mapped[int] = mapped_column(
|
|
ForeignKey("creator_account.id", ondelete="CASCADE"), nullable=False, index=True
|
|
)
|
|
note_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
|
title: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
|
# 发布时间(毫秒)。后台按发布时间筛选,这是它的主时间轴。
|
|
publish_time: Mapped[Optional[int]] = mapped_column(BigInteger)
|
|
|
|
# --- 运营指标 ---------------------------------------------------------
|
|
# 计数用 BigInteger:曝光量可以很大,用 INT 迟早溢出。
|
|
exposure: Mapped[Optional[int]] = mapped_column(BigInteger)
|
|
views: Mapped[Optional[int]] = mapped_column(BigInteger)
|
|
likes: Mapped[Optional[int]] = mapped_column(BigInteger)
|
|
comments: Mapped[Optional[int]] = mapped_column(BigInteger)
|
|
favorites: Mapped[Optional[int]] = mapped_column(BigInteger)
|
|
shares: Mapped[Optional[int]] = mapped_column(BigInteger)
|
|
new_followers: Mapped[Optional[int]] = mapped_column(BigInteger)
|
|
danmaku: Mapped[Optional[int]] = mapped_column(BigInteger)
|
|
|
|
# 比率与时长。后台返回的可能是 "12.3%"/"1分30秒" 这类字符串,解析不了的存 NULL
|
|
# 而不是 0 —— 与监控层的口径一致:0 是真实值,NULL 是"不知道"。
|
|
cover_ctr: Mapped[Optional[float]] = mapped_column(Float)
|
|
avg_watch_seconds: Mapped[Optional[float]] = mapped_column(Float)
|
|
two_second_exit_rate: Mapped[Optional[float]] = mapped_column(Float)
|
|
completion_rate: Mapped[Optional[float]] = mapped_column(Float)
|
|
|
|
captured_at: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
|
|
|
|
account: Mapped["CreatorAccount"] = relationship(back_populates="notes")
|
|
|
|
__table_args__ = (
|
|
# 同一个时点同一篇只留一行,重复同步不会堆积。
|
|
Index("ix_creator_note_stat_unique", "account_id", "note_id", "captured_at", unique=True),
|
|
)
|