EZVenera 插件制作
面向插件作者的当前实现说明。重点解释 EZVenera 现在真实支持什么、该怎么写、哪些地方容易踩坑,以及哪些 API 看起来存在但当前宿主并没有真正接通。
01插件是什么
EZVenera 的插件就是一个 JavaScript 文件,宿主会去找 class YourSource extends ComicSource,实例化后读取它的能力对象。EZVenera 尽量兼容原版 venera-configs,但只保留了简化版产品真正要用到的能力。
02安装与存储
安装后的源文件会落到应用支持目录下:
- 插件文件:
plugin_runtime/sources/*.js - 插件数据:
plugin_runtime/data/*.json - Cookie:
plugin_runtime/cookies.db
当前源管理页支持直接输入原始地址安装,也支持从源索引列表里选择安装。
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.loadInfo 和 comic.loadEp。
/** @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 请照实填写。
name = "JM 漫画"
key = "jm"
version = "1.0.3"
minAppVersion = "1.2.2"
url = "https://example.com/raw/jm.js"
06JS API
最值得依赖的是 Network、fetch、Convert、HtmlDocument、Comic、ComicDetails、ImageLoadingConfig、Image、APP。
Network / fetch
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
let raw = Convert.encodeUtf8("comic-123")
let hash = Convert.md5(raw)
let hashText = Convert.hexEncode(hash)
HtmlDocument
let res = await Network.get("https://example.com/comic/123")
let doc = new HtmlDocument(res.body)
let title = doc.querySelector("h1")?.text ?? "未知标题"
APP
if (APP.platform === "windows") {
// 按平台切换站点或请求头
}
07搜索
支持两种模型:search.load(keyword, options, page) 和 search.loadNext(keyword, options, nextToken)。两者同时存在时,宿主优先用 load。
search = {
optionList: [
{
label: "排序",
options: [
"latest-最新",
"popular-热门"
],
default: "latest"
}
],
load: async (keyword, options, page) => {
let sort = options[0] ?? "latest"
let res = await Network.get(
`https://example.com/api/search?q=${encodeURIComponent(keyword)}&sort=${sort}&page=${page}`
)
let json = JSON.parse(res.body)
return {
comics: json.items.map(item => new Comic({
id: item.id.toString(),
title: item.title,
subTitle: item.author ?? "",
cover: item.cover
})),
maxPage: json.maxPage ?? 1
}
}
}
08分类
分类页由 category 定义入口,由 categoryComics 定义实际列表加载。当前已支持静态筛选项、动态筛选项和两种排行分页模型。
category
category = {
title: "分类",
enableRankingPage: true,
parts: [
{
name: "题材",
type: "fixed",
categories: [
{
label: "热血",
target: {
page: "category",
attributes: { category: "hot_blood", param: null }
}
}
]
}
]
}
optionList / optionLoader
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
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,章节可以是平铺结构,也可以是分组结构。
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
comic = {
onImageLoad: async (url, comicId, epId) => ({
url,
headers: {
"referer": "https://example.com/",
"user-agent": "Mozilla/5.0"
}
})
}
modifyImage — 图片解扰
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 — 失败重签名
comic = {
onImageLoad: async (url, comicId, epId) => ({
url,
onLoadFailed: async () => {
let retryUrl = await this.getSignedImageUrl(comicId, epId)
return { url: retryUrl }
}
})
}
onImageLoad,最好同时实现 onThumbnailLoad。11登录与 cookie
支持账号密码登录、WebView 登录和手工 cookie 登录。账号密码成功后,宿主会自动保存账号和密码;token、session 需要自己 saveData。
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设置项
当前只支持 select、switch、input。callback 在 EZVenera 中会被忽略。
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。
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完整示例
/** @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常见坑
- 01
loadInfo和loadEp往往不独立,章节 ID、签名 URL、Referer 经常依赖详情页数据。 - 02
onImageLoad返回值最好是普通对象,不要返回宿主不认识的复杂实例。 - 03
modifyImage是脚本文本,不是直接传函数对象。 - 04需要特殊 headers 的封面,不要只写
onImageLoad,同时实现onThumbnailLoad。 - 05cookie 登录一定要同时实现
logout。 - 06
UI.*现在不要当成关键业务能力。
16调试建议
最稳的顺序:先打通 search 或 category,再做 loadInfo,再做 loadEp,最后处理 headers、cookie、解扰。
- 先确认接口返回的 JSON 是对的。
- 再确认
Comic/ComicDetails字段结构正确。 - 阅读链路出问题时,先去掉
modifyImage,确认是请求错误还是解扰错误。 - 详情页调不通时,先返回最小的
ComicDetails,再一项项补字段。
plugin_runtime.dart、plugin_source_parser.dart、plugin_js_engine.dart、plugin_image_loader.dart,再配合 assets/init.js、_template_.js 和真实源样本一起读。