Files
video/server/app/services/aidrama91_service.py
T
2026-10-25 16:14:26 +08:00

863 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""91 短剧(AI短剧)采集:加密接口客户端 + 系列/集数入库
上游协议:pwa.php 单入口,AES-256-CFB(segment 128) 加密请求体 + MD5(SHA256) 签名。
时间戳签名有效期约 4 小时,因此**不存原始 m3u8 地址**,改为存本站代理地址,
播放时再向上游换取新鲜签名(见 /api/v1/m3u8/aidrama)。
"""
import asyncio
import hashlib
import json
import logging
import random
import string
import time
from datetime import datetime
import httpx
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.video import (
Category,
CollectionRecord,
CollectionSource,
Series,
Tag,
Video,
VideoTag,
)
logger = logging.getLogger(__name__)
KIND = "aidrama91"
VLOG_KIND = "aidrama91_vlog" # 91 非系列短视频
SOURCE_KEY = "91aidrama" # collection_records.source_key
VLOG_SOURCE_KEY = "91vlog" # 短视频采集记录 key
SOURCE_CODE = "91" # sources.code
SELF_API_BASE = "https://vapi.256kb.top"
AUTHOR_USER_ID = 1
VLOG_CATEGORY_NAME = "91短视频" # vlog 无真实分类,归到本地分类
# 协议常量(前端 JS 逆向)
AES_KEY = b"cc88ddc9357ff461e08f047aedee692b"
AES_IV = b"e89225cfbbimgkcu"
SIGN_KEY = "cc88ddc9357ff461e08f047aedee692b"
APP_VERSION = "7.5.0"
CLIENT_ORIGIN = "https://pwa4.jl8v0p5t.cc"
# 封面图解密:CDN 返回的是 AES-128-CBC 加密的图片字节(无 padding),
# 源站前端 DecryptImage 解密后展示 —— 本站通过 /api/v1/img/aidrama 代理解密
IMG_AES_KEY = b"f5d965df75336270"
IMG_AES_IV = b"97b60394abc2fbe1"
IMG_HOST_SUFFIX = ".drleao.cn" # 封面 CDN 域名白名单(防 SSRF)
HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Linux; Android 10; SM-G973F) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36"
),
"Origin": CLIENT_ORIGIN,
"Referer": f"{CLIENT_ORIGIN}/",
"Accept": "application/json, text/plain, */*",
"Content-Type": "application/x-www-form-urlencoded",
}
OAUTH_CHARSET = string.ascii_letters + string.digits
MAX_PAGE_LIMIT = 20 # 上游单页上限(传 100 实际只返回 20)
# ---------- 加密 / 签名 ----------
def _cfb(key: bytes, iv: bytes):
try:
from cryptography.hazmat.decrepit.ciphers.modes import CFB
except ImportError: # cryptography < 43
from cryptography.hazmat.primitives.ciphers.modes import CFB
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms
return Cipher(algorithms.AES(key), CFB(iv))
def _encrypt(plaintext: str) -> str:
op = _cfb(AES_KEY, AES_IV).encryptor()
return (op.update(plaintext.encode()) + op.finalize()).hex().upper()
def _decrypt(hex_data: str) -> str:
op = _cfb(AES_KEY, AES_IV).decryptor()
return (op.update(bytes.fromhex(hex_data)) + op.finalize()).decode("utf-8", "replace")
def _new_oauth_id() -> str:
return "".join(random.choices(OAUTH_CHARSET, k=16)) + str(int(time.time() * 1000))
async def _post(client: httpx.AsyncClient, api_url: str, payload: dict) -> dict:
"""加密并发送,返回解密后的业务数据(已剥掉外层包装)"""
ts_ms = int(time.time() * 1000)
payload = dict(payload, timestamp=ts_ms)
data_hex = _encrypt(json.dumps(payload, separators=(",", ":")))
ts_sec = ts_ms // 1000
raw = f"client=pwa&data={data_hex}&timestamp={ts_sec}{SIGN_KEY}"
sign = hashlib.md5(hashlib.sha256(raw.encode()).hexdigest().encode()).hexdigest()
resp = await client.post(
api_url,
content=f"client=pwa&timestamp={ts_sec}&data={data_hex}&sign={sign}",
headers=HEADERS,
timeout=30,
)
if resp.status_code != 200:
raise RuntimeError(f"上游 HTTP {resp.status_code}(oauth_id 可能未注册或已被限流)")
body = resp.json()
if body.get("errcode") not in (0, None):
raise RuntimeError(f"上游错误: {str(body)[:200]}")
payload_text = body.get("data")
if not payload_text:
return body
return json.loads(_decrypt(payload_text))
def _base(oauth_id: str, token: str) -> dict:
return {
"oauth_type": "pwa", "oauth_id": oauth_id, "trace_id": "",
"aff_x_code": "", "version": APP_VERSION, "token": token,
}
# ---------- 会话 ----------
async def create_session(
client: httpx.AsyncClient, api_url: str, account: str, password: str
) -> tuple[str, str]:
"""注册 oauth_id 并登录,返回 (oauth_id, token)"""
oauth_id = _new_oauth_id()
# 上游对未注册的 oauth_id 直接 503,必须先 system/index
await _post(client, api_url, dict(_base(oauth_id, ""), mod="system", code="index", data={}))
result = await _post(client, api_url, dict(
_base(oauth_id, ""), mod="user", code="regOrLogin",
account=account, password=password, type="login",
))
inner = result.get("data", result) if isinstance(result, dict) else {}
token = inner.get("token") or ""
if not token:
raise RuntimeError(f"91 登录失败: {inner.get('msg') or str(result)[:150]}")
return oauth_id, token
# 进程内 token 缓存(2 个 uvicorn worker → 最多 2 次登录);失效时自动重登
_TOKENS: dict[int, tuple[str, str, float]] = {}
TOKEN_TTL = 3600
async def open_session(
client: httpx.AsyncClient, source: CollectionSource, force: bool = False
) -> tuple[str, str]:
"""获取可用的 (oauth_id, token):进程缓存 → DB 配置 → 重新登录"""
key = int(source.id or 0)
cfg = load_config(source)
if not force:
hit = _TOKENS.get(key)
if hit and hit[2] > time.time():
return hit[0], hit[1]
if cfg.get("oauth_id") and cfg.get("token"):
_TOKENS[key] = (cfg["oauth_id"], cfg["token"], time.time() + TOKEN_TTL)
return cfg["oauth_id"], cfg["token"]
oauth_id, token = await create_session(
client, source.list_url, cfg["account"], cfg["password"])
_TOKENS[key] = (oauth_id, token, time.time() + TOKEN_TTL)
return oauth_id, token
def load_config(source: CollectionSource) -> dict:
try:
cfg = json.loads(source.config or "{}")
except json.JSONDecodeError:
cfg = {}
cfg.setdefault("account", "")
cfg.setdefault("password", "")
cfg.setdefault("category_ids", [4])
cfg.setdefault("page_size", MAX_PAGE_LIMIT)
return cfg
# ---------- 业务接口 ----------
async def fetch_list(
client: httpx.AsyncClient, api_url: str, oauth_id: str, token: str,
page: int, limit: int, category_ids: list[int],
) -> dict:
"""短剧列表;返回 {list, total, has_more}"""
data = await _post(client, api_url, dict(
_base(oauth_id, token), mod="aidrama", code="home",
page=page, limit=min(limit, MAX_PAGE_LIMIT), pay_type="all", sort="all",
category_ids=category_ids or [4],
))
inner = data.get("data") or {}
return {
"list": inner.get("list") or [],
"total": int(inner.get("total") or 0),
"has_more": bool(inner.get("has_more")),
}
async def fetch_detail(
client: httpx.AsyncClient, api_url: str, oauth_id: str, token: str, drama_id: int
) -> dict:
data = await _post(client, api_url, dict(
_base(oauth_id, token), mod="aidrama", code="detail", drama_id=drama_id,
))
return (data.get("data") or {}).get("row") or {}
async def fetch_episodes(
client: httpx.AsyncClient, api_url: str, oauth_id: str, token: str,
drama_id: int, page: int = 1, limit: int = MAX_PAGE_LIMIT,
) -> dict:
"""集数列表;返回 {list, has_more}"""
data = await _post(client, api_url, dict(
_base(oauth_id, token), mod="aidrama", code="episodes",
drama_id=drama_id, page=page, limit=min(limit, MAX_PAGE_LIMIT),
))
inner = data.get("data") or {}
return {"list": inner.get("list") or [], "has_more": bool(inner.get("has_more"))}
async def fetch_all_episodes(
client: httpx.AsyncClient, api_url: str, oauth_id: str, token: str, drama_id: int
) -> list[dict]:
"""翻页取全部集数(每页 20 上限)"""
out: list[dict] = []
page = 1
while True:
part = await fetch_episodes(client, api_url, oauth_id, token, drama_id, page=page)
out.extend(part["list"])
if not part["has_more"] or not part["list"]:
break
page += 1
return out
# ---------- vlog 短视频接口 ----------
VLOG_MAX_LIMIT = 50 # 实测 vlog 单页上限 50(传 100 只返回 50)
async def fetch_vlog_list(
client: httpx.AsyncClient, api_url: str, oauth_id: str, token: str,
page: int, limit: int = VLOG_MAX_LIMIT, vlog_type: str = "new",
) -> dict:
"""vlog 列表(非系列短视频);返回 {list, has_more}"""
data = await _post(client, api_url, dict(
_base(oauth_id, token), mod="choice", code="vlog",
type=vlog_type, page=page, limit=min(limit, VLOG_MAX_LIMIT),
))
lst = data.get("data") or []
if not isinstance(lst, list):
lst = []
return {"list": lst, "has_more": len(lst) >= min(limit, VLOG_MAX_LIMIT)}
async def fetch_vlog_detail(
client: httpx.AsyncClient, api_url: str, oauth_id: str, token: str, vid: int
) -> dict:
"""vlog 详情(含 playUrl / tags)"""
data = await _post(client, api_url, dict(
_base(oauth_id, token), mod="index", code="detail", id=vid))
return data.get("data") or {}
# ---------- 字段解析 ----------
def _pick_url(raw: str | dict, prefer: str = "720") -> str:
"""封面字段是 JSON 字符串或 dict:{"ori","360","720"},取指定档位"""
if not raw:
return ""
try:
obj = json.loads(raw) if isinstance(raw, str) else raw
except json.JSONDecodeError:
return raw if isinstance(raw, str) else ""
if not isinstance(obj, dict):
return ""
return obj.get(prefer) or obj.get("ori") or ""
def proxied_cover(url: str) -> str:
"""封面地址改写为本站解密代理(CDN 图是加密的,直连无法显示)"""
if not url or "/api/v1/img/aidrama" in url:
return url
if not url.startswith(("http://", "https://")):
return url
from urllib.parse import quote
return f"{SELF_API_BASE}/api/v1/img/aidrama?u={quote(url, safe='')}"
def decrypt_image(data: bytes) -> bytes:
"""AES-128-CBC(NoPadding) 解密 CDN 加密图片,返回原始 JPEG/PNG 字节"""
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
op = Cipher(algorithms.AES(IMG_AES_KEY), modes.CBC(IMG_AES_IV)).decryptor()
return op.update(data) + op.finalize()
def _play_url(drama_id: int, episode_id: int, episode_no: int | None) -> str:
"""本站代理地址(含 m3u8 字样,App 端会走本地缓存代理)"""
url = f"{SELF_API_BASE}/api/v1/m3u8/aidrama?d={drama_id}&e={episode_id}"
if episode_no:
url += f"&n={episode_no}"
return url
def _safe_dt(ts) -> datetime | None:
try:
n = int(ts)
except (TypeError, ValueError):
return None
return datetime.fromtimestamp(n) if n > 0 else None
def _parse_count(v) -> int:
"""解析播放/点赞等计数,兼容 '143.7万' / '1.2万' / '34' / 34"""
if v is None:
return 0
if isinstance(v, (int, float)):
return int(v)
s = str(v).strip()
if not s:
return 0
if s.endswith("万"):
try:
return int(float(s[:-1]) * 10000)
except ValueError:
return 0
try:
return int(s)
except ValueError:
return 0
def _vlog_play_url(vid: int) -> str:
"""vlog 单视频播放代理地址(含 m3u8 字样,App 走本地缓存)"""
return f"{SELF_API_BASE}/api/v1/m3u8/vlog?vid={vid}"
# ---------- 入库 ----------
async def _upsert_tags(db: AsyncSession, names: list[str]) -> list[int]:
"""按名称取/建标签,返回 tag id 列表"""
ids: list[int] = []
for name in {n.strip() for n in names if n and n.strip()}:
name = name[:20]
tid = (await db.execute(select(Tag.id).where(Tag.name == name))).scalar_one_or_none()
if not tid:
t = Tag(name=name, video_count=0)
db.add(t)
await db.flush()
tid = t.id
ids.append(tid)
return ids
async def import_drama(
db: AsyncSession, client: httpx.AsyncClient, source: CollectionSource,
drama: dict, oauth_id: str, token: str,
api_url: str | None = None, category_id: int | None = None, source_id: int | None = None,
) -> tuple[int, int]:
"""导入单部短剧(系列 + 集数视频),返回 (新增集数, 跳过集数)"""
drama_id = int(drama.get("id") or 0)
if not drama_id:
return 0, 0
api_url = api_url or source.list_url
cat = category_id if category_id is not None else source.category_id
sid = source_id if source_id is not None else source.source_id
cfg = load_config(source)
detail = drama
try:
full = await fetch_detail(client, api_url, oauth_id, token, drama_id)
if full:
detail = {**drama, **full}
except Exception:
logger.warning("短剧 %s 详情获取失败,使用列表字段", drama_id)
record = (await db.execute(
select(CollectionRecord).where(
CollectionRecord.source_key == SOURCE_KEY,
CollectionRecord.source_id == drama_id,
)
)).scalar_one_or_none()
cover = proxied_cover(_pick_url(detail.get("cover") or ""))
tags = list(detail.get("tag_list") or []) + list(detail.get("category_list") or [])
# 像既有采集一样:按标题拆分关键词作为 tag
from app.services.title_tags import extract_keywords
tags += extract_keywords(detail.get("title") or "")
total_declared = int(detail.get("total_episodes") or 0)
series = await db.get(Series, record.series_id) if record else None
if series is None:
series = Series(
title=(detail.get("title") or f"短剧{drama_id}")[:100],
cover_url=cover[:500],
description=(detail.get("description") or "")[:500],
author_id=AUTHOR_USER_ID,
category_id=cat,
source_id=sid,
total_episodes=total_declared,
status=1,
)
db.add(series)
await db.flush()
if record:
record.series_id = series.id
else:
db.add(CollectionRecord(
source_key=SOURCE_KEY, source_id=drama_id, series_id=series.id))
await db.flush()
else:
# 已有系列:同步标题/封面/简介(源站可能改过)
series.title = (detail.get("title") or series.title)[:100]
series.cover_url = (cover or series.cover_url)[:500]
series.total_episodes = max(series.total_episodes, total_declared)
existing = set((await db.execute(
select(Video.episode_no).where(
Video.series_id == series.id, Video.episode_no.is_not(None))
)).scalars().all())
episodes = await fetch_all_episodes(client, api_url, oauth_id, token, drama_id)
tag_ids: list[int] | None = None
created = skipped = 0
now = datetime.now()
for ep in episodes:
ep_id = int(ep.get("id") or 0)
ep_no = int(ep.get("episode_number") or 0)
if not ep_id or not ep_no or ep_no in existing:
skipped += 1
continue
ep_cover = proxied_cover(_pick_url(ep.get("cover") or "")) or cover
video = Video(
user_id=AUTHOR_USER_ID,
category_id=cat,
source_id=sid,
series_id=series.id,
episode_no=ep_no,
title=(ep.get("title") or f"第{ep_no}集")[:100],
description=(detail.get("description") or "")[:500],
cover_url=ep_cover[:500],
video_url=_play_url(drama_id, ep_id, ep_no),
duration=int(ep.get("duration") or 0),
status=2,
like_count=int(ep.get("like") or 0),
favorite_count=int(ep.get("favorite_num") or 0),
comment_count=int(ep.get("comment_count") or 0),
published_at=_safe_dt(ep.get("created_at")) or now,
)
db.add(video)
await db.flush()
# 标签挂到每集视频上(系列标签来自 tag_list / category_list)
if tags:
if tag_ids is None:
tag_ids = await _upsert_tags(db, tags)
for tid in tag_ids:
db.add(VideoTag(video_id=video.id, tag_id=tid))
existing.add(ep_no)
created += 1
series.total_episodes = max(len(existing), total_declared)
return created, skipped
async def sync_source(
db: AsyncSession, source: CollectionSource, max_pages: int = 0, delay: float = 0.3,
should_cancel=None, on_progress=None,
) -> dict:
"""按列表翻页增量采集(已导入的短剧跳过,仍会补同步新集数)
should_cancel: 可选回调,返回 True 时在每页/每条边界退出循环(用于手动取消)
on_progress: 可选回调,每页拉到列表后调用 on_progress(dict(stat))(用于后台展示进度)
"""
cfg = load_config(source)
# 每条/每页之间的间隔可由采集源 config 的 delay 自定义(秒),未配置时用默认值
if cfg.get("delay") is not None:
delay = float(cfg["delay"])
stat: dict = {
"kind": KIND, "dramas_new": 0, "dramas_exist": 0,
"episodes_created": 0, "episodes_skipped": 0, "pages": 0, "total": 0,
}
page_size = min(int(cfg.get("page_size") or MAX_PAGE_LIMIT), MAX_PAGE_LIMIT)
category_ids = cfg.get("category_ids") or [4]
def _cancelled() -> bool:
return bool(should_cancel and should_cancel())
for attempt in (1, 2):
try:
async with httpx.AsyncClient(follow_redirects=True) as client:
oauth_id, token = await open_session(client, source, force=attempt == 2)
api_url = source.list_url
cat = source.category_id
sid = source.source_id
page = 1
while True:
if _cancelled():
logger.info("91 短剧同步被取消(第 %s 页前)", page)
return stat
data = await fetch_list(
client, api_url, oauth_id, token, page, page_size, category_ids)
stat["pages"] = page
stat["total"] = data["total"]
items = data["list"]
logger.info("91 短剧第 %s 页:%s 部(共 %s)", page, len(items), data["total"])
if on_progress:
on_progress(dict(stat))
for drama in items:
if _cancelled():
logger.info("91 短剧同步被取消(处理 drama 中)")
return stat
drama_id = int(drama.get("id") or 0)
if not drama_id:
continue
try:
created, skipped = await import_drama(
db, client, source, drama, oauth_id, token,
api_url=api_url, category_id=cat, source_id=sid)
await db.commit()
if created > 0:
stat["dramas_new"] += 1
else:
stat["dramas_exist"] += 1
stat["episodes_created"] += created
stat["episodes_skipped"] += skipped
except Exception:
await db.rollback()
logger.exception("导入 91 短剧 %s 失败,跳过", drama_id)
await asyncio.sleep(delay)
if not data["has_more"] or not items:
break
if max_pages and page >= max_pages:
break
page += 1
await asyncio.sleep(delay)
cfg["oauth_id"], cfg["token"] = oauth_id, token
source.config = json.dumps(cfg, ensure_ascii=False)
break
except Exception:
_TOKENS.pop(int(source.id or 0), None)
if attempt == 2:
source.last_sync_at = datetime.now()
await db.commit()
raise
logger.warning("91 同步失败,重新登录后重试一次")
await asyncio.sleep(1)
source.last_sync_at = datetime.now()
await db.commit()
return stat
async def refresh_source_id(db: AsyncSession, source: CollectionSource) -> int:
"""确保采集源绑定的 sources 记录存在,返回来源 id"""
if source.source_id:
return source.source_id
from app.models.video import Source
sid = (await db.execute(
select(Source.id).where(Source.code == SOURCE_CODE)
)).scalar_one_or_none()
if not sid:
max_sort = 0
s = Source(name="91", code=SOURCE_CODE, sort=max_sort + 1, status=1)
db.add(s)
await db.flush()
sid = s.id
source.source_id = sid
return sid
async def ensure_local_category(db: AsyncSession, name: str) -> int:
"""按采集源名称找/建本站分类"""
cid = (await db.execute(
select(Category.id).where(Category.name == name)
)).scalar_one_or_none()
if cid:
return cid
c = Category(name=name, sort=99, status=1)
db.add(c)
await db.flush()
return c.id
async def fetch_playlist(source: CollectionSource, drama_id: int, episode_id: int,
episode_no: int | None = None) -> str:
"""换取某集的最新 m3u8 文本(签名有时效,每次播放现取;token 失效自动重登)"""
for attempt in (1, 2):
try:
return await _fetch_playlist_once(
source, drama_id, episode_id, episode_no, force=attempt == 2)
except Exception:
_TOKENS.pop(int(source.id or 0), None)
if attempt == 2:
raise
logger.warning("91 播放列表获取失败,重新登录后重试一次")
await asyncio.sleep(0.5)
raise RuntimeError("播放列表获取失败")
async def _fetch_playlist_once(
source: CollectionSource, drama_id: int, episode_id: int,
episode_no: int | None, force: bool,
) -> str:
async with httpx.AsyncClient(follow_redirects=True) as client:
oauth_id, token = await open_session(client, source, force=force)
target: dict | None = None
# 有集号就直接算所在页;否则从第 1 页起扫(最多 5 页兜底)
pages: list[int] = []
if episode_no and episode_no > 0:
pages.append((episode_no - 1) // MAX_PAGE_LIMIT + 1)
pages.extend(p for p in (1, 2, 3, 4, 5) if p not in pages)
for page in pages:
part = await fetch_episodes(
client, source.list_url, oauth_id, token, drama_id, page=page)
for ep in part["list"]:
if int(ep.get("id") or 0) == episode_id:
target = ep
break
if target or not part["has_more"]:
break
if not target:
raise RuntimeError("源站未找到该集")
m3u8 = target.get("full_m3u8") or ""
if not m3u8:
raise RuntimeError("该集暂无可播放地址(可能需付费或未解锁)")
resp = await client.get(
m3u8, headers={"User-Agent": HEADERS["User-Agent"]}, timeout=30)
resp.raise_for_status()
if not resp.text.lstrip().startswith("#EXTM3U"):
raise RuntimeError("源站播放列表异常")
return resp.text
async def get_collection_source(db: AsyncSession, source_id: int | None = None) -> CollectionSource:
"""取 91 采集源(播放代理用)"""
stmt = select(CollectionSource).where(CollectionSource.kind == KIND, CollectionSource.status == 1)
if source_id:
stmt = stmt.where(CollectionSource.id == source_id)
row = (await db.execute(stmt.order_by(CollectionSource.id))).scalars().first()
if not row:
raise RuntimeError("未配置 91 采集源")
return row
async def get_vlog_source(db: AsyncSession, source_id: int | None = None) -> CollectionSource:
"""取 91 短视频采集源(播放代理用)"""
stmt = select(CollectionSource).where(
CollectionSource.kind == VLOG_KIND, CollectionSource.status == 1)
if source_id:
stmt = stmt.where(CollectionSource.id == source_id)
row = (await db.execute(stmt.order_by(CollectionSource.id))).scalars().first()
if not row:
raise RuntimeError("未配置 91 短视频采集源")
return row
# ---------- vlog 入库 ----------
async def import_vlog_video(
db: AsyncSession, client: httpx.AsyncClient, source: CollectionSource,
item: dict, oauth_id: str, token: str,
api_url: str | None = None, category_id: int | None = None, source_id: int | None = None,
) -> bool:
"""导入单条 vlog 短视频,返回是否新建"""
vid = int(item.get("id") or 0)
if not vid:
return False
api_url = api_url or source.list_url
cat = category_id if category_id is not None else source.category_id
sid = source_id if source_id is not None else source.source_id
record = (await db.execute(select(CollectionRecord).where(
CollectionRecord.source_key == VLOG_SOURCE_KEY,
CollectionRecord.source_id == vid,
))).scalar_one_or_none()
if record:
v = await db.get(Video, record.video_id) if record.video_id else None
if v:
return False
# 详情取 playUrl + tags
detail = item
try:
full = await fetch_vlog_detail(client, api_url, oauth_id, token, vid)
if full:
detail = {**item, **full}
except Exception:
logger.warning("vlog %s 详情获取失败,使用列表字段", vid)
cover = proxied_cover(_pick_url(detail.get("thumbImg") or detail.get("cover_thumb") or ""))
title = (detail.get("title") or f"短视频{vid}")[:100]
tags = list(detail.get("tags") or [])
from app.services.title_tags import extract_keywords
tags += extract_keywords(title)
now = datetime.now()
video = Video(
user_id=AUTHOR_USER_ID,
category_id=cat,
source_id=sid,
title=title,
description=(str(detail.get("descriptions") or "")[:500]),
cover_url=cover[:500],
video_url=_vlog_play_url(vid),
duration=int(detail.get("duration") or 0),
status=2,
like_count=_parse_count(detail.get("like")),
favorite_count=0,
comment_count=_parse_count(detail.get("comment")),
view_count=_parse_count(detail.get("play_count")),
published_at=now,
)
db.add(video)
await db.flush()
if tags:
tag_ids = await _upsert_tags(db, tags)
for tid in tag_ids:
db.add(VideoTag(video_id=video.id, tag_id=tid))
if record:
record.video_id = video.id
else:
db.add(CollectionRecord(source_key=VLOG_SOURCE_KEY, source_id=vid, video_id=video.id))
return True
async def sync_vlog_source(
db: AsyncSession, source: CollectionSource, max_pages: int = 0, delay: float = 0.3,
should_cancel=None, on_progress=None,
) -> dict:
"""vlog 短视频翻页增量采集。should_cancel 返回 True 时每页/每条边界退出;
on_progress 在每页拉到列表后回调 dict(stat)(用于后台展示进度)"""
cfg = load_config(source)
# 每条/每页之间的间隔可由采集源 config 的 delay 自定义(秒),未配置时用默认值
if cfg.get("delay") is not None:
delay = float(cfg["delay"])
stat: dict = {
"kind": VLOG_KIND, "videos_new": 0, "videos_exist": 0,
"pages": 0,
}
page_size = min(int(cfg.get("page_size") or VLOG_MAX_LIMIT), VLOG_MAX_LIMIT)
def _cancelled() -> bool:
return bool(should_cancel and should_cancel())
for attempt in (1, 2):
try:
async with httpx.AsyncClient(follow_redirects=True) as client:
oauth_id, token = await open_session(client, source, force=attempt == 2)
# 缓存源基本字段,避免 rollback 后 ORM 对象过期导致属性访问失败
api_url = source.list_url
category_id = source.category_id
source_id = source.source_id
page = 1
while True:
if _cancelled():
logger.info("91 vlog 同步被取消(第 %s 页前)", page)
return stat
data = await fetch_vlog_list(
client, api_url, oauth_id, token, page, page_size)
stat["pages"] = page
items = data["list"]
logger.info("91 vlog 第 %s 页:%s 条", page, len(items))
if on_progress:
on_progress(dict(stat))
for item in items:
if _cancelled():
logger.info("91 vlog 同步被取消(处理 item 中)")
return stat
vid = int(item.get("id") or 0)
if not vid:
continue
try:
created = await import_vlog_video(
db, client, source, item, oauth_id, token,
api_url=api_url, category_id=category_id, source_id=source_id)
await db.commit()
if created:
stat["videos_new"] += 1
else:
stat["videos_exist"] += 1
except Exception:
await db.rollback()
logger.exception("导入 91 vlog %s 失败,跳过", vid)
await asyncio.sleep(delay)
if not data["has_more"] or not items:
break
if max_pages and page >= max_pages:
break
page += 1
await asyncio.sleep(delay)
cfg["oauth_id"], cfg["token"] = oauth_id, token
source.config = json.dumps(cfg, ensure_ascii=False)
break
except Exception:
_TOKENS.pop(int(source.id or 0), None)
if attempt == 2:
source.last_sync_at = datetime.now()
await db.commit()
raise
logger.warning("91 vlog 同步失败,重新登录后重试一次")
await asyncio.sleep(1)
source.last_sync_at = datetime.now()
await db.commit()
return stat
async def fetch_vlog_playlist(source: CollectionSource, vid: int) -> str:
"""换取 vlog 播放列表(playUrl 带时效签名,播放时现取)"""
for attempt in (1, 2):
try:
return await _fetch_vlog_playlist_once(source, vid, force=attempt == 2)
except Exception:
_TOKENS.pop(int(source.id or 0), None)
if attempt == 2:
raise
logger.warning("91 vlog 播放列表获取失败,重新登录后重试一次")
await asyncio.sleep(0.5)
raise RuntimeError("播放列表获取失败")
async def _fetch_vlog_playlist_once(source: CollectionSource, vid: int, force: bool) -> str:
async with httpx.AsyncClient(follow_redirects=True) as client:
oauth_id, token = await open_session(client, source, force=force)
detail = await fetch_vlog_detail(client, source.list_url, oauth_id, token, vid)
m3u8 = detail.get("playUrl") or ""
if not m3u8:
raise RuntimeError("该视频暂无可播放地址(可能需付费或未解锁)")
resp = await client.get(
m3u8, headers={"User-Agent": HEADERS["User-Agent"]}, timeout=30)
resp.raise_for_status()
if not resp.text.lstrip().startswith("#EXTM3U"):
raise RuntimeError("源站播放列表异常")
return resp.text