Spider 类型总览
FongMi/TV 支持 3 种可扩展 Spider 运行时,以及 1 种内置(无需代码)方案:
Java 或 Kotlin 类继承
Spider
基类,打包为含 classes.dex 的 JAR 文件。性能最强,可调用完整 Android SDK 与第三方 Java
库。推荐用于高性能或签名需求。
构建方式:Android Gradle 编译输出 release JAR/AAR,通过 DexClassLoader 动态加载。
JavaScript 文件,运行于嵌入式 QuickJS 引擎(ES2020+)。支持
req()
、
pd()
、
CryptoJS
、
cheerio
等内置工具。
开发效率最高,适合大多数爬虫场景,免编译即可更新。
Python 3 脚本,运行于 Chaquopy 环境。可使用
requests
、
BeautifulSoup
、
lxml
等标准 Python 库。
适合已有 Python 爬虫代码的快速移植;需要 App 自带 Chaquopy 支持。
直接对接苹果 CMS XML 或 JSON API 格式(
<rss>
/
class[]
/
list[]
)。无需任何代码,
api
字段直接填接口根 URL。
适合已有标准 CMS 接口的站点,零开发成本。
选型参考
| 维度 | JAR | JS | Python |
|---|---|---|---|
| 开发语言 | Java / Kotlin | JavaScript (ES2020) | Python 3 |
| 运行时 | Android ART(DexClassLoader) | QuickJS 引擎 | Chaquopy |
| 性能 | ⭐⭐⭐ 最佳 | ⭐⭐ 良好 | ⭐ 一般 |
| 更新方式 | 远程 JAR URL(需重新编译) | 远程 JS URL(即改即生效) | 远程 PY URL(即改即生效) |
| 第三方库 | OkHttp、Jsoup、内置完整 | 内置 CryptoJS、cheerio | requests、bs4、lxml 等 |
| HTTPS / TLS | 完全支持(OkHttp) | 通过 Java 桥接 | 通过 Python requests |
| 推荐场景 | 高频请求、签名加密、复杂业务 | 通用爬虫、快速迭代 | 已有 Python 代码移植 |
Spider 接口方法完整参考
所有 Spider 继承自 com.github.catvod.crawler.Spider 抽象类,默认实现返回空结果。只需覆盖(Override)所需方法。
| 方法签名 | 调用时机 | 参数说明 | 返回格式 |
|---|---|---|---|
init(ctx, extend) |
Spider 首次创建时 |
extend
= site.ext 字段的值(已 fetch 的字符串)
|
无返回值 |
homeContent(filter) |
打开站点首页 |
filter
=true 时需要返回
filters{}
|
Result JSON,含
class[]
分类列表(必须),可含
list[]
推荐视频,可含
filters{}
|
homeVideoContent() |
首页加载更多推荐视频 | 无 |
Result JSON,含
list[]
|
categoryContent(tid, pg, filter, extend) |
进入分类 / 翻页 / 切换筛选 |
tid
=分类 ID,
pg
=页码(从 "1" 开始),
filter
=是否有筛选,
extend
=用户筛选条件 Map
|
Result JSON,含
list[]
和
pagecount
(总页数)
|
detailContent(ids) |
打开视频详情页 |
ids
=vod_id 列表(通常只有一个元素)
|
Result JSON,含
list[]
(完整 Vod,含 vod_play_from/url)
|
searchContent(key, quick) |
搜索(基础版) |
key
=关键词,
quick
=是否快速搜索(不翻页)
|
Result JSON,含
list[]
|
searchContent(key, quick, pg) |
搜索(分页版) |
同上 +
pg
=当前页码
|
Result JSON,含
list[]
和
pagecount
|
playerContent(flag, id, vipFlags) |
点击剧集播放 |
flag
=来源名(vod_play_from 中的一项),
id
=集 URL/ID(vod_play_url 中的值),
vipFlags
=全局 flags 列表
|
Result JSON,含
url
(直链)或
parse=1
(交解析器),可含
header
、
format
、
key
(DRM)、
subs
|
liveContent(url) |
直播频道播放 |
url
=直播频道 URL
|
JSON 字符串(含直播流地址)或直接返回重写后的 URL |
manualVideoCheck() |
决定是否手动检测视频格式 | 无 | boolean,true=App 手动嗅探;false=依赖 Content-Type 自动判断 |
isVideoFormat(url) |
每次嗅探到 URL 时调用 |
url
=待判断 URL
|
boolean,true=这是视频 URL 停止嗅探 |
proxy(params) |
本地代理请求到达时 |
params
=请求参数 Map(from App 内置 local server)
|
Object[] = {statusCode, contentType, responseBody} |
action(action) |
自定义动作(如刷新 Token) |
action
=动作标识字符串
|
JSON 字符串 |
destroy() |
Spider 被销毁前 | 无 | 无(释放资源) |
public String siteKey
字段,由加载器在实例化后自动写入当前站点的
key
值。你可以在 Spider 内部通过
this.siteKey
(Java)或
siteKey
(JS)读取,用于多站点共享一个 Spider 类时区分站点。
JAR Spider(Java / Kotlin)
项目配置
-
1新建 Android Library 模块在 Android Studio 创建空 Library(不是 Application)。
compileSdk建议 34 以上。 -
2添加 catvod 依赖在
build.gradle中添加 catvod 核心依赖(提供 Spider 基类、OkHttp 客户端、工具函数等)。 -
3创建 Spider 类类名以
csp_开头(约定,非强制),重写所需方法。 -
4打包为 JAR(含 DEX)通过自定义 Gradle Task 将 classes.dex 打包进 JAR,确保 DexClassLoader 能加载。
build.gradle 配置
plugins {
id 'com.android.library'
}
android {
compileSdk 34
defaultConfig {
minSdk 21
}
}
dependencies {
// 提供 Spider 基类、OkHttp 包装、工具函数
compileOnly 'com.github.catvod:catvod:最新版本'
// 常用工具(非必须,catvod 内已有 okhttp)
compileOnly 'com.squareup.okhttp3:okhttp:4.12.0'
compileOnly 'org.jsoup:jsoup:1.17.2'
}
// 打包为含 DEX 的 JAR Task
task makeJar(type: Jar, dependsOn: ['assembleRelease']) {
archiveFileName = "spider.jar"
from zipTree("build/outputs/aar/${project.name}-release.aar")
include 'classes.dex', 'classes*.dex'
}
完整 Spider 示例(Java)
import android.content.Context;
import com.github.catvod.crawler.Spider;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import org.json.JSONArray;
import org.json.JSONObject;
import java.util.HashMap;
import java.util.List;
public class csp_DemoSite extends Spider {
private static final String BASE_URL = "https://api.example.com";
private OkHttpClient http;
private String token = "";
// 初始化(extend = site.ext 字段)
@Override
public void init(Context context, String extend) throws Exception {
http = client(); // 使用基类提供的安全 OkHttp 客户端
// extend 可以是 URL 或 JSON 字符串,自行解析
if (extend != null && extend.startsWith("http")) {
String body = http.newCall(new Request.Builder().url(extend).build())
.execute().body().string();
token = new JSONObject(body).optString("token");
}
}
// 首页:返回分类列表
@Override
public String homeContent(boolean filter) throws Exception {
JSONObject resp = get(BASE_URL + "/home");
JSONObject result = new JSONObject();
JSONArray classes = new JSONArray();
JSONArray cats = resp.getJSONArray("categories");
for (int i = 0; i < cats.length(); i++) {
JSONObject item = cats.getJSONObject(i);
classes.put(new JSONObject()
.put("type_id", item.getString("id"))
.put("type_name", item.getString("name"))
.put("type_flag", "1")); // 1=有筛选器
}
result.put("class", classes);
if (filter) result.put("filters", buildFilters(resp));
return result.toString();
}
// 分类页:返回列表 + 总页数
@Override
public String categoryContent(
String tid, String pg, boolean filter,
HashMap<String, String> extend) throws Exception {
String area = extend.getOrDefault("area", "");
String year = extend.getOrDefault("year", "");
JSONObject resp = get(BASE_URL + "/vod?type=" + tid + "&pg=" + pg
+ "&area=" + area + "&year=" + year);
return buildVodList(resp).toString();
}
// 详情页:返回完整 Vod(含播放 URL)
@Override
public String detailContent(List<String> ids) throws Exception {
JSONObject resp = get(BASE_URL + "/detail?id=" + ids.get(0));
JSONObject vod = resp.getJSONObject("vod");
JSONArray eps = vod.getJSONArray("episodes");
// 播放信息:多集用 # 分隔,集名$URL 格式
StringBuilder playUrl = new StringBuilder();
for (int i = 0; i < eps.length(); i++) {
if (i > 0) playUrl.append("#");
JSONObject ep = eps.getJSONObject(i);
playUrl.append(ep.getString("name")).append("$").append(ep.getString("url"));
}
JSONObject item = new JSONObject()
.put("vod_id", ids.get(0))
.put("vod_name", vod.getString("title"))
.put("vod_content", vod.optString("desc"))
.put("vod_pic", vod.optString("cover"))
.put("vod_year", vod.optString("year"))
.put("vod_actor", vod.optString("actors"))
.put("vod_director", vod.optString("director"))
.put("vod_play_from", "线路1")
.put("vod_play_url", playUrl.toString());
return new JSONObject().put("list", new JSONArray().put(item)).toString();
}
// 播放:返回真实直链
@Override
public String playerContent(String flag, String id, List<String> vipFlags) throws Exception {
// 如果 id 已经是 m3u8/mp4 直链,直接返回
if (id.contains(".m3u8") || id.contains(".mp4")) {
return new JSONObject()
.put("parse", 0)
.put("url", id)
.toString();
}
// 否则通过 API 解析
JSONObject resp = get(BASE_URL + "/play?id=" + id);
return new JSONObject()
.put("parse", 0)
.put("url", resp.getString("url"))
.put("header", new JSONObject().put("Referer", BASE_URL))
.toString();
}
// 搜索
@Override
public String searchContent(String key, boolean quick) throws Exception {
JSONObject resp = get(BASE_URL + "/search?q=" + key);
return buildVodList(resp).toString();
}
// 工具函数
private JSONObject get(String url) throws Exception {
Request req = new Request.Builder().url(url)
.header("Authorization", "Bearer " + token)
.build();
return new JSONObject(http.newCall(req).execute().body().string());
}
private JSONObject buildVodList(JSONObject resp) throws Exception {
JSONArray items = resp.getJSONArray("list");
JSONArray list = new JSONArray();
for (int i = 0; i < items.length(); i++) {
JSONObject item = items.getJSONObject(i);
list.put(new JSONObject()
.put("vod_id", item.getString("id"))
.put("vod_name", item.getString("title"))
.put("vod_pic", item.optString("cover"))
.put("vod_remarks", item.optString("tag")));
}
return new JSONObject()
.put("list", list)
.put("pagecount", resp.optInt("totalPage", 1));
}
}
JS Spider(QuickJS)
JS Spider 是最流行的编写方式。文件为标准 JavaScript,通过暴露特定变量/函数让 App 识别 Spider 类。QuickJS 支持 ES2020+ 语法,包括 async/await、可选链、nullish coalescing 等。
基本结构
// ── Spider 类(命名与配置中的 api 字段一致)
var csp_DemoSite = {
// ── 内部状态
baseUrl: '',
token: '',
// ── init:接收 extend(site.ext 的内容)
init: async function(ext) {
// ext 可以是 URL(已自动 fetch)或 JSON 字符串
var config = typeof ext === 'string' ? JSON.parse(ext) : ext;
this.baseUrl = config.url || 'https://api.example.com';
this.token = config.token || '';
},
// ── 首页:返回分类列表 + 可选首页推荐
homeContent: async function(filter) {
var resp = await req(this.baseUrl + '/home', {headers: this.headers()});
var data = JSON.parse(resp.content);
var classes = data.categories.map(c => ({
type_id: c.id,
type_name: c.name,
type_flag: '1', // 1 = 有筛选器
}));
var filters = {};
if (filter) {
classes.forEach(c => {
filters[c.type_id] = [
{ key: 'area', name: '地区', value: [
{n:'全部',v:''}, {n:'大陆',v:'1'}, {n:'欧美',v:'2'}
]},
{ key: 'year', name: '年份', value: [
{n:'全部',v:''}, {n:'2025',v:'2025'}, {n:'2024',v:'2024'}
]}
];
});
}
return JSON.stringify({ class: classes, filters });
},
// ── 分类页
categoryContent: async function(tid, pg, filter, extend) {
var url = `${this.baseUrl}/vod?type=${tid}&pg=${pg}&area=${extend.area||''}&year=${extend.year||''}`;
var resp = await req(url, {headers: this.headers()});
var data = JSON.parse(resp.content);
return JSON.stringify({
list: data.list.map(this.mapVod),
pagecount: data.totalPage,
});
},
// ── 详情页
detailContent: async function(ids) {
var resp = await req(this.baseUrl + `/detail?id=${ids[0]}`, {headers: this.headers()});
var vod = JSON.parse(resp.content).vod;
var playUrl = vod.episodes.map(ep => `${ep.name}$${ep.url}`).join('#');
return JSON.stringify({ list: [{
vod_id: ids[0],
vod_name: vod.title,
vod_pic: vod.cover,
vod_content: vod.desc,
vod_year: vod.year,
vod_actor: vod.actors,
vod_director: vod.director,
vod_remarks: vod.remarks,
vod_play_from: '线路1',
vod_play_url: playUrl,
}]});
},
// ── 播放
playerContent: async function(flag, id, vipFlags) {
if (id.includes('.m3u8') || id.includes('.mp4')) {
return JSON.stringify({ parse: 0, url: id });
}
var resp = await req(this.baseUrl + `/play?id=${id}`, {headers: this.headers()});
var data = JSON.parse(resp.content);
return JSON.stringify({
parse: 0,
url: data.url,
header: { Referer: this.baseUrl },
});
},
// ── 搜索
searchContent: async function(key, quick) {
var resp = await req(this.baseUrl + `/search?q=${encodeURIComponent(key)}`, {headers: this.headers()});
var data = JSON.parse(resp.content);
return JSON.stringify({ list: data.list.map(this.mapVod) });
},
// ── 内部工具
headers: function() {
return { Authorization: `Bearer ${this.token}`, 'User-Agent': 'TV/1.0' };
},
mapVod: function(item) {
return {
vod_id: item.id,
vod_name: item.title,
vod_pic: item.cover,
vod_remarks: item.tag,
};
},
};
使用 HTML 解析工具(pd / pdfh / pdfa)
detailContent: async function(ids) {
var resp = await req(`https://site.example.com/movie/${ids[0]}.html`);
var html = resp.content;
var base = resp.url; // 最终 URL(可能经过跳转)
// pdfh:提取单个节点的内容(CSS 选择器 + 属性)
var title = pdfh(html, 'h1.title&&Text');
var cover = pdfh(html, 'img.cover&&src');
// pdfa:提取多个节点列表(返回 HTML 片段数组)
var epItems = pdfa(html, '.episodes&&a');
var playUrl = epItems.map(ep => {
var name = pdfh(ep, 'a&&Text');
var href = pdfh(ep, 'a&&href');
// pd:把相对 URL 拼成绝对 URL
return `${name}$${pd(ep, 'a&&href', base)}`;
}).join('#');
return JSON.stringify({ list: [{
vod_id: ids[0],
vod_name: title,
vod_pic: cover,
vod_play_from: '来源一',
vod_play_url: playUrl,
}]});
},
使用 CryptoJS 处理加密
playerContent: async function(flag, id, vipFlags) {
var resp = await req(`https://api.example.com/enc_play?id=${id}`);
var data = JSON.parse(resp.content);
// AES-CBC 解密示例
var key = CryptoJS.enc.Utf8.parse('your_16_byte_key');
var iv = CryptoJS.enc.Utf8.parse('your_16_byte_ivv');
var decrypted = CryptoJS.AES.decrypt(data.enc_url, key, {
iv: iv,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7,
});
var url = decrypted.toString(CryptoJS.enc.Utf8);
return JSON.stringify({ parse: 0, url });
},
JS Spider 内置全局 API 总览
| 全局变量 / 函数 | 说明 |
|---|---|
| req(url, opts) |
HTTP 请求,返回
{content, code, headers, url}
。opts 支持
method
、
headers
、
body
、
timeout
|
| pdfh(html, rule) |
从 HTML 提取单个值(CSS 选择器 + 属性,如
'div.title&&Text'
)
|
| pdfa(html, rule) | 从 HTML 提取节点列表(返回每个节点的 HTML 字符串数组) |
| pd(html, rule, base) | 提取 URL 并拼接 base,返回绝对 URL |
| pdfl(html, rule, attr, base) | 提取 URL 列表(数组形式) |
| CryptoJS | 内置 crypto-js 库(AES / DES / MD5 / SHA 等) |
| cheerio |
内置 cheerio(JQuery-like HTML 解析,
cheerio.load(html)
)
|
| local_proxy |
是否启用本地代理服务器(布尔),set true 后 App 向 Spider 的
proxy()
转发请求
|
| print(...args) | 输出调试日志(Android Logcat tag: QuickJS) |
| siteKey |
当前站点的
key
字段(由 App 注入,用于多站点共享同一 JS)
|
| siteType |
当前站点的
type
字段
|
req() 参数详解
// GET
var r1 = await req('https://api.example.com/list', {
headers: { 'Cookie': 'sess=abc' },
timeout: 15000, // ms
redirect: true, // 跟随重定向
withRedirectUrl: true, // 在 content 中附加最终 URL
});
print(r1.code); // HTTP 状态码
print(r1.content); // 响应体字符串
print(r1.headers); // 响应头 JSON 对象
print(r1.url); // 最终 URL(经过重定向后)
// POST(表单)
var r2 = await req('https://api.example.com/login', {
method: 'post',
data: { username: 'admin', password: '123456' },
});
// POST(JSON body)
var r3 = await req('https://api.example.com/api', {
method: 'post',
body: JSON.stringify({ id: '123' }),
headers: { 'Content-Type': 'application/json' },
});
Python Spider(Chaquopy)
Python Spider 运行于 Chaquopy 提供的 Python 3.8+ 环境。类继承关系与 JAR 相同,只是语言是 Python,且通过 Chaquopy 的 Android 桥接调用 Java 对象。
build.gradle
中集成了 Chaquopy 插件,并且设备上安装了完整的 Python 运行环境。部分精简版 App 不支持
Python Spider。
Python Spider 模板
# -*- coding: utf-8 -*-
import json
import requests
from bs4 import BeautifulSoup
class Spider:
"""FongMi/TV Python Spider 模板"""
def __init__(self):
self.base_url = 'https://api.example.com'
self.session = requests.Session()
self.session.headers.update({'User-Agent': 'Mozilla/5.0'})
def init(self, extend: str):
"""接收 site.ext 字段(字符串,可以是 URL 已 fetch 的内容,或直接 JSON)"""
try:
config = json.loads(extend)
self.base_url = config.get('url', self.base_url)
token = config.get('token', '')
if token:
self.session.headers.update({'Authorization': f'Bearer {token}'})
except Exception:
pass
def homeContent(self, filter: bool) -> str:
resp = self.session.get(self.base_url + '/home').json()
classes = [
{'type_id': c['id'], 'type_name': c['name'], 'type_flag': '1'}
for c in resp.get('categories', [])
]
result = {'class': classes}
if filter:
result['filters'] = self._build_filters()
return json.dumps(result, ensure_ascii=False)
def categoryContent(self, tid: str, pg: str, filter: bool,
extend: dict) -> str:
url = self.base_url + f'/vod?type={tid}&pg={pg}'
url += f'&area={extend.get("area","")}&year={extend.get("year","")}'
data = self.session.get(url).json()
return json.dumps({
'list': [self._map_vod(v) for v in data.get('list', [])],
'pagecount': data.get('totalPage', 1),
}, ensure_ascii=False)
def detailContent(self, ids: list) -> str:
data = self.session.get(self.base_url + f'/detail?id={ids[0]}').json()
vod = data['vod']
play_url = '#'.join(
f'{ep["name"]}${ep["url"]}' for ep in vod.get('episodes', [])
)
return json.dumps({'list': [{
'vod_id': ids[0],
'vod_name': vod['title'],
'vod_pic': vod.get('cover', ''),
'vod_content': vod.get('desc', ''),
'vod_year': vod.get('year', ''),
'vod_actor': vod.get('actors', ''),
'vod_director': vod.get('director', ''),
'vod_play_from': '线路1',
'vod_play_url': play_url,
}]}, ensure_ascii=False)
def playerContent(self, flag: str, id_: str,
vip_flags: list) -> str:
if any(id_.endswith(ext) for ext in ['.m3u8', '.mp4']):
return json.dumps({'parse': 0, 'url': id_})
data = self.session.get(self.base_url + f'/play?id={id_}').json()
return json.dumps({
'parse': 0,
'url': data['url'],
'header': {'Referer': self.base_url},
}, ensure_ascii=False)
def searchContent(self, key: str, quick: bool) -> str:
data = self.session.get(self.base_url + f'/search?q={key}').json()
return json.dumps({
'list': [self._map_vod(v) for v in data.get('list', [])]
}, ensure_ascii=False)
### ── 工具函数 ─────────────────────────────────
def _map_vod(self, item: dict) -> dict:
return {
'vod_id': item.get('id', ''),
'vod_name': item.get('title', ''),
'vod_pic': item.get('cover', ''),
'vod_remarks': item.get('tag', ''),
}
def _build_filters(self) -> dict:
common = [
{'key': 'area', 'name': '地区', 'value': [
{'n': '全部', 'v': ''}, {'n': '大陆', 'v': '1'}
]},
]
return {'1': common, '2': common}
工具函数 API(req / pd / pdfh / pdfa / pdfl)
以下内置工具函数可在 JS Spider 中直接调用(无需 import),也可通过 JAR Spider 中的对应 Java 工具类实现类似功能。
pdfh — 提取单个值
// 基本用法:选择器&&属性名
pdfh(html, 'div.title&&Text') // 获取文本内容
pdfh(html, 'img.poster&&src') // 获取 src 属性
pdfh(html, 'a.link&&href') // 获取 href 属性
pdfh(html, 'div.wrap&&Html') // 获取 innerHTML
// 多层选择(用空格分隔层级,类似 CSS 后代选择器)
pdfh(html, '.card .title&&Text')
pdfh(html, 'ul.eps li:eq(0) a&&Text') // :eq(n) 选第 n 个
// 正则提取(使用 ##regex##)
pdfh(html, 'script&&Html##var id="(\\d+)"##')
pdfa — 提取节点列表
// pdfa 返回匹配元素的 HTML 字符串列表
var items = pdfa(html, '.vod-list&&.vod-item');
items.forEach(item => {
var name = pdfh(item, 'a&&Title'); // 或 Text
var href = pdfh(item, 'a&&href');
var pic = pdfh(item, 'img&&src');
// ...
});
pd — 提取绝对 URL
// pd = pdfh + URL 补全(相对路径 → 绝对路径)
var base = 'https://site.example.com/movie/';
var link = pd(html, 'a.page-link&&href', base);
// 例:href="/page/2" → https://site.example.com/page/2
选择器规则语法速查
| 语法 | 说明 | 示例 |
|---|---|---|
| &&Text | 获取元素的文本内容(innerText) | 'h1.title&&Text' |
| &&Html | 获取元素的 innerHTML | 'div.desc&&Html' |
| &&属性名 | 获取指定 HTML 属性值 | 'img&&data-src' |
| :eq(n) | 选取第 n 个匹配元素(0 开始) | 'li:eq(2)&&Text' |
| ##regex## | 在提取结果上应用正则,返回第一个捕获组 | 'script&&Html##"url":"(.*?)"##' |
| 空格 | 层级后代选择(父 子) | '.list .item a&&href' |
| Class | 获取 class 属性 | 'div&&Class' |
Live Spider(liveContent 方法)
liveContent(url) 用于在直播播放前对 URL 进行二次处理(如解密、获取临时鉴权 URL、动态替换地址等)。
@Override
public String liveContent(String url) throws Exception {
// url 是配置中直播频道的原始 URL
// 假设需要把静态 URL 换成带时效 token 的临时地址
JSONObject body = get(apiBase + "/live/auth?channel=" + Uri.encode(url));
String tempUrl = body.getString("stream_url");
// 可以直接返回 URL 字符串
return tempUrl;
// 也可以返回播放 JSON(同 playerContent 格式)
// return new JSONObject()
// .put("url", tempUrl)
// .put("header", new JSONObject().put("Referer", apiBase))
// .toString();
}
liveContent: async function(url) {
// 1. 直接返回重写后的 URL(最简单)
if (url.includes('cdn1.old.com')) {
return url.replace('cdn1.old.com', 'cdn2.new.com');
}
// 2. 通过接口获取临时鉴权 URL
var resp = await req(this.baseUrl + `/live_auth?url=${encodeURIComponent(url)}`);
var data = JSON.parse(resp.content);
return JSON.stringify({
url: data.stream_url,
header: { 'Referer': 'https://live.example.com/' },
});
},
spider
字段指向同一 JAR/JS 文件,用
playerContent
字段指向负责播放处理的 Spider 类名即可。当直播频道带有
playUrl
时,liveContent 会被调用。
proxy() 本地代理方法
proxy() 让 Spider 在 App 内置的本地 HTTP 服务器上注册处理器,用于绕过某些服务跨域限制、动态修改请求/响应,或提供本地缓存服务。
// proxy() 返回 Object[]:[状态码, Content-Type, 响应体]
@Override
public Object[] proxy(Map<String, String> params) throws Exception {
String action = params.get("action"); // 自定义标识
String url = params.getOrDefault("url", "");
if ("m3u8_proxy".equals(action)) {
// 修改 m3u8 内容(如替换 key 请求 URL、添加认证头)
String body = modifyM3u8(url);
return new Object[]{200, "application/x-mpegURL", body};
} else if ("key".equals(action)) {
// 动态获取 DRM key(从第三方服务)
byte[] key = fetchDrmKey(params.get("kid"));
return new Object[]{200, "application/octet-stream", key};
} else {
return new Object[]{404, "text/plain", "not found"};
}
}
var csp_DemoSite = {
// 启用本地代理(playerContent 中的 url 指向本地地址)
local_proxy: true,
playerContent: async function(flag, id, vipFlags) {
// 构造本地地址,让 App 把请求forwarded 给 proxy()
return JSON.stringify({
url: `http://127.0.0.1:9978/proxy?action=m3u8_proxy&url=${encodeURIComponent(id)}&key=${this.key}`,
parse: 0,
});
},
proxy: async function(params) {
var url = params.url;
var resp = await req(url, { headers: { Authorization: `Bearer ${params.key}` } });
// 修改 m3u8 内容,替换 key URI 为本地地址
var content = resp.content.replace(
/URI="(.*?)"/g,
(_, keyUrl) => `URI="http://127.0.0.1:9978/proxy?action=key&url=${encodeURIComponent(keyUrl)}"`
);
return [200, 'application/x-mpegURL', content];
},
};
常见模式与调试技巧
常见模式
// config.json 中
{
"key": "site_a", "name": "站点A", "type": 1,
"api": "csp_Universal", "jar": "https://cdn.example.com/universal.js",
"ext": "{\"url\":\"https://site-a.example.com\",\"token\":\"tok_a\"}"
},
{
"key": "site_b", "name": "站点B", "type": 1,
"api": "csp_Universal", "jar": "https://cdn.example.com/universal.js",
"ext": "{\"url\":\"https://site-b.example.com\",\"token\":\"tok_b\"}"
}
categoryContent: async function(tid, pg, filter, extend) {
// tid 格式:顶级分类用 "1",子目录用 "folder_path/xxxxx"
if (tid.startsWith('folder/')) {
return await this.listFolder(tid.slice(7));
}
// 正常分类...
},
listFolder: async function(path) {
var resp = await req(`https://api.example.com/dir?path=${path}`);
var data = JSON.parse(resp.content);
return JSON.stringify({ list: data.items.map(item => ({
vod_id: `folder/${item.path}`, // 子目录继续用 folder/ 前缀
vod_name: item.name,
vod_tag: item.isDir ? 'folder' : '', // 目录项打 folder 标记
vod_remarks: item.isDir ? '目录' : item.size,
}))});
},
// App 可以在 UI 中触发自定义 action(如"重新登录"按钮)
action: async function(act) {
if (act === 'refresh_token') {
var resp = await req(this.baseUrl + '/refresh', {
method: 'post',
body: JSON.stringify({ refresh_token: this.refreshToken }),
headers: { 'Content-Type': 'application/json' },
});
this.token = JSON.parse(resp.content).access_token;
return JSON.stringify({ msg: 'Token 刷新成功', code: 0 });
}
return JSON.stringify({ msg: 'unknown action', code: -1 });
},
调试技巧
-
使用
print()输出变量到 Logcat(Tag:QuickJS或catvod) -
在 Android Studio 用
adb logcat | grep -E "QuickJS|catvod"实时查看日志 - 先在 Node.js 中用 mock 数据测试核心逻辑,再迁移到 JS Spider
- 将每个功能写成独立函数,便于单独更换和测试
-
在 JAR 项目里写 Unit Test,用
mockwebserver模拟 API -
用
android.util.Log.d("csp_MySpider", msg)输出调试信息 -
发布到本地 HTTP 服务器(如
python -m http.server 8080),在 App 里用http://10.0.2.2:8080/spider.jar(模拟器)调试 -
Spider 构造函数不要执行网络请求,所有网络操作放在
init()或各方法中
-
不要
在
homeContent()里返回class=[](空数组),否则分类列表为空,需要至少返回一个分类 -
不要
在
detailContent()返回的 Vod 中省略vod_play_from/vod_play_url,否则详情页无法播放 -
vod_play_url中$$$分隔符两侧必须与vod_play_from中的来源数量完全对应 - JAR Spider 中切勿持有 Activity/Context 长期引用,否则会导致内存泄漏
-
JS Spider
init()是同步初始化入口,某些版本可能不支持 async,遇到问题改为同步版本
返回格式快速参考
| 方法 | 必须包含 | 可选 |
|---|---|---|
| homeContent |
class[]
(分类列表)
|
list[]
(首页推荐),
filters{}
(筛选器)
|
| homeVideoContent | list[] |
— |
| categoryContent | list[] |
pagecount
(总页数)
|
| detailContent |
list[]
(含 vod_play_from/url)
|
subs[]
(字幕),
danmaku
(弹幕)
|
| playerContent |
url
或
parse=1
|
header
,
format
,
key
(DRM),
subs[]
,
drm
|
| searchContent | list[] |
pagecount
(搜索分页版)
|
| liveContent |
URL 字符串 或 包含
url
的 JSON
|
header
(直播请求头)
|
| proxy | [statusCode, contentType, body] |
— |