EZVenera 插件制作

面向插件作者的当前实现说明。重点解释 EZVenera 现在真实支持什么、该怎么写、哪些地方容易踩坑,以及哪些 API 看起来存在但当前宿主并没有真正接通。

01插件是什么

EZVenera 的插件就是一个 JavaScript 文件,宿主会去找 class YourSource extends ComicSource,实例化后读取它的能力对象。EZVenera 尽量兼容原版 venera-configs,但只保留了简化版产品真正要用到的能力。

插件能安装,不等于插件里的所有字段都会在 EZVenera 里生效。当前宿主采取的是「支持的解析,不支持的忽略」的策略。

02安装与存储

安装后的源文件会落到应用支持目录下:

  • 插件文件:plugin_runtime/sources/*.js
  • 插件数据:plugin_runtime/data/*.json
  • Cookie:plugin_runtime/cookies.db

当前源管理页支持直接输入原始地址安装,也支持从源索引列表里选择安装。

对应实现:plugin_runtime.dart、plugin_source_repository.dart、sources_page.dart

03能力边界

已支持当前不要依赖
account.login
account.loginWithWebview
account.loginWithCookies
search.load / loadNext
category
categoryComics.load
categoryComics.optionList / optionLoader
categoryComics.ranking.*
comic.loadInfo / loadEp
comic.onImageLoad / onThumbnailLoad
comic.idMatch / link
settings (select / switch / input)
translation
explore
favorites
comment 系列
comic.starRating
comic.archive
comic.onClickTag
comic.enableTagsTranslate
comic.loadThumbnails
settings.callback
UI.* 作为主流程依赖
minAppVersion 现在会被读取,也能通过 APP.version 获取真实宿主版本,但安装阶段还没有做严格拦截,写插件时还是要自己控制兼容面。

04最小结构

最小可用插件至少要有合法的元数据、一个入口、comic.loadInfocomic.loadEp

JavaScript
/** @type {import('./_venera_.js')} */

class DemoSource extends ComicSource {
    name = "示例漫画源"
    key = "demo_source"
    version = "1.0.0"
    minAppVersion = "1.2.2"
    url = "https://example.com/demo_source.js"

    search = {
        load: async (keyword, options, page) => {
            return {
                comics: [
                    new Comic({
                        id: "demo-1",
                        title: `搜索:${keyword}`,
                        subTitle: "第一个结果",
                        cover: "https://example.com/cover.jpg"
                    })
                ],
                maxPage: 1
            }
        }
    }

    comic = {
        loadInfo: async (id) => new ComicDetails({
            title: "示例漫画",
            cover: "https://example.com/cover.jpg",
            tags: {},
            chapters: { "ep-1": "第 1 话" }
        }),

        loadEp: async (comicId, epId) => ({
            images: [
                "https://example.com/page-1.jpg",
                "https://example.com/page-2.jpg"
            ]
        })
    }
}

05元数据

name 用于显示,key 是唯一标识(只含字母、数字、下划线),url 是更新地址,minAppVersion 请照实填写。

JavaScript
name = "JM 漫画"
key = "jm"
version = "1.0.3"
minAppVersion = "1.2.2"
url = "https://example.com/raw/jm.js"

06JS API

最值得依赖的是 NetworkfetchConvertHtmlDocumentComicComicDetailsImageLoadingConfigImageAPP

Network / fetch

JavaScript
let res = await Network.get("https://example.com/api/list", {
    "user-agent": "Mozilla/5.0",
    "referer": "https://example.com/"
})

if (res.status !== 200) {
    throw `HTTP ${res.status}`
}

let json = JSON.parse(res.body)

Convert

JavaScript
let raw = Convert.encodeUtf8("comic-123")
let hash = Convert.md5(raw)
let hashText = Convert.hexEncode(hash)

HtmlDocument

JavaScript
let res = await Network.get("https://example.com/comic/123")
let doc = new HtmlDocument(res.body)
let title = doc.querySelector("h1")?.text ?? "未知标题"

APP

JavaScript
if (APP.platform === "windows") {
    // 按平台切换站点或请求头
}
UI.* 在 init.js 里声明了 showMessage、showDialog 等接口,但当前 EZVenera 宿主没有真正接通这套 bridge。不要把它当成当前版本的可靠能力。

08分类

分类页由 category 定义入口,由 categoryComics 定义实际列表加载。当前已支持静态筛选项、动态筛选项和两种排行分页模型。

category

JavaScript
category = {
    title: "分类",
    enableRankingPage: true,
    parts: [
        {
            name: "题材",
            type: "fixed",
            categories: [
                {
                    label: "热血",
                    target: {
                        page: "category",
                        attributes: { category: "hot_blood", param: null }
                    }
                }
            ]
        }
    ]
}

optionList / optionLoader

JavaScript
categoryComics = {
    optionLoader: async (category, param) => {
        if (category === "rank") {
            return [{ label: "时间", options: ["day-日榜", "week-周榜", "month-月榜"] }]
        }
        return [{ label: "排序", options: ["latest-最新", "popular-热门"] }]
    },

    load: async (category, param, options, page) => {
        // ...
    }
}

ranking.loadNext

JavaScript
categoryComics = {
    ranking: {
        options: ["day-日榜", "week-周榜"],
        loadNext: async (option, next) => {
            let url = next
                ? `https://example.com/api/ranking/next?token=${encodeURIComponent(next)}`
                : `https://example.com/api/ranking?mode=${option}`

            let res = await Network.get(url)
            let json = JSON.parse(res.body)

            return {
                comics: json.items.map(item => new Comic({
                    id: item.id.toString(),
                    title: item.title,
                    cover: item.cover
                })),
                next: json.nextToken ?? null
            }
        }
    }
}

09详情与章节

comic.loadInfo 返回 ComicDetails,章节可以是平铺结构,也可以是分组结构。

JavaScript
comic = {
    loadInfo: async (id) => {
        let res = await Network.get(`https://example.com/api/comic/${id}`)
        let data = JSON.parse(res.body)

        return new ComicDetails({
            title: data.title,
            subTitle: data.author,
            cover: data.cover,
            description: data.description,
            tags: {
                author: [data.author],
                genre: data.tags ?? []
            },
            chapters: Object.fromEntries(
                data.chapters.map(ch => [ch.id.toString(), ch.title])
            )
        })
    },

    loadEp: async (comicId, epId) => {
        let res = await Network.get(
            `https://example.com/api/comic/${comicId}/episode/${epId}`
        )
        let json = JSON.parse(res.body)
        return { images: json.images.map(item => item.url) }
    }
}

10图片链路

只有当图片请求不是「直接 GET 一张公开 URL」时,才需要写 onImageLoad。常见用途是补 headers、Referer、cookie、响应转换、解扰、失败重签名。

普通 headers

JavaScript
comic = {
    onImageLoad: async (url, comicId, epId) => ({
        url,
        headers: {
            "referer": "https://example.com/",
            "user-agent": "Mozilla/5.0"
        }
    })
}

modifyImage — 图片解扰

JavaScript
comic = {
    onImageLoad: async (url, comicId, epId) => ({
        url,
        modifyImage: `
            let modifyImage = (image) => {
                let topHalf = image.copyRange(0, 0, image.width, Math.floor(image.height / 2))
                let bottomHalf = image.copyRange(
                    0, Math.floor(image.height / 2),
                    image.width, image.height - Math.floor(image.height / 2)
                )
                let result = Image.empty(image.width, image.height)
                result.fillImageAt(0, 0, bottomHalf)
                result.fillImageAt(0, bottomHalf.height, topHalf)
                return result
            }
        `
    })
}

onLoadFailed — 失败重签名

JavaScript
comic = {
    onImageLoad: async (url, comicId, epId) => ({
        url,
        onLoadFailed: async () => {
            let retryUrl = await this.getSignedImageUrl(comicId, epId)
            return { url: retryUrl }
        }
    })
}
远程封面也会走缩略图链路。需要特殊 Referer 或 cookie 的源,不要只实现 onImageLoad,最好同时实现 onThumbnailLoad

11登录与 cookie

支持账号密码登录、WebView 登录和手工 cookie 登录。账号密码成功后,宿主会自动保存账号和密码;token、session 需要自己 saveData

JavaScript
account = {
    login: async (account, pwd) => {
        let res = await Network.post(
            "https://example.com/api/login",
            { "content-type": "application/json" },
            JSON.stringify({ account, password: pwd })
        )

        let json = JSON.parse(res.body)
        if (json.code !== 200) throw json.message ?? "登录失败"

        this.saveData("token", json.token)
        return "ok"
    },

    logout: () => {
        this.deleteData("token")
        Network.deleteCookies("https://example.com")
    }
}

12设置项

当前只支持 selectswitchinputcallback 在 EZVenera 中会被忽略。

JavaScript
settings = {
    domain: {
        title: "站点域名",
        type: "select",
        options: [
            { value: "https://a.example.com", text: "主站 A" },
            { value: "https://b.example.com", text: "主站 B" }
        ],
        default: "https://a.example.com"
    },
    useMobileApi: {
        title: "使用移动接口",
        type: "switch",
        default: true
    },
    customToken: {
        title: "自定义令牌",
        type: "input",
        default: ""
    }
}

let domain = this.loadSetting("domain")
let useMobileApi = this.loadSetting("useMobileApi")

13其他字段

translation 用于翻译源自己的字符串;comic.idMatch 用于识别用户输入;comic.link 把外部链接转换成漫画 ID。

JavaScript
translation = {
    "zh_CN": { "Latest": "最新", "Popular": "热门" }
}

comic = {
    idMatch: "^(\\d+|demo-\\d+)$",
    link: {
        domains: ["example.com"],
        linkToId: (url) => {
            let match = url.match(/comic\\/(\\d+)/)
            return match ? match[1] : null
        }
    }
}

14完整示例

JavaScript
/** @type {import('./_venera_.js')} */

class SampleSource extends ComicSource {
    name = "Sample Source"
    key = "sample_source"
    version = "1.0.0"
    minAppVersion = "1.2.2"
    url = "https://example.com/sample_source.js"

    settings = {
        baseUrl: {
            title: "站点地址",
            type: "select",
            options: [
                { value: "https://api.example.com", text: "主站" },
                { value: "https://backup.example.com", text: "备用站" }
            ],
            default: "https://api.example.com"
        }
    }

    get baseUrl() {
        return this.loadSetting("baseUrl") || "https://api.example.com"
    }

    search = {
        load: async (keyword, options, page) => {
            let res = await Network.get(
                `${this.baseUrl}/search?q=${encodeURIComponent(keyword)}&page=${page}`
            )
            let json = JSON.parse(res.body)
            return {
                comics: json.items.map(item => this.parseComic(item)),
                maxPage: json.maxPage ?? 1
            }
        }
    }

    category = {
        title: "分类",
        parts: [
            {
                name: "题材",
                type: "fixed",
                categories: [
                    {
                        label: "动作",
                        target: {
                            page: "category",
                            attributes: { category: "action", param: null }
                        }
                    }
                ]
            }
        ]
    }

    categoryComics = {
        optionList: [
            { label: "排序", options: ["latest-最新", "popular-热门"] }
        ],
        load: async (category, param, options, page) => {
            let res = await Network.get(
                `${this.baseUrl}/category/${category}?page=${page}`
            )
            let json = JSON.parse(res.body)
            return {
                comics: json.items.map(item => this.parseComic(item)),
                maxPage: json.maxPage ?? 1
            }
        }
    }

    comic = {
        loadInfo: async (id) => {
            let res = await Network.get(`${this.baseUrl}/comic/${id}`)
            let data = JSON.parse(res.body)
            return new ComicDetails({
                title: data.title,
                subTitle: data.author,
                cover: data.cover,
                description: data.description,
                tags: { author: [data.author], genre: data.tags ?? [] },
                chapters: Object.fromEntries(
                    data.chapters.map(ch => [ch.id.toString(), ch.title])
                ),
                url: data.url
            })
        },

        loadEp: async (comicId, epId) => {
            let res = await Network.get(`${this.baseUrl}/comic/${comicId}/episode/${epId}`)
            let data = JSON.parse(res.body)
            return { images: data.images }
        },

        onImageLoad: async (url, comicId, epId) => ({
            url,
            headers: {
                "referer": `${this.baseUrl}/comic/${comicId}`,
                "user-agent": "Mozilla/5.0"
            }
        })
    }

    parseComic(item) {
        return new Comic({
            id: item.id.toString(),
            title: item.title,
            subTitle: item.author ?? "",
            cover: item.cover,
            tags: item.tags ?? []
        })
    }
}

15常见坑

  • 01loadInfoloadEp 往往不独立,章节 ID、签名 URL、Referer 经常依赖详情页数据。
  • 02onImageLoad 返回值最好是普通对象,不要返回宿主不认识的复杂实例。
  • 03modifyImage 是脚本文本,不是直接传函数对象。
  • 04需要特殊 headers 的封面,不要只写 onImageLoad,同时实现 onThumbnailLoad
  • 05cookie 登录一定要同时实现 logout
  • 06UI.* 现在不要当成关键业务能力。

16调试建议

最稳的顺序:先打通 search 或 category,再做 loadInfo,再做 loadEp,最后处理 headers、cookie、解扰。

  1. 先确认接口返回的 JSON 是对的。
  2. 再确认 Comic / ComicDetails 字段结构正确。
  3. 阅读链路出问题时,先去掉 modifyImage,确认是请求错误还是解扰错误。
  4. 详情页调不通时,先返回最小的 ComicDetails,再一项项补字段。
对照源码时,优先看 plugin_runtime.dartplugin_source_parser.dartplugin_js_engine.dartplugin_image_loader.dart,再配合 assets/init.js_template_.js 和真实源样本一起读。