Spider 编写 / Spider Development Guide

FongMi /TV Spider
编写完全指南

从零到一编写 JAR(Java/Kotlin)、JS(QuickJS)、Python(Chaquopy)三种 Spider,涵盖每个接口方法的参数格式、返回结构与真实代码示例。

Based on FongMi/TV · 2026
目录 · Table of Contents
01 · Spider 类型总览 02 · Spider 接口方法完整参考 03 · JAR Spider(Java / Kotlin) 04 · JS Spider(QuickJS) 05 · Python Spider(Chaquopy) 06 · 工具函数 API(req / pd / pdfh / pdfa) 07 · Live Spider(liveContent) 08 · proxy() 本地代理方法 09 · 常见模式与调试技巧
01

Spider 类型总览

FongMi/TV 支持 3 种可扩展 Spider 运行时,以及 1 种内置(无需代码)方案:

type=3 · JAR
JAR Spider

Java 或 Kotlin 类继承 Spider 基类,打包为含 classes.dex 的 JAR 文件。性能最强,可调用完整 Android SDK 与第三方 Java 库。推荐用于高性能或签名需求。

构建方式:Android Gradle 编译输出 release JAR/AAR,通过 DexClassLoader 动态加载。

type=1 · JS
JS Spider(QuickJS)

JavaScript 文件,运行于嵌入式 QuickJS 引擎(ES2020+)。支持 req()pd()CryptoJScheerio 等内置工具。

开发效率最高,适合大多数爬虫场景,免编译即可更新。

type=2 · Python
Python Spider(Chaquopy)

Python 3 脚本,运行于 Chaquopy 环境。可使用 requestsBeautifulSouplxml 等标准 Python 库。

适合已有 Python 爬虫代码的快速移植;需要 App 自带 Chaquopy 支持。

type=0 · 内置
内置 API(无 Spider)

直接对接苹果 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 代码移植
02

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 (交解析器),可含 headerformatkey (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 被销毁前 无(释放资源)
ℹ️ siteKey 字段
Spider 基类有一个 public String siteKey 字段,由加载器在实例化后自动写入当前站点的 key 值。你可以在 Spider 内部通过 this.siteKey (Java)或 siteKey (JS)读取,用于多站点共享一个 Spider 类时区分站点。
03

JAR Spider(Java / Kotlin)

项目配置

build.gradle 配置

Groovy build.gradle(Library 模块)
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)

Java csp_DemoSite.java — 基础点播 Spider
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));
    }
}
04

JS Spider(QuickJS)

JS Spider 是最流行的编写方式。文件为标准 JavaScript,通过暴露特定变量/函数让 App 识别 Spider 类。QuickJS 支持 ES2020+ 语法,包括 async/await、可选链、nullish coalescing 等。

基本结构

JavaScript spider.js — 最小可运行 JS Spider
// ── 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)

JavaScript HTML 爬虫:从页面直接解析数据
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 处理加密

JavaScript AES 解密播放地址
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 支持 methodheadersbodytimeout
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() 参数详解

JavaScript 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' },
});
05

Python Spider(Chaquopy)

Python Spider 运行于 Chaquopy 提供的 Python 3.8+ 环境。类继承关系与 JAR 相同,只是语言是 Python,且通过 Chaquopy 的 Android 桥接调用 Java 对象。

⚠️ 运行时要求
Python Spider 需要 App 的 build.gradle 中集成了 Chaquopy 插件,并且设备上安装了完整的 Python 运行环境。部分精简版 App 不支持 Python Spider。

Python Spider 模板

Python csp_DemoSite.py — 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}
06

工具函数 API(req / pd / pdfh / pdfa / pdfl)

以下内置工具函数可在 JS Spider 中直接调用(无需 import),也可通过 JAR Spider 中的对应 Java 工具类实现类似功能。

pdfh — 提取单个值

JavaScript 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 — 提取节点列表

JavaScript pdfa() 返回 HTML 数组
// 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

JavaScript pd() 自动拼接 base 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'
07

Live Spider(liveContent 方法)

liveContent(url) 用于在直播播放前对 URL 进行二次处理(如解密、获取临时鉴权 URL、动态替换地址等)。

Java liveContent() — 通过 API 获取临时直播 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();
}
JavaScript liveContent() — JS 版本
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
在直播配置(lives[])中用 spider 字段指向同一 JAR/JS 文件,用 playerContent 字段指向负责播放处理的 Spider 类名即可。当直播频道带有 playUrl 时,liveContent 会被调用。
08

proxy() 本地代理方法

proxy() 让 Spider 在 App 内置的本地 HTTP 服务器上注册处理器,用于绕过某些服务跨域限制、动态修改请求/响应,或提供本地缓存服务。

Java proxy() — 本地代理请求处理
// 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"};
    }
}
JavaScript JS Spider 启用本地代理
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];
    },
};
09

常见模式与调试技巧

常见模式

JavaScript 模式 1:ext 字段传入不同域名,实现一套代码多站点复用
// 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\"}"
}
JavaScript 模式 2:用 vod_tag="folder" 实现多级目录浏览
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,
    }))});
},
JavaScript 模式 3:action() 方法实现动态刷新 Token
// 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 });
},

调试技巧

✅ JS Spider 调试
  • 使用 print() 输出变量到 Logcat(Tag: QuickJScatvod
  • 在 Android Studio 用 adb logcat | grep -E "QuickJS|catvod" 实时查看日志
  • 先在 Node.js 中用 mock 数据测试核心逻辑,再迁移到 JS Spider
  • 将每个功能写成独立函数,便于单独更换和测试
✅ JAR 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 urlparse=1 headerformatkey (DRM), subs[]drm
searchContent list[] pagecount (搜索分页版)
liveContent URL 字符串 或 包含 url 的 JSON header (直播请求头)
proxy [statusCode, contentType, body]