跳转到内容

V8 脚本

本页面向插件作者,讲解怎样用 V8 后端编写爬虫脚本:入口签名、配置怎么传入、宿主能力 Kabegame.*、运行时提供的 Web 平台全局,以及下载、分页与元数据。全部方法的完整签名见 Kabegame API 字典。

打包工具库 @kabegame/plugin-sdk 是另一回事——它是打包进插件产物的纯 JS 工具函数(resolveUrl、sleep、md5 等),与本页的运行时注入能力分开,见 plugin-sdk 工具库。

插件包结构与打包见 插件格式;两个后端的整体对比见 爬虫后端选择。


入口:crawl(common, custom)

V8 插件必须导出一个 crawl 函数。它可以是同步的,也可以返回 Promise;副作用全部通过 Kabegame.* 产生。

export async function crawl(common, custom) {
const baseUrl = common.base_url ?? "https://example.com";
// custom 是用户在采集对话框填的 kbConfig 值
const page = custom.start ?? 1;
await Kabegame.to(`${baseUrl}/posts?page=${page}`);
// ...
}

common —— 宿主提供的公共配置

当前只有一个键:

字段类型说明
common.base_urlstring | null来自 package.json 的 kbBaseUrl(trim 后;未定义为 null)。

custom —— 用户填的表单值

custom 是 kbConfig 所有变量的合并结果(默认值叠加用户输入),形如 { [key]: value }。每个值的类型对应其 kbConfig 声明(int/float → 数字,string → 字符串,boolean → 布尔,options → 选中项的 variable,list → 字符串数组,checkbox → { variable: bool } 对象……)。变量类型详见 插件清单字段 · kbConfig。

export async function crawl(_common, custom) {
// 不需要 base_url 时用 _common 忽略第一参
const mode = custom.crawl_mode; // options → "all" | "tags" | ...
const tags = custom.mode_tag_value ?? []; // list → string[]
const includeR18 = custom.r18 === true; // boolean
}

运行时全局

除了 Kabegame,V8 运行时还提供一批标准 Web 平台全局,直接使用即可,无需 import:

  • URL / URLSearchParams
  • TextEncoder / TextDecoder
  • atob / btoa
  • crypto / crypto.subtle
  • fetch / Request / Response / Headers
  • setTimeout / setInterval / clearTimeout / clearInterval
  • DOMParser(把 HTML 字符串解析成 DOM,用于选择器查询)
  • console.*

用 fetch 请求 JSON

旧的 @kabegame/plugin-sdk/host 与 fetchJson 已移除。请求 JSON 用标准 fetch:

const data = await (await fetch(url)).json();

fetch 会合并当前任务通过 Kabegame.setHeader() 设置的请求头,但不会按当前页面自动解析相对 URL。需要相对路径时显式写:

const abs = new URL(relative, await Kabegame.currentUrl()).href;

Kabegame.* 宿主能力

宿主对象 Kabegame 在每个 V8 插件里全局可用(不从 SDK 导入)。下面按用途分组,完整签名与返回值见 Kabegame API 字典。

页面栈与导航

运行时维护一个页面栈:

  • await Kabegame.to(url) — 访问一个页面并推入栈,返回最终 URL(相对 URL 相对当前页解析)。
  • await Kabegame.back() — 弹出栈顶页面。
  • await Kabegame.currentUrl() / currentHtml() / currentDocument() / currentHeaders() — 读栈顶页面的 URL / 原始 HTML / 解析后的 Document / 响应头。
await Kabegame.to(postUrl);
const doc = await Kabegame.currentDocument();
const href = doc?.querySelector(".icon-download")?.getAttribute("href");
if (href) {
await Kabegame.downloadImage(new URL(href, await Kabegame.currentUrl()).href);
}
await Kabegame.back();

下载图片 / 视频

await Kabegame.downloadImage(imageUrl, {
name: "artist / character", // 展示名 / 文件名
url: postUrl, // 来源 URL
metadata: { schema: 1, title: "Post title", tags: ["wallpaper"] },
searchTexts: ["Post title", "my-plugin_tags_wallpaper"],
});
  • downloadImage 把资源加入下载队列。
  • opts.metadata 是任意 JSON,写入 metadata 行,用于详情页展示(配合 templates/description.ejs)。存储时会由应用自动盖上运行插件的版本号;请在 metadata 里自带一个 schema 标记供迁移脚本判断。
  • 多张图共享一行 metadata,或需要在解析出图片 URL 前先写 metadata 时,用 createImageMetadata 拿到 metadata_id 再传给 downloadImage(url, { metadata_id })。

元数据搜索片段

downloadImage(url, { metadata, searchTexts }) 与 createImageMetadata(value, { searchTexts }) 可以为新 metadata 提供精选搜索片段。缺省时应用按键名与字符串叶子展开 JSON;空数组表示不参与元数据搜索。downloadImage 同时传 metadata_id 时不会新建 metadata,因此会忽略 searchTexts 并写警告。

  • 尽量覆盖 description.ejs 展示给用户的原文,例如标签显示名,让搜索符合直觉。
  • 可以增加 <插件>_<字段>_<值> 形式的前缀条目,支持 my-plugin_rating_safe 这类按字段筛选。
  • 不要收录键名、页面看不到的链接,以及会过期或没有信息量的文字。
  • 每项最多 1024 字符、最多 1000 项;片段会清理换行与首尾空白并去重,无效项记录警告后跳过。

图片标签

downloadImage 可带 labels,图片入库成功后会出现在应用的「标签」分区里(标签就是一种画册),用户可按标签浏览和搜索:

await Kabegame.downloadImage(imageUrl, {
url: postUrl,
labels: [
{ key: "hatsune_miku", category: "pixiv/character", name: "初音ミク" },
{ key: "vocaloid" }, // category 缺省为插件 id → 挂在「<插件 id>」下
],
});
  • key 必填,只能是 [a-zA-Z0-9_-]、英文括号 () 和空格(空格不能在首尾或连续),不超过 64 字节;同一级下不区分大小写唯一。category 各段同规则。
  • category 是用 / 分隔的上级 key 链(每段同样的字符集),缺省为插件 id;缺失的各段会自动建成标签目录,末端 key 建成可挂图的标签。
  • name 只在首次创建该标签时作为显示名,缺省等于 key;已存在的标签永远不会被插件改名(用户可能改过)。
  • 不合规的标签,或 category 段与已有标签同名、末端与已有标签目录同名时,会写一条任务日志警告并只跳过该标签;同批其它标签和图片下载不受影响。输入不会被自动修正;labels 不是数组时整次调用报参数错误。
  • 标签只增不减。命中去重时,只有开启「去重时更新元数据」才会给已有图片补标签。
  • 只挂叶子标签:上级由 category 隐含,搜索时勾选「包含子标签」即可按目录搜。
Kabegame.setHeader("Cookie", "sitelang=zh-cn"); // 影响后续 to() 与 fetch
Kabegame.delHeader("Cookie");
// 让宿主把用户在「畅游」里登录该站的 cookie 注入本任务请求头:
if (!Kabegame.requireCookie()) { // 省略 host 时从 base_url 推断
Kabegame.warn("请先在畅游里登录该站点");
}

Kabegame.requireCookie(host?) 4.4.0 不会把 cookie 明文暴露给插件,返回值只表示注入是否成功。需要这个能力时把 engines.kabegame 抬到 >=4.4.0。配合 kbLabels 的 auth.needCookie 标签向用户提示。

进度与日志

Kabegame.addProgress(100 / imageUrls.length); // 累加进度,宿主会 clamp
Kabegame.warn("排行榜实际获取数量少于请求上限"); // warn 级任务日志
console.log("普通日志"); // 走 console

插件私有数据

Kabegame.pluginData() / setPluginData(map) 读写按插件 ID 隔离的持久 JSON 对象,适合缓存游标、token、TTL 状态等:

const data = Kabegame.pluginData<{ cursor?: string }>();
if (data.cursor) await Kabegame.to(data.cursor);
// ...
Kabegame.setPluginData({ cursor: nextPageUrl });

私有虚拟文件系统 Kabegame.fs

每个 V8 爬虫任务有一个私有虚拟文件系统,暴露完整的 deno_fs 接口(含同步方法)。路径从 Kabegame.fs.getRoot()(V8 下同步返回)开始,句柄随任务结束失效,不要持久化虚拟路径。

const root = Kabegame.fs.getRoot();
await Kabegame.fs.writeTextFile(`${root}/data/cursor.txt`, "abc");

虚拟路径媒体工具 Kabegame.ffmpeg

Kabegame.ffmpeg 4.4.0 提供两个高层媒体函数:

  • await Kabegame.ffmpeg.muxStreams(inputs, output):对至少两个输入流做 stream-copy 合流。
  • await Kabegame.ffmpeg.probe(path):返回 { isVideo, mimeType, width, height, browserSafe },无法识别或非视频时返回 null。

输入与输出都必须是当前任务 VFS 的虚拟绝对路径。插件使用这些 API 时须声明 engines.kabegame >= 4.4.0;Android 调用会抛出可读的不支持错误。

下面示例把已获取的音视频分片写入 VFS、合流,再用同一个虚拟路径下载 API 提交入库。文件句柄的 write() 可能短写,因此循环到整段写完:

async function writeParts(path: string, parts: ArrayBuffer[]) {
const file = await Kabegame.fs.create(path);
try {
for (const part of parts) {
const bytes = new Uint8Array(part);
let offset = 0;
while (offset < bytes.byteLength) {
const written = await file.write(bytes.subarray(offset));
if (written <= 0) throw new Error("写入媒体分片失败");
offset += written;
}
}
} finally {
file.close();
}
}
const root = Kabegame.fs.getRoot();
const workDir = `${root}/tmp/media-example`;
const videoPath = `${workDir}/video.mp4`;
const audioPath = `${workDir}/audio.mp4`;
const outputPath = `${workDir}/output.mp4`;
await Kabegame.fs.mkdir(workDir, { recursive: true });
try {
await writeParts(videoPath, videoFragments);
await writeParts(audioPath, audioFragments);
await Kabegame.ffmpeg.muxStreams([videoPath, audioPath], outputPath);
await Kabegame.downloadImage(outputPath, { name: "合流视频", url: postUrl });
} finally {
await Kabegame.fs.remove(workDir, { recursive: true });
}

提前退出与取消

  • crawl 正常返回即结束;抛异常则任务失败并记录错误。
  • 需要提前结束就直接 return。
  • 任务被用户取消后,下一次 Kabegame.* 调用会抛错;用 await 让异常自然向外传播即可干净中止。

元数据迁移

插件升级后,历史图片的 metadata 结构可能变化。提供 metadata_migrations/migrate.js 并在 package.json 声明 kbMetadataMigration,即可把旧行就地迁移到新结构。

契约:一个 ES module,export function migrate(metadata) 的入参与返回值都是 JSON 字符串,运行在裸 V8(无 import、无 Kabegame.*、无宿主 API,只有原生 JSON/String/RegExp 等)。脚本应按 metadata 里的 schema 自检、幂等、一步到位。

metadata_migrations/migrate.js
export function migrate(json) {
const m = JSON.parse(json);
if (m.schema === 1) return json; // 已是最新结构,原样返回
// ...把旧结构升级到 schema 1...
return JSON.stringify({ schema: 1, title: m.title });
}

迁移后的行按 (plugin_id, plugin_version, data) 去重;迁移成功会先发出带最终 metadataId / pluginVersion 的 image-changed 字段快照,再发出作用域为该插件的 images-change(change) 对账列表。

每一行无论迁移成功与否都只处理一次:失败时保留原数据并同样盖上当前插件版本,不会在每次启动时反复重试;修好脚本后需要再发一个新版本。

给历史图片补标签

同一个脚本可以再导出 provideLabels(json)(允许 async),返回与 downloadImage 的 labels 相同结构的数组。应用只在 migrate 成功后,用迁移后的 metadata 调用它,并把标签挂到引用这行 metadata 的所有图片上。

export function provideLabels(json) {
const m = JSON.parse(json);
return (m.characters ?? []).map((c) => ({ key: c.slug, category: "my-plugin/character", name: c.name }));
}

给历史图片提供搜索片段

脚本还可导出 provideSearchTextList(json)(允许 async),返回字符串数组。调用失败或未导出时回退到默认 JSON 展开;显式返回空数组表示不参与元数据搜索。编写原则与爬取时的 searchTexts 相同:覆盖模板展示原文,可增加字段前缀,不收录键名、不可见链接或过期文字。

export function provideSearchTextList(json) {
const m = JSON.parse(json);
return [m.title, ...(m.tags ?? []).map((tag) => `my-plugin_tags_${tag}`)].filter(Boolean);
}

migrate、provideLabels 与 provideSearchTextList 至少导出一个;没有 migrate 时,两个 provider 都按原数据调用。


延伸阅读