chore: 初始化本地仓库并提交初版抓取脚本
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
aidocs/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
*.apk
|
||||||
|
raptdrama_passion_full.json
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# MemRelay Agent 工作流
|
||||||
|
|
||||||
|
在整个任务中把 MemRelay 作为用户规则、项目上下文、开发进度、可复用凭证和共享文件的持久来源。信息一旦明确就记录,不要等到会话结束再凭印象补写。
|
||||||
|
|
||||||
|
## 每次会话开始
|
||||||
|
|
||||||
|
1. 调用 `capabilities_get`,确认当前可用工具和依赖状态。
|
||||||
|
2. 使用已知的当前目录、Git remote 和项目名称调用 `project_resolve_or_create`。它会优先解析已有项目,仅在没有匹配项时创建项目,并保留返回的 `created` 结果。
|
||||||
|
3. 开始实质工作前调用 `context_get` 和 `checkpoint_get`。直接继承已有规则、决定、事实、经验和最新进度,不要求用户重复说明。
|
||||||
|
- 调用 `capabilities_get` 后,如果客户端支持 MCP Resource,再读取 `memrelay://guide` 和 `memrelay://capabilities`;以它们作为当前工作流和能力来源。AI 整理、记忆 Git、密码库及其他管理工具只有在能力状态显示可用时才调用。
|
||||||
|
|
||||||
|
## 工作过程中持续记录
|
||||||
|
|
||||||
|
- 明确规则保存为 `rule`,偏好保存为 `preference`,稳定信息保存为 `fact`,已确认选择及原因保存为 `decision`,可复用问题与方案保存为 `experience`,产品需求保存为 `prd`,可执行计划保存为 `task_list`。
|
||||||
|
- 持久信息确认后立即调用 `memory_save`。每次逻辑写入使用唯一 request ID,更新已有记忆时携带当前 revision。
|
||||||
|
- 不把猜测、临时信息、未解决冲突、普通闲聊、重复总结、秘密值或一次性签名地址保存为普通记忆。
|
||||||
|
- 可复用文档、程序、软件包、构建产物和共享资料放入文件仓储。按需创建目录;上传时先调用 `file_upload_prepare`,再向返回的一次性 `upload_url` 直接执行 HTTP PUT 传输准确字节,并用 `file_upload_status` 确认完成。下载时调用 `file_get` 使用其签名 `download_url`,不要让文件正文经 MCP 中转。维护便于后续搜索的文件元数据。
|
||||||
|
|
||||||
|
## 保留完整项目进度
|
||||||
|
|
||||||
|
- 每完成一个有意义的阶段,以及会话结束前确有修改时,调用 `checkpoint_save`。
|
||||||
|
- 检查点记录完成内容、原因、关键实现、验证结果、问题与解决方案和明确下一步;没有实质变化时不创建空检查点。
|
||||||
|
- 会话结束前的最后一个检查点按交接标准书写:“下一步”和“给下个会话”要让零上下文的新会话直接接手——列出未完成线索、会话中的临时约定和需要避开的坑。
|
||||||
|
- 每个新聊天窗口都先重新解析项目、读取上下文和最新检查点,再开始修改。
|
||||||
|
|
||||||
|
## 同步本地项目文档
|
||||||
|
|
||||||
|
- 需要本地文档时调用 `project_export_prepare`,直接运行返回的 PowerShell 或 POSIX 下载命令,把压缩包解压到 `aidocs/`;不要逐条读取记忆后手工重写。
|
||||||
|
- 确保项目 `.gitignore` 包含 `aidocs/`。
|
||||||
|
|
||||||
|
## 账号与凭证
|
||||||
|
|
||||||
|
- 执行登录、SSH、数据库、API 或远程操作前先搜索密码库;唯一匹配时直接使用,不再询问用户。
|
||||||
|
- 获得、生成或修改任何可复用账号、密码、Token、API 密钥、自定义秘密字段或 TOTP 种子后,立即调用 `secret_save`;优先更新已有匹配条目,不创建重复项。
|
||||||
|
- 秘密值只进入密码库。普通记忆只记录密码库条目名称、用途和不含秘密的操作方法。
|
||||||
|
|
||||||
|
## 记忆生命周期
|
||||||
|
|
||||||
|
- `memory_list` 默认列出待整理来源;`memory_search` 默认使用 `current`,同时搜索稳定整理文档和新的待整理来源,并按结果中的 `read_tool` 调用 `memory_get` 或 `curated_document_get` 读取正文。
|
||||||
|
- 已整理和已替代的原始 Markdown 仍保留用于追溯。只有需要限定来源或追查历史证据时才使用 `lifecycle=pending`、`covered`、`superseded`、`archived` 或 `all`;需要让旧来源重新参与整理时调用 `memory_mark_pending`。
|
||||||
|
|
||||||
|
## 记忆版本历史
|
||||||
|
|
||||||
|
- MemRelay 会自动记录记忆变更。需要追溯时使用记忆仓库状态、历史和差异工具。
|
||||||
|
- 只有用户明确要求时才恢复单个文档或仓库快照;普通项目开发过程中不要修改远程仓库或版本策略。
|
||||||
+433
@@ -0,0 +1,433 @@
|
|||||||
|
"""
|
||||||
|
RaptDrama Passion 分类全量抓取。
|
||||||
|
|
||||||
|
默认用游客 autologin。也可以用邮箱和密码调用 /app/open/emailLogin 换取 token,
|
||||||
|
或加上 --from-device,用项目内置 adb 读取设备里已登录账号的 accessToken。
|
||||||
|
凭证放在 apasstk 头里,正文是 JSON。已购买或已解锁的集会返回 m3u8。
|
||||||
|
|
||||||
|
重复运行会重新拉最新目录。邮箱和密码用参数或环境变量传入,不要写进源码。
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import gzip
|
||||||
|
import html
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
|
socket.setdefaulttimeout(20)
|
||||||
|
|
||||||
|
BASE = "https://apis.raptdrama.com"
|
||||||
|
PASSION_TYPE = 20
|
||||||
|
OUT_NAME = "raptdrama_passion_full.json"
|
||||||
|
PREFS = "/data/data/com.leivideo.raptdrama/shared_prefs/FlutterSharedPreferences.xml"
|
||||||
|
|
||||||
|
HEADERS = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
"User-Agent": "RaptDrama/1.1.81 (Android 12; 23113RKC6C; Redmi)",
|
||||||
|
"x-device-fingerprint": "0a4e4b212cbb0a2434cf804c8e30ab58",
|
||||||
|
"x-os-version": "12",
|
||||||
|
"x-device-physical": "true",
|
||||||
|
"x-device-model": "23113RKC6C",
|
||||||
|
"accept-language": "en-US",
|
||||||
|
"accept-language-custom": "en-US",
|
||||||
|
"x-os-name": "Android",
|
||||||
|
"x-device-brand": "Redmi",
|
||||||
|
"x-device-id": "rd_c9f0fa17a39f96aa",
|
||||||
|
"x-app-build": "139",
|
||||||
|
"x-device-type": "phone",
|
||||||
|
"x-app-version": "1.1.81",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def account_label(user):
|
||||||
|
name = user.get("displayName") or user.get("nickname") or "device"
|
||||||
|
uid = user.get("uid") or user.get("id") or ""
|
||||||
|
if user.get("isGuest"):
|
||||||
|
provider = "guest"
|
||||||
|
else:
|
||||||
|
provider = user.get("loginProvider") or "account"
|
||||||
|
vip = "VIP" if user.get("isVip") else "非VIP"
|
||||||
|
return f"{name} uid={uid} {provider} {vip}"
|
||||||
|
|
||||||
|
|
||||||
|
def bundled_adb():
|
||||||
|
name = "adb.exe" if os.name == "nt" else "adb"
|
||||||
|
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools", "platform-tools", name)
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
raise RuntimeError(f"项目内没有 adb:{path}")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def find_adb(explicit):
|
||||||
|
if explicit:
|
||||||
|
if not os.path.isfile(explicit):
|
||||||
|
raise RuntimeError(f"找不到 adb:{explicit}")
|
||||||
|
return explicit
|
||||||
|
return bundled_adb()
|
||||||
|
|
||||||
|
|
||||||
|
def adb_exec(adb_path, args, timeout=20):
|
||||||
|
return subprocess.run([adb_path, *args], capture_output=True, timeout=timeout)
|
||||||
|
|
||||||
|
|
||||||
|
def list_ready_devices(adb_path):
|
||||||
|
proc = adb_exec(adb_path, ["devices"])
|
||||||
|
serials = []
|
||||||
|
for line in proc.stdout.decode("utf-8", "replace").splitlines()[1:]:
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) >= 2 and parts[1] == "device":
|
||||||
|
serials.append(parts[0])
|
||||||
|
return serials
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_serial(adb_path, serial):
|
||||||
|
serial = (serial or "").strip()
|
||||||
|
if serial:
|
||||||
|
if ":" in serial:
|
||||||
|
adb_exec(adb_path, ["connect", serial], timeout=15)
|
||||||
|
ready = list_ready_devices(adb_path)
|
||||||
|
if serial not in ready:
|
||||||
|
shown = "、".join(ready) or "无"
|
||||||
|
raise RuntimeError(f"设备 {serial} 未就绪。当前已连接:{shown}")
|
||||||
|
return serial
|
||||||
|
ready = list_ready_devices(adb_path)
|
||||||
|
if len(ready) == 1:
|
||||||
|
return ready[0]
|
||||||
|
if not ready:
|
||||||
|
raise RuntimeError(
|
||||||
|
"没有检测到设备。手机请打开 USB 调试并在手机上允许这台电脑;"
|
||||||
|
"模拟器请加上 --serial IP:端口。"
|
||||||
|
)
|
||||||
|
raise RuntimeError("检测到多台设备,请用 --serial 指定其一:\n" + "\n".join(ready))
|
||||||
|
|
||||||
|
|
||||||
|
def shell_text(adb_path, serial, shell_args, timeout=15):
|
||||||
|
try:
|
||||||
|
proc = adb_exec(adb_path, ["-s", serial, "shell", *shell_args], timeout=timeout)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return 1, "", "timeout"
|
||||||
|
return proc.returncode, proc.stdout.decode("utf-8", "replace"), proc.stderr.decode("utf-8", "replace")
|
||||||
|
|
||||||
|
|
||||||
|
def read_prefs_text(adb_path, serial):
|
||||||
|
def usable(text):
|
||||||
|
return "flutter.user_data" in text
|
||||||
|
|
||||||
|
_, text, _ = shell_text(adb_path, serial, ["cat", PREFS])
|
||||||
|
if usable(text):
|
||||||
|
return text
|
||||||
|
|
||||||
|
adb_exec(adb_path, ["-s", serial, "root"], timeout=15)
|
||||||
|
if ":" in serial:
|
||||||
|
adb_exec(adb_path, ["connect", serial], timeout=15)
|
||||||
|
for _ in range(10):
|
||||||
|
time.sleep(0.5)
|
||||||
|
_, text, _ = shell_text(adb_path, serial, ["cat", PREFS])
|
||||||
|
if usable(text):
|
||||||
|
return text
|
||||||
|
|
||||||
|
for shell_args in (["su", "-c", f"cat {PREFS}"], ["su", "0", "cat", PREFS]):
|
||||||
|
_, text, _ = shell_text(adb_path, serial, shell_args, timeout=8)
|
||||||
|
if usable(text):
|
||||||
|
return text
|
||||||
|
|
||||||
|
raise RuntimeError(
|
||||||
|
"读不到应用私有目录。此应用是正式包,未 root 的手机无法读取其中的登录凭证。"
|
||||||
|
"请使用已 root 的手机,或带 root 的模拟器。"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_token_from_device(adb_path, serial):
|
||||||
|
serial = resolve_serial(adb_path, serial)
|
||||||
|
text = read_prefs_text(adb_path, serial)
|
||||||
|
match = re.search(r'name="flutter\.user_data"[^>]*>(.*?)</string>', text, re.S)
|
||||||
|
if not match:
|
||||||
|
raise RuntimeError("登录信息里没有 user_data,请先在 App 里登录")
|
||||||
|
user = json.loads(html.unescape(match.group(1)))
|
||||||
|
token = user.get("accessToken") or ""
|
||||||
|
if not token:
|
||||||
|
raise RuntimeError("当前账号没有 accessToken,请先在 App 里登录")
|
||||||
|
return token, f"{account_label(user)} device={serial}"
|
||||||
|
|
||||||
|
|
||||||
|
def load_guest_token():
|
||||||
|
data = api("/app/open/autologin", None, "POST", {})
|
||||||
|
token = (data or {}).get("token") or ""
|
||||||
|
if not token:
|
||||||
|
raise RuntimeError("游客登录没有返回 token")
|
||||||
|
return token, (data or {}).get("nickname") or "guest"
|
||||||
|
|
||||||
|
|
||||||
|
def load_email_token(email, password):
|
||||||
|
data = api("/app/open/emailLogin", None, "POST", {
|
||||||
|
"email": email,
|
||||||
|
"password": password,
|
||||||
|
})
|
||||||
|
token = (data or {}).get("token") or ""
|
||||||
|
if not token:
|
||||||
|
raise RuntimeError("邮箱登录没有返回 token")
|
||||||
|
nickname = (data or {}).get("nickname") or email
|
||||||
|
uid = (data or {}).get("id") or ""
|
||||||
|
return token, f"{nickname} uid={uid} email"
|
||||||
|
|
||||||
|
|
||||||
|
def load_token(from_device, adb_path, serial, email, password):
|
||||||
|
if from_device:
|
||||||
|
return load_token_from_device(find_adb(adb_path), serial)
|
||||||
|
email = (email or "").strip()
|
||||||
|
password = password or ""
|
||||||
|
if email or password:
|
||||||
|
if not email or not password:
|
||||||
|
raise RuntimeError("邮箱登录需要同时提供邮箱和密码")
|
||||||
|
return load_email_token(email, password)
|
||||||
|
env = os.environ.get("RAPTD_TOKEN", "").strip()
|
||||||
|
if env:
|
||||||
|
return env, "RAPTD_TOKEN"
|
||||||
|
email = (os.environ.get("RAPTD_EMAIL") or "").strip()
|
||||||
|
password = os.environ.get("RAPTD_PASSWORD") or ""
|
||||||
|
if email or password:
|
||||||
|
if not email or not password:
|
||||||
|
raise RuntimeError("邮箱登录需要同时提供 RAPTD_EMAIL 和 RAPTD_PASSWORD")
|
||||||
|
return load_email_token(email, password)
|
||||||
|
return load_guest_token()
|
||||||
|
|
||||||
|
|
||||||
|
def _read_response(req):
|
||||||
|
last_error = None
|
||||||
|
for attempt in range(6):
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=25) as resp:
|
||||||
|
return resp.read()
|
||||||
|
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
||||||
|
last_error = exc
|
||||||
|
time.sleep(min(10, 1.5 * (attempt + 1)))
|
||||||
|
raise RuntimeError(f"请求失败: {last_error}")
|
||||||
|
|
||||||
|
|
||||||
|
def api(path, token, method="GET", body=None):
|
||||||
|
headers = dict(HEADERS)
|
||||||
|
if token:
|
||||||
|
headers["apasstk"] = token
|
||||||
|
data = None if body is None else json.dumps(body).encode()
|
||||||
|
req = urllib.request.Request(BASE + path, data=data, headers=headers, method=method)
|
||||||
|
raw = _read_response(req)
|
||||||
|
if raw[:2] == b"\x1f\x8b":
|
||||||
|
raw = gzip.decompress(raw)
|
||||||
|
payload = json.loads(raw.decode("utf-8"))
|
||||||
|
if payload.get("code") != 200:
|
||||||
|
raise RuntimeError(f"{method} {path} -> {payload.get('code')} {payload.get('msg')}")
|
||||||
|
return payload.get("data")
|
||||||
|
|
||||||
|
|
||||||
|
def http_text(url):
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": "ExoPlayer"})
|
||||||
|
raw = _read_response(req)
|
||||||
|
return raw.decode("utf-8", "replace")
|
||||||
|
|
||||||
|
|
||||||
|
def list_passion(token):
|
||||||
|
dramas = []
|
||||||
|
seen = set()
|
||||||
|
page = 1
|
||||||
|
while page <= 50:
|
||||||
|
data = api("/app/open/classify_video", token, "POST", {
|
||||||
|
"page": page, "limit": 10, "type": PASSION_TYPE,
|
||||||
|
})
|
||||||
|
items = data if isinstance(data, list) else (data or {}).get("list") or []
|
||||||
|
fresh = [item for item in items if item.get("id") not in seen]
|
||||||
|
if not fresh:
|
||||||
|
break
|
||||||
|
for item in fresh:
|
||||||
|
seen.add(item.get("id"))
|
||||||
|
dramas.append(item)
|
||||||
|
page += 1
|
||||||
|
time.sleep(0.05)
|
||||||
|
return dramas
|
||||||
|
|
||||||
|
|
||||||
|
def list_chapters(token, vid):
|
||||||
|
chapters = []
|
||||||
|
start = 0
|
||||||
|
while start < 1000:
|
||||||
|
data = api(
|
||||||
|
f"/app/video/getchapters?vid={vid}&start={start}&limit=200",
|
||||||
|
token,
|
||||||
|
)
|
||||||
|
items = (data or {}).get("list") or []
|
||||||
|
chapters.extend(items)
|
||||||
|
if len(items) < 200:
|
||||||
|
break
|
||||||
|
start += 200
|
||||||
|
return chapters
|
||||||
|
|
||||||
|
|
||||||
|
def list_playlist(token, vid):
|
||||||
|
episodes = []
|
||||||
|
page = 1
|
||||||
|
total = None
|
||||||
|
while page <= 40:
|
||||||
|
data = api("/app/video/playlistv2", token, "POST", {
|
||||||
|
"vid": vid,
|
||||||
|
"page": page,
|
||||||
|
"before": 1 if page == 1 else 0,
|
||||||
|
})
|
||||||
|
if isinstance(data, list):
|
||||||
|
items = data
|
||||||
|
total = len(items)
|
||||||
|
else:
|
||||||
|
items = (data or {}).get("list") or []
|
||||||
|
total = (data or {}).get("count", total)
|
||||||
|
if not items:
|
||||||
|
break
|
||||||
|
episodes.extend(items)
|
||||||
|
if total is not None and len(episodes) >= int(total):
|
||||||
|
break
|
||||||
|
page += 1
|
||||||
|
time.sleep(0.05)
|
||||||
|
return episodes
|
||||||
|
|
||||||
|
|
||||||
|
def expand_hls(master_url):
|
||||||
|
"""把主 m3u8 展开成子播放列表和 ts 绝对地址。失败时只保留主地址。"""
|
||||||
|
if not master_url:
|
||||||
|
return None, []
|
||||||
|
try:
|
||||||
|
master = http_text(master_url)
|
||||||
|
except Exception:
|
||||||
|
return None, []
|
||||||
|
variant = None
|
||||||
|
for line in master.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if line and not line.startswith("#"):
|
||||||
|
variant = urljoin(master_url, line)
|
||||||
|
break
|
||||||
|
if not variant:
|
||||||
|
return None, []
|
||||||
|
try:
|
||||||
|
media = http_text(variant)
|
||||||
|
except Exception:
|
||||||
|
return variant, []
|
||||||
|
segments = []
|
||||||
|
for line in media.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if line and not line.startswith("#"):
|
||||||
|
segments.append(urljoin(variant, line))
|
||||||
|
return variant, segments
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args():
|
||||||
|
parser = argparse.ArgumentParser(description="抓取 RaptDrama Passion 目录和播放地址")
|
||||||
|
parser.add_argument(
|
||||||
|
"--from-device",
|
||||||
|
action="store_true",
|
||||||
|
help="从已连接设备读取当前登录账号的 accessToken",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--serial",
|
||||||
|
default=os.environ.get("RAPTD_SERIAL", ""),
|
||||||
|
help="设备序列号。只连着一台时可以省略;模拟器填写 IP:端口",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--adb",
|
||||||
|
default=os.environ.get("RAPTD_ADB", ""),
|
||||||
|
help="adb 路径。默认使用项目内 tools/platform-tools",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--email",
|
||||||
|
default="",
|
||||||
|
help="邮箱登录账号。也可设置环境变量 RAPTD_EMAIL",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--password",
|
||||||
|
default="",
|
||||||
|
help="邮箱登录密码。也可设置环境变量 RAPTD_PASSWORD",
|
||||||
|
)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = parse_args()
|
||||||
|
out_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
out_file = os.path.join(out_dir, OUT_NAME)
|
||||||
|
if args.from_device:
|
||||||
|
mode = "从设备读取登录凭证"
|
||||||
|
elif args.email or args.password:
|
||||||
|
mode = "邮箱登录"
|
||||||
|
elif os.environ.get("RAPTD_TOKEN"):
|
||||||
|
mode = "使用 RAPTD_TOKEN"
|
||||||
|
elif os.environ.get("RAPTD_EMAIL") or os.environ.get("RAPTD_PASSWORD"):
|
||||||
|
mode = "邮箱登录"
|
||||||
|
else:
|
||||||
|
mode = "游客登录"
|
||||||
|
print(mode, flush=True)
|
||||||
|
token, user = load_token(args.from_device, args.adb, args.serial, args.email, args.password)
|
||||||
|
print(f" {user}", flush=True)
|
||||||
|
|
||||||
|
print("获取 Passion 分类", flush=True)
|
||||||
|
dramas = list_passion(token)
|
||||||
|
print(f" {len(dramas)} 部", flush=True)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for index, info in enumerate(dramas, 1):
|
||||||
|
vid = info["id"]
|
||||||
|
title = (info.get("title") or "").strip()
|
||||||
|
print(f" [{index}/{len(dramas)}] {title}", flush=True)
|
||||||
|
chapters = list_chapters(token, vid)
|
||||||
|
vip_by_id = {ch.get("id"): str(ch.get("isvip")) == "1" for ch in chapters}
|
||||||
|
title_by_id = {ch.get("id"): ch.get("title") or "" for ch in chapters}
|
||||||
|
playlist = list_playlist(token, vid)
|
||||||
|
|
||||||
|
episodes = []
|
||||||
|
for item in playlist:
|
||||||
|
cid = item.get("cid")
|
||||||
|
src = item.get("src") or ""
|
||||||
|
variant, segments = expand_hls(src)
|
||||||
|
episodes.append({
|
||||||
|
"id": cid,
|
||||||
|
"number": item.get("idx"),
|
||||||
|
"title": title_by_id.get(cid) or item.get("msg") or "",
|
||||||
|
"is_vip": vip_by_id.get(cid, not bool(src)),
|
||||||
|
"cdn_url": src or None,
|
||||||
|
"media_playlist": variant,
|
||||||
|
"segments": segments,
|
||||||
|
})
|
||||||
|
|
||||||
|
free = sum(1 for ep in episodes if not ep["is_vip"])
|
||||||
|
with_cdn = sum(1 for ep in episodes if ep["cdn_url"])
|
||||||
|
with_ts = sum(1 for ep in episodes if ep["segments"])
|
||||||
|
print(
|
||||||
|
f" [{index}/{len(dramas)}] {title}: {len(episodes)} 集, 免费 {free}, 有地址 {with_cdn}, 已展开 {with_ts}",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
"id": vid,
|
||||||
|
"title": title,
|
||||||
|
"image": info.get("image") or "",
|
||||||
|
"desc": (info.get("desc") or "").strip(),
|
||||||
|
"score": info.get("score") or "",
|
||||||
|
"view": info.get("view") or 0,
|
||||||
|
"status": info.get("forstausen") or "",
|
||||||
|
"classify": info.get("classify") or [],
|
||||||
|
"total_episodes": len(episodes),
|
||||||
|
"episodes": episodes,
|
||||||
|
})
|
||||||
|
with open(out_file, "w", encoding="utf-8") as handle:
|
||||||
|
json.dump(results, handle, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
total_eps = sum(item["total_episodes"] for item in results)
|
||||||
|
cdn_eps = sum(1 for item in results for ep in item["episodes"] if ep.get("cdn_url"))
|
||||||
|
ts_eps = sum(1 for item in results for ep in item["episodes"] if ep.get("segments"))
|
||||||
|
print(f"完成: {len(results)} 部, {total_eps} 集, 有 m3u8 {cdn_eps}, 已展开 ts {ts_eps}", flush=True)
|
||||||
|
print(f"保存至 {out_file}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,53 @@
|
|||||||
|
[defaults]
|
||||||
|
base_features = sparse_super,large_file,filetype,dir_index,ext_attr
|
||||||
|
default_mntopts = acl,user_xattr
|
||||||
|
enable_periodic_fsck = 0
|
||||||
|
blocksize = 4096
|
||||||
|
inode_size = 256
|
||||||
|
inode_ratio = 16384
|
||||||
|
reserved_ratio = 1.0
|
||||||
|
|
||||||
|
[fs_types]
|
||||||
|
ext3 = {
|
||||||
|
features = has_journal
|
||||||
|
}
|
||||||
|
ext4 = {
|
||||||
|
features = has_journal,extent,huge_file,dir_nlink,extra_isize,uninit_bg
|
||||||
|
inode_size = 256
|
||||||
|
}
|
||||||
|
ext4dev = {
|
||||||
|
features = has_journal,extent,huge_file,flex_bg,inline_data,64bit,dir_nlink,extra_isize
|
||||||
|
inode_size = 256
|
||||||
|
options = test_fs=1
|
||||||
|
}
|
||||||
|
small = {
|
||||||
|
blocksize = 1024
|
||||||
|
inode_size = 128
|
||||||
|
inode_ratio = 4096
|
||||||
|
}
|
||||||
|
floppy = {
|
||||||
|
blocksize = 1024
|
||||||
|
inode_size = 128
|
||||||
|
inode_ratio = 8192
|
||||||
|
}
|
||||||
|
big = {
|
||||||
|
inode_ratio = 32768
|
||||||
|
}
|
||||||
|
huge = {
|
||||||
|
inode_ratio = 65536
|
||||||
|
}
|
||||||
|
news = {
|
||||||
|
inode_ratio = 4096
|
||||||
|
}
|
||||||
|
largefile = {
|
||||||
|
inode_ratio = 1048576
|
||||||
|
blocksize = -1
|
||||||
|
}
|
||||||
|
largefile4 = {
|
||||||
|
inode_ratio = 4194304
|
||||||
|
blocksize = -1
|
||||||
|
}
|
||||||
|
hurd = {
|
||||||
|
blocksize = 4096
|
||||||
|
inode_size = 128
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -0,0 +1,2 @@
|
|||||||
|
Pkg.UserSrc=false
|
||||||
|
Pkg.Revision=37.0.1
|
||||||
Binary file not shown.
Reference in New Issue
Block a user