@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/LICENSE +21 -0
- package/README.md +335 -2
- package/data/README.md +10 -0
- package/data/public_suffix_list.dat +16581 -0
- package/dist/cli.js +3096 -0
- package/package.json +51 -4
- package/templates/async/src/index.js +50 -0
- package/templates/async/src/mapping.js +7 -0
- package/templates/async/test/cases.json +4 -0
- package/templates/async/test/fixtures/sample.json +10 -0
- package/templates/common/.github/workflows/shinsou-refresh.yml +42 -0
- package/templates/common/gitignore +3 -0
- package/templates/manga/src/index.js +75 -0
- package/templates/manga/test/cases.json +10 -0
- package/templates/manga/test/fixtures/sample.json +18 -0
- package/templates/mixed/src/index.js +138 -0
- package/templates/mixed/test/cases.json +6 -0
- package/templates/mixed/test/fixtures/sample.json +22 -0
- package/templates/novel/src/index.js +64 -0
- package/templates/novel/test/cases.json +4 -0
- package/templates/novel/test/fixtures/sample.json +10 -0
package/package.json
CHANGED
|
@@ -1,6 +1,53 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shinsou/plugin",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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,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,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
|
+
]
|