@shinsou/plugin 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,53 @@
1
1
  {
2
2
  "name": "@shinsou/plugin",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Shinsou X 套件作者工具:金鑰、建置、檢查、測試、開發伺服器與簽章發佈。",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/aluo96078/shinsou-plugin-cli.git"
9
+ },
10
+ "homepage": "https://github.com/aluo96078/shinsou-plugin-cli#readme",
11
+ "bugs": {
12
+ "url": "https://github.com/aluo96078/shinsou-plugin-cli/issues"
13
+ },
14
+ "publishConfig": {
15
+ "access": "public"
16
+ },
17
+ "type": "module",
18
+ "bin": {
19
+ "shinsou-plugin": "dist/cli.js"
20
+ },
21
+ "files": [
22
+ "dist",
23
+ "templates",
24
+ "data",
25
+ "README.md"
26
+ ],
27
+ "engines": {
28
+ "node": ">=20"
29
+ },
30
+ "scripts": {
31
+ "build": "node scripts/build.mjs",
32
+ "prepare": "node scripts/build.mjs",
33
+ "typecheck": "tsc --noEmit",
34
+ "test": "node --import tsx --test test/*.test.ts",
35
+ "prepublishOnly": "npm run typecheck && npm test && npm run build"
36
+ },
37
+ "dependencies": {
38
+ "@noble/ciphers": "^2.4.0",
39
+ "@noble/ed25519": "^3.2.0",
40
+ "@noble/hashes": "^2.4.0",
41
+ "esbuild": "^0.28.2",
42
+ "node-html-parser": "^9.0.7",
43
+ "qrcode-terminal": "^0.12.0",
44
+ "quickjs-emscripten": "^0.32.0"
45
+ },
46
+ "devDependencies": {
47
+ "@types/node": "^22.20.5",
48
+ "@types/qrcode-terminal": "^0.12.2",
49
+ "cbor-x": "^1.6.9",
50
+ "tsx": "^4.23.15",
51
+ "typescript": "~5.9.0"
52
+ }
53
+ }
@@ -0,0 +1,50 @@
1
+ // Async (JSON API) template: hooks may return Promises and use the non-blocking bridge.net.fetch.
2
+ // Modules are allowed: build bundles imports into one script.
3
+ import { toManga } from "./mapping.js";
4
+
5
+ var API = "https://api.example.com";
6
+
7
+ async function getJson(path) {
8
+ var res = await bridge.net.fetch(API + path, { method: "GET", headers: { Accept: "application/json" } });
9
+ if (res.status !== 200) throw new Error("SHINSOU_SOURCE_HTTP_UNAVAILABLE");
10
+ return JSON.parse(res.body);
11
+ }
12
+
13
+ var source = {
14
+ baseUrl: "https://example.com",
15
+ supportsLatest: false,
16
+
17
+ getPopularManga: async function (page) {
18
+ var data = await getJson("/titles?sort=popular&page=" + page);
19
+ return new MangasPage(data.items.map(toManga), data.hasNext);
20
+ },
21
+
22
+ getSearchManga: async function (page, query) {
23
+ var data = await getJson("/titles?q=" + encodeURIComponent(query) + "&page=" + page);
24
+ return new MangasPage(data.items.map(toManga), data.hasNext);
25
+ },
26
+
27
+ getMangaDetails: async function (manga) {
28
+ var data = await getJson("/titles/" + encodeURIComponent(manga.url));
29
+ var m = toManga(data);
30
+ m.description = data.description || null;
31
+ m.initialized = true;
32
+ return m;
33
+ },
34
+
35
+ getChapterList: async function (manga) {
36
+ var data = await getJson("/titles/" + encodeURIComponent(manga.url) + "/chapters");
37
+ return data.items.map(function (c) {
38
+ var ch = SChapter.create();
39
+ ch.url = c.id;
40
+ ch.name = c.title;
41
+ ch.chapterNumber = c.number;
42
+ return ch;
43
+ });
44
+ },
45
+
46
+ getPageList: async function (chapter) {
47
+ var data = await getJson("/chapters/" + encodeURIComponent(chapter.url) + "/pages");
48
+ return data.pages.map(function (url, i) { return new Page(i, "", url); });
49
+ }
50
+ };
@@ -0,0 +1,7 @@
1
+ export function toManga(item) {
2
+ var m = SManga.create();
3
+ m.url = String(item.id);
4
+ m.title = item.title;
5
+ m.thumbnailUrl = item.cover || null;
6
+ return m;
7
+ }
@@ -0,0 +1,4 @@
1
+ [
2
+ { "name": "popular-page-1", "hook": "getPopularManga", "args": [1], "expect": { "path": "mangas", "minItems": 1, "matches": { "0.title": "Async Sample", "0.url": "42" } } },
3
+ { "name": "pages", "hook": "getPageList", "args": [{ "url": "c1" }], "expect": { "minItems": 2 } }
4
+ ]
@@ -0,0 +1,10 @@
1
+ [
2
+ {
3
+ "request": { "method": "GET", "url": "https://api.example.com/titles?sort=popular&page=1" },
4
+ "response": { "status": 200, "headers": { "content-type": "application/json" }, "body": "{\"items\":[{\"id\":42,\"title\":\"Async Sample\",\"cover\":\"https://cdn.example.com/42.jpg\"}],\"hasNext\":false}" }
5
+ },
6
+ {
7
+ "request": { "method": "GET", "url": "https://api.example.com/chapters/c1/pages" },
8
+ "response": { "status": 200, "body": "{\"pages\":[\"https://cdn.example.com/c1/1.jpg\",\"https://cdn.example.com/c1/2.jpg\"]}" }
9
+ }
10
+ ]
@@ -0,0 +1,42 @@
1
+ # Re-signs publisher-revocations.json before it expires (default lifetime: 30 days).
2
+ # The app refuses new installs and updates from a publisher whose revocations have expired
3
+ # (ADR-0002 D8), so this must keep running.
4
+ #
5
+ # Secrets:
6
+ # SHINSOU_PLUGIN_KEY the full JSON of ~/.config/shinsou-plugin/keys/<name>.json
7
+ # SHINSOU_PLUGIN_PASSPHRASE its passphrase (only if the key is encrypted)
8
+ name: Refresh Shinsou revocations
9
+
10
+ on:
11
+ schedule:
12
+ - cron: "17 3 * * 1" # every Monday 03:17 UTC
13
+ workflow_dispatch:
14
+
15
+ permissions:
16
+ contents: write
17
+
18
+ concurrency:
19
+ group: shinsou-sign
20
+ cancel-in-progress: false
21
+
22
+ jobs:
23
+ refresh:
24
+ runs-on: ubuntu-latest
25
+ steps:
26
+ - uses: actions/checkout@v4
27
+ - uses: actions/setup-node@v4
28
+ with:
29
+ node-version: 22
30
+ - run: npm ci
31
+ - name: Re-sign revocations
32
+ env:
33
+ SHINSOU_PLUGIN_KEY: ${{ secrets.SHINSOU_PLUGIN_KEY }}
34
+ SHINSOU_PLUGIN_PASSPHRASE: ${{ secrets.SHINSOU_PLUGIN_PASSPHRASE }}
35
+ run: npx shinsou-plugin refresh --key "$(node -p 'JSON.parse(process.env.SHINSOU_PLUGIN_KEY).name')"
36
+ - name: Commit the new signature and sequence
37
+ run: |
38
+ git config user.name "shinsou-refresh"
39
+ git config user.email "shinsou-refresh@users.noreply.github.com"
40
+ git add .shinsou/state.json public
41
+ git diff --cached --quiet || git commit -m "chore: refresh publisher revocations"
42
+ git push
@@ -0,0 +1,3 @@
1
+ node_modules/
2
+ build/
3
+ # public/ holds the signed repository you publish; commit it if you publish from this repo.
@@ -0,0 +1,75 @@
1
+ // Manga source template. `var source` must stay a top-level declaration: the host looks it up by name.
2
+ // Network access is limited to the exact origins in shinsou.plugin.json (sources[].requestOrigins).
3
+
4
+ var BASE = "https://example.com";
5
+
6
+ function absolute(url) {
7
+ return url.indexOf("http") === 0 ? url : BASE + url;
8
+ }
9
+
10
+ var source = {
11
+ baseUrl: BASE,
12
+ supportsLatest: true,
13
+
14
+ getPopularManga: function (page) {
15
+ var html = bridge.httpGet(BASE + "/popular?page=" + page);
16
+ if (!html) return new MangasPage([], false);
17
+ var doc = Jsoup.parse(html, BASE);
18
+ var mangas = doc.select(".manga-item").map(function (el) {
19
+ var m = SManga.create();
20
+ m.url = el.selectFirst("a").attr("href");
21
+ m.title = el.selectFirst(".title").text();
22
+ m.thumbnailUrl = el.selectFirst("img").absUrl("src");
23
+ return m;
24
+ });
25
+ return new MangasPage(mangas, doc.selectFirst("a.next") !== null);
26
+ },
27
+
28
+ getLatestUpdates: function (page) {
29
+ return this.getPopularManga(page);
30
+ },
31
+
32
+ getSearchManga: function (page, query, filters) {
33
+ var html = bridge.httpGet(BASE + "/search?q=" + encodeURIComponent(query) + "&page=" + page);
34
+ if (!html) return new MangasPage([], false);
35
+ var doc = Jsoup.parse(html, BASE);
36
+ var mangas = doc.select(".manga-item").map(function (el) {
37
+ var m = SManga.create();
38
+ m.url = el.selectFirst("a").attr("href");
39
+ m.title = el.selectFirst(".title").text();
40
+ return m;
41
+ });
42
+ return new MangasPage(mangas, false);
43
+ },
44
+
45
+ getMangaDetails: function (manga) {
46
+ var doc = Jsoup.parse(bridge.httpGet(absolute(manga.url)) || "", BASE);
47
+ manga.description = doc.select(".description").text();
48
+ manga.author = doc.select(".author").text();
49
+ manga.status = SManga.ONGOING;
50
+ manga.initialized = true;
51
+ return manga;
52
+ },
53
+
54
+ getChapterList: function (manga) {
55
+ var doc = Jsoup.parse(bridge.httpGet(absolute(manga.url)) || "", BASE);
56
+ return doc.select(".chapter-list a").map(function (el) {
57
+ var ch = SChapter.create();
58
+ ch.url = el.attr("href");
59
+ ch.name = el.text();
60
+ return ch;
61
+ });
62
+ },
63
+
64
+ getPageList: function (chapter) {
65
+ var doc = Jsoup.parse(bridge.httpGet(absolute(chapter.url)) || "", BASE);
66
+ return doc.select(".page-img img").map(function (img, i) {
67
+ var url = img.absUrl("data-src") || img.absUrl("src");
68
+ return new Page(i, "", url);
69
+ });
70
+ },
71
+
72
+ getFilterList: function () {
73
+ return [];
74
+ }
75
+ };
@@ -0,0 +1,10 @@
1
+ [
2
+ { "name": "popular-page-1", "hook": "getPopularManga", "args": [1], "expect": { "path": "mangas", "minItems": 2, "snapshot": true } },
3
+ {
4
+ "name": "chapters",
5
+ "hook": "getChapterList",
6
+ "args": [{ "url": "/manga/1", "title": "Sample" }],
7
+ "expect": { "minItems": 2, "matches": { "0": { "url": "/manga/1/chapter/1", "name": "Chapter 1", "scanlator": null, "dateUpload": 0, "chapterNumber": -1 } } }
8
+ },
9
+ { "name": "pages", "hook": "getPageList", "args": [{ "url": "/manga/1/chapter/1" }], "expect": { "minItems": 2, "matches": { "0.imageUrl": "https://example.com/img/1.jpg" } } }
10
+ ]
@@ -0,0 +1,18 @@
1
+ [
2
+ {
3
+ "request": { "method": "GET", "url": "https://example.com/popular?page=1" },
4
+ "response": {
5
+ "status": 200,
6
+ "headers": { "content-type": "text/html; charset=utf-8" },
7
+ "body": "<div class=\"manga-item\"><a href=\"/manga/1\"><img src=\"/cover/1.jpg\"><span class=\"title\">範例漫畫一</span></a></div><div class=\"manga-item\"><a href=\"/manga/2\"><img src=\"/cover/2.jpg\"><span class=\"title\">Sample Two</span></a></div><a class=\"next\" href=\"?page=2\">next</a>"
8
+ }
9
+ },
10
+ {
11
+ "request": { "method": "GET", "url": "https://example.com/manga/1" },
12
+ "response": { "status": 200, "body": "<p class=\"description\">A sample.</p><p class=\"author\">Someone</p><div class=\"chapter-list\"><a href=\"/manga/1/chapter/1\">Chapter 1</a><a href=\"/manga/1/chapter/2\">Chapter 2</a></div>" }
13
+ },
14
+ {
15
+ "request": { "method": "GET", "url": "https://example.com/manga/1/chapter/1" },
16
+ "response": { "status": 200, "body": "<div class=\"page-img\"><img data-src=\"/img/1.jpg\"><img data-src=\"/img/2.jpg\"></div>" }
17
+ }
18
+ ]
@@ -0,0 +1,138 @@
1
+ // Mixed package: one script, two sources with their own contentKinds (shinsou.plugin.json).
2
+ // - manga source: IMAGE_SEQUENCE → getPageList returns image pages
3
+ // - novel source: PLAIN_TEXT → getChapterText returns the chapter text string
4
+ // `var sources` must stay a top-level declaration. Each entry's `id` must equal the sourceId in
5
+ // shinsou.plugin.json (init filled them in): the host selects a source by exact id, never by position.
6
+
7
+ var MANGA_BASE = "https://example.com";
8
+ var NOVEL_BASE = "https://novel.example.com";
9
+
10
+ function absolute(base, url) {
11
+ return url.indexOf("http") === 0 ? url : base + url;
12
+ }
13
+
14
+ var mangaSource = {
15
+ id: "__MANGA_SOURCE_ID__",
16
+ baseUrl: MANGA_BASE,
17
+ supportsLatest: true,
18
+
19
+ getPopularManga: function (page) {
20
+ var html = bridge.httpGet(MANGA_BASE + "/popular?page=" + page);
21
+ if (!html) return new MangasPage([], false);
22
+ var doc = Jsoup.parse(html, MANGA_BASE);
23
+ var mangas = doc.select(".manga-item").map(function (el) {
24
+ var m = SManga.create();
25
+ m.url = el.selectFirst("a").attr("href");
26
+ m.title = el.selectFirst(".title").text();
27
+ m.thumbnailUrl = el.selectFirst("img").absUrl("src");
28
+ return m;
29
+ });
30
+ return new MangasPage(mangas, doc.selectFirst("a.next") !== null);
31
+ },
32
+
33
+ getLatestUpdates: function (page) {
34
+ return this.getPopularManga(page);
35
+ },
36
+
37
+ getSearchManga: function (page, query, filters) {
38
+ var html = bridge.httpGet(MANGA_BASE + "/search?q=" + encodeURIComponent(query) + "&page=" + page);
39
+ if (!html) return new MangasPage([], false);
40
+ var doc = Jsoup.parse(html, MANGA_BASE);
41
+ var mangas = doc.select(".manga-item").map(function (el) {
42
+ var m = SManga.create();
43
+ m.url = el.selectFirst("a").attr("href");
44
+ m.title = el.selectFirst(".title").text();
45
+ return m;
46
+ });
47
+ return new MangasPage(mangas, false);
48
+ },
49
+
50
+ getMangaDetails: function (manga) {
51
+ var doc = Jsoup.parse(bridge.httpGet(absolute(MANGA_BASE, manga.url)) || "", MANGA_BASE);
52
+ manga.description = doc.select(".description").text();
53
+ manga.author = doc.select(".author").text();
54
+ manga.status = SManga.ONGOING;
55
+ manga.initialized = true;
56
+ return manga;
57
+ },
58
+
59
+ getChapterList: function (manga) {
60
+ var doc = Jsoup.parse(bridge.httpGet(absolute(MANGA_BASE, manga.url)) || "", MANGA_BASE);
61
+ return doc.select(".chapter-list a").map(function (el) {
62
+ var ch = SChapter.create();
63
+ ch.url = el.attr("href");
64
+ ch.name = el.text();
65
+ return ch;
66
+ });
67
+ },
68
+
69
+ getPageList: function (chapter) {
70
+ var doc = Jsoup.parse(bridge.httpGet(absolute(MANGA_BASE, chapter.url)) || "", MANGA_BASE);
71
+ return doc.select(".page-img img").map(function (img, i) {
72
+ var url = img.absUrl("data-src") || img.absUrl("src");
73
+ return new Page(i, "", url);
74
+ });
75
+ },
76
+
77
+ getFilterList: function () {
78
+ return [];
79
+ }
80
+ };
81
+
82
+ function listBooks(path) {
83
+ var html = bridge.httpGet(NOVEL_BASE + path);
84
+ if (!html) return new MangasPage([], false);
85
+ var doc = Jsoup.parse(html, NOVEL_BASE);
86
+ var books = doc.select(".book").map(function (el) {
87
+ var m = SManga.create();
88
+ m.url = el.selectFirst("a").attr("href");
89
+ m.title = el.selectFirst("a").text();
90
+ m.author = el.select(".author").text() || null;
91
+ return m;
92
+ });
93
+ return new MangasPage(books, doc.selectFirst("a.next") !== null);
94
+ }
95
+
96
+ var novelSource = {
97
+ id: "__NOVEL_SOURCE_ID__",
98
+ baseUrl: NOVEL_BASE,
99
+ supportsLatest: true,
100
+
101
+ getPopularManga: function (page) {
102
+ return listBooks("/rank?page=" + page);
103
+ },
104
+
105
+ getLatestUpdates: function (page) {
106
+ return listBooks("/latest?page=" + page);
107
+ },
108
+
109
+ getSearchManga: function (page, query, filters) {
110
+ return listBooks("/search?q=" + encodeURIComponent(query) + "&page=" + page);
111
+ },
112
+
113
+ getMangaDetails: function (book) {
114
+ var doc = Jsoup.parse(bridge.httpGet(absolute(NOVEL_BASE, book.url)) || "", NOVEL_BASE);
115
+ book.description = doc.select(".intro").text();
116
+ book.initialized = true;
117
+ return book;
118
+ },
119
+
120
+ getChapterList: function (book) {
121
+ var doc = Jsoup.parse(bridge.httpGet(absolute(NOVEL_BASE, book.url)) || "", NOVEL_BASE);
122
+ return doc.select(".toc a").map(function (el) {
123
+ var ch = SChapter.create();
124
+ ch.url = el.attr("href");
125
+ ch.name = el.text();
126
+ return ch;
127
+ });
128
+ },
129
+
130
+ // Returns the chapter text itself: paragraphs separated by a blank line.
131
+ getChapterText: function (chapter) {
132
+ var doc = Jsoup.parse(bridge.httpGet(absolute(NOVEL_BASE, chapter.url)) || "", NOVEL_BASE);
133
+ var paragraphs = doc.select(".content p").map(function (p) { return p.text(); });
134
+ return paragraphs.join("\n\n");
135
+ }
136
+ };
137
+
138
+ var sources = [mangaSource, novelSource];
@@ -0,0 +1,6 @@
1
+ [
2
+ { "name": "manga-popular", "hook": "getPopularManga", "args": [1], "sourceId": "__MANGA_SOURCE_ID__", "expect": { "path": "mangas", "minItems": 2, "matches": { "0.title": "範例漫畫一" } } },
3
+ { "name": "manga-pages", "hook": "getPageList", "args": [{ "url": "/manga/1/chapter/1" }], "sourceId": "__MANGA_SOURCE_ID__", "expect": { "minItems": 2, "matches": { "0.imageUrl": "https://example.com/img/1.jpg" } } },
4
+ { "name": "novel-rank", "hook": "getPopularManga", "args": [1], "sourceId": "__NOVEL_SOURCE_ID__", "expect": { "path": "mangas", "minItems": 1, "matches": { "0.title": "範例小說" } } },
5
+ { "name": "novel-chapter-text", "hook": "getChapterText", "args": [{ "url": "/book/1/1.html" }], "sourceId": "__NOVEL_SOURCE_ID__", "expect": { "equals": "第一段。\n\n第二段。" } }
6
+ ]
@@ -0,0 +1,22 @@
1
+ [
2
+ {
3
+ "request": { "method": "GET", "url": "https://example.com/popular?page=1" },
4
+ "response": {
5
+ "status": 200,
6
+ "headers": { "content-type": "text/html; charset=utf-8" },
7
+ "body": "<div class=\"manga-item\"><a href=\"/manga/1\"><img src=\"/cover/1.jpg\"><span class=\"title\">範例漫畫一</span></a></div><div class=\"manga-item\"><a href=\"/manga/2\"><img src=\"/cover/2.jpg\"><span class=\"title\">Sample Two</span></a></div><a class=\"next\" href=\"?page=2\">next</a>"
8
+ }
9
+ },
10
+ {
11
+ "request": { "method": "GET", "url": "https://example.com/manga/1/chapter/1" },
12
+ "response": { "status": 200, "body": "<div class=\"page-img\"><img data-src=\"/img/1.jpg\"><img data-src=\"/img/2.jpg\"></div>" }
13
+ },
14
+ {
15
+ "request": { "method": "GET", "url": "https://novel.example.com/rank?page=1" },
16
+ "response": { "status": 200, "body": "<div class=\"book\"><a href=\"/book/1\">範例小說</a><span class=\"author\">作者</span></div>" }
17
+ },
18
+ {
19
+ "request": { "method": "GET", "url": "https://novel.example.com/book/1/1.html" },
20
+ "response": { "status": 200, "body": "<div class=\"content\"><p>第一段。</p><p>第二段。</p></div>" }
21
+ }
22
+ ]
@@ -0,0 +1,64 @@
1
+ // Novel source template. A PLAIN_TEXT source is a text source when the script defines
2
+ // getChapterText(chapter) returning the chapter text as one string (up to 4 MiB).
3
+ // `var source` must stay a top-level declaration: the host looks it up by name.
4
+
5
+ var BASE = "https://example.com";
6
+
7
+ function absolute(url) {
8
+ return url.indexOf("http") === 0 ? url : BASE + url;
9
+ }
10
+
11
+ function listBooks(path) {
12
+ var html = bridge.httpGet(BASE + path);
13
+ if (!html) return new MangasPage([], false);
14
+ var doc = Jsoup.parse(html, BASE);
15
+ var books = doc.select(".book").map(function (el) {
16
+ var m = SManga.create();
17
+ m.url = el.selectFirst("a").attr("href");
18
+ m.title = el.selectFirst("a").text();
19
+ m.author = el.select(".author").text() || null;
20
+ return m;
21
+ });
22
+ return new MangasPage(books, doc.selectFirst("a.next") !== null);
23
+ }
24
+
25
+ var source = {
26
+ baseUrl: BASE,
27
+ supportsLatest: true,
28
+
29
+ getPopularManga: function (page) {
30
+ return listBooks("/rank?page=" + page);
31
+ },
32
+
33
+ getLatestUpdates: function (page) {
34
+ return listBooks("/latest?page=" + page);
35
+ },
36
+
37
+ getSearchManga: function (page, query, filters) {
38
+ return listBooks("/search?q=" + encodeURIComponent(query) + "&page=" + page);
39
+ },
40
+
41
+ getMangaDetails: function (book) {
42
+ var doc = Jsoup.parse(bridge.httpGet(absolute(book.url)) || "", BASE);
43
+ book.description = doc.select(".intro").text();
44
+ book.initialized = true;
45
+ return book;
46
+ },
47
+
48
+ getChapterList: function (book) {
49
+ var doc = Jsoup.parse(bridge.httpGet(absolute(book.url)) || "", BASE);
50
+ return doc.select(".toc a").map(function (el) {
51
+ var ch = SChapter.create();
52
+ ch.url = el.attr("href");
53
+ ch.name = el.text();
54
+ return ch;
55
+ });
56
+ },
57
+
58
+ // Returns the chapter text itself: paragraphs separated by a blank line.
59
+ getChapterText: function (chapter) {
60
+ var doc = Jsoup.parse(bridge.httpGet(absolute(chapter.url)) || "", BASE);
61
+ var paragraphs = doc.select(".content p").map(function (p) { return p.text(); });
62
+ return paragraphs.join("\n\n");
63
+ }
64
+ };
@@ -0,0 +1,4 @@
1
+ [
2
+ {"name": "rank-page-1", "hook": "getPopularManga", "args": [1], "expect": {"path": "mangas", "minItems": 1, "matches": {"0.title": "範例小說"}}},
3
+ {"name": "chapter-text", "hook": "getChapterText", "args": [{"url": "/book/1/1.html"}], "expect": {"equals": "第一段。\n\n第二段。"}}
4
+ ]
@@ -0,0 +1,10 @@
1
+ [
2
+ {
3
+ "request": { "method": "GET", "url": "https://example.com/rank?page=1" },
4
+ "response": { "status": 200, "body": "<div class=\"book\"><a href=\"/book/1\">範例小說</a><span class=\"author\">作者</span></div>" }
5
+ },
6
+ {
7
+ "request": { "method": "GET", "url": "https://example.com/book/1/1.html" },
8
+ "response": { "status": 200, "body": "<div class=\"content\"><p>第一段。</p><p>第二段。</p></div>" }
9
+ }
10
+ ]