@cloud411716/fancy-webnovel 0.3.16 → 1.0.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.
@@ -0,0 +1,143 @@
1
+ /**
2
+ * jinjiang.js — 晋江文学城排行榜采集
3
+ *
4
+ * 策略:纯 HTTP + gb18030 解码。
5
+ * 详情页使用 fetch + arrayBuffer + TextDecoder('gb18030')。
6
+ * 无需 Playwright。
7
+ */
8
+
9
+ export const platform = 'jinjiang';
10
+ export const label = '晋江';
11
+
12
+ export const rankList = [
13
+ { id: '5', label: '月榜', channel: 'all', type: '5' },
14
+ { id: '7', label: '总分榜', channel: 'all', type: '7' },
15
+ { id: '4', label: '季度榜', channel: 'all', type: '4' },
16
+ { id: '12', label: '收入金榜', channel: 'all', type: '12' },
17
+ { id: '16', label: '完结金榜', channel: 'all', type: '16' },
18
+ { id: '17', label: '新手金榜', channel: 'all', type: '17' },
19
+ ];
20
+
21
+ const BASE_URL = 'https://www.jjwxc.net/topten.php';
22
+ const DETAIL_BASE = 'https://www.jjwxc.net/onebook.php';
23
+
24
+ /**
25
+ * Scrape one rank list.
26
+ * @param {string} rankId - orderstr, e.g. '5'
27
+ * @param {object} ctx - Cordis plugin context
28
+ * @param {AbortSignal} signal
29
+ * @returns {Promise<{ ok: boolean, books: object[], error?: string }>}
30
+ */
31
+ export async function scrapeRank(rankId, ctx, signal) {
32
+ const url = `${BASE_URL}?orderstr=${rankId}`;
33
+
34
+ // 1. 抓列表页
35
+ const { ok: listOk, status, body, error: listError } = await httpGet(ctx, url, signal);
36
+ if (!listOk) return { ok: false, books: [], error: listError ?? `HTTP ${status}` };
37
+
38
+ // 2. 解析书籍 ID 列表
39
+ const bookIds = extractBookIds(body);
40
+ if (!bookIds.length) return { ok: false, books: [], error: 'no book ids found' };
41
+
42
+ // 3. 批量补采详情(每批 6 本)
43
+ const CHUNK = 6;
44
+ const books = [];
45
+ for (let i = 0; i < bookIds.length; i += CHUNK) {
46
+ if (signal?.aborted) break;
47
+ const chunk = bookIds.slice(i, i + CHUNK);
48
+ const details = await Promise.all(chunk.map(id => fetchDetail(ctx, id, signal)));
49
+ for (const d of details) {
50
+ if (d) books.push(d);
51
+ }
52
+ }
53
+
54
+ return books.length > 0
55
+ ? { ok: true, books }
56
+ : { ok: false, books: [], error: 'no books collected' };
57
+ }
58
+
59
+ async function httpGet(ctx, url, signal) {
60
+ let timerDispose;
61
+ const timeout = new Promise((_, reject) => {
62
+ timerDispose = ctx.timeout(() => reject(new Error('timeout')), 15000);
63
+ });
64
+ try {
65
+ const res = await Promise.race([
66
+ fetch(url, {
67
+ headers: {
68
+ 'User-Agent': 'Mozilla/5.0 (compatible; fancy-webnovel/1.0)',
69
+ 'Accept': 'text/html',
70
+ 'Accept-Language': 'zh-CN',
71
+ },
72
+ signal,
73
+ }),
74
+ timeout,
75
+ ]);
76
+ if (!res.ok) return { ok: false, status: res.status, body: '', error: `HTTP ${res.status}` };
77
+ const body = await res.text();
78
+ return { ok: true, status: res.status, body };
79
+ } catch (err) {
80
+ return { ok: false, status: 0, body: '', error: err.message };
81
+ } finally {
82
+ try { timerDispose?.(); } catch (_) {}
83
+ }
84
+ }
85
+
86
+ function extractBookIds(html) {
87
+ const ids = [];
88
+ // 两种布局:表格行和交替行
89
+ const tableRows = html.match(/<tr[^>]*>[\s\S]*?<\/tr>/gi) ?? [];
90
+ for (const row of tableRows) {
91
+ const m = row.match(/onebook\.php\?novelid=(\d+)/);
92
+ if (m) ids.push(m[1]);
93
+ }
94
+ // 交替行布局: 书名 / 作者 交替
95
+ if (!ids.length) {
96
+ const novelLinks = html.match(/novelid=(\d+)/g) ?? [];
97
+ for (const link of novelLinks) {
98
+ const id = link.match(/(\d+)/)?.[1];
99
+ if (id && !ids.includes(id)) ids.push(id);
100
+ }
101
+ }
102
+ return [...new Set(ids)].slice(0, 60); // 最多 60 本
103
+ }
104
+
105
+ async function fetchDetail(ctx, novelid, signal) {
106
+ let timerDispose;
107
+ const timeout = new Promise((_, reject) => {
108
+ timerDispose = ctx.timeout(() => reject(new Error('timeout')), 10000);
109
+ });
110
+ try {
111
+ const res = await Promise.race([
112
+ fetch(`${DETAIL_BASE}?novelid=${novelid}`, { signal }),
113
+ timeout,
114
+ ]);
115
+ if (!res.ok) return null;
116
+ const buf = await res.arrayBuffer();
117
+ const h = new TextDecoder('gb18030').decode(new Uint8Array(buf));
118
+
119
+ const prop = (/** @type {string} */ name) => {
120
+ const m = h.match(new RegExp(`itemprop="${name}"[^>]*>([^<]*)<`));
121
+ return m ? m[1].trim() : '';
122
+ };
123
+
124
+ return {
125
+ rank: 0,
126
+ title: prop('title') || prop('novelname') || '',
127
+ author: prop('author') || '',
128
+ category: '',
129
+ words: parseInt(prop('wordCount').replace(/[^\d]/g, ''), 10) || 0,
130
+ collect: prop('collectedCount') || '',
131
+ nutrition: prop('nutritionCount') || '',
132
+ score: prop('scoreCount') || '',
133
+ status: (h.match(/(连载中|已完结|完结)/) || [, ''])[1],
134
+ intro: (h.match(/<meta[^>]+name="description"[^>]+content="([^"]*)"/i)?.[1] ?? '').slice(0, 150),
135
+ cover: '',
136
+ bookId: novelid,
137
+ };
138
+ } catch (_) {
139
+ return null;
140
+ } finally {
141
+ try { timerDispose?.(); } catch (_) {}
142
+ }
143
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * qidian.js — 起点排行榜采集
3
+ *
4
+ * 策略:纯 HTTP mobile SSR。移动端页面在 script 标签里内嵌完整的
5
+ * pageContext JSON,绕过了服务端渲染的加密,直接提取书籍数据。
6
+ * 无需 Playwright。
7
+ *
8
+ * Rank paths (mobile):
9
+ * /rank/1_5173_2 畅销榜
10
+ * /rank/1_5173_7 月票榜
11
+ * /rank/1_5173_14 签约作者新书榜
12
+ * /rank/1_5173_22 公众作者新书榜
13
+ * /rank/1_5173_31 新人作者新书榜
14
+ */
15
+
16
+ const MOBILE_BASE = 'https://m.qidian.com';
17
+
18
+ const RANK_PATHS = {
19
+ hotsales: '/rank/1_5173_2',
20
+ yuepiao: '/rank/1_5173_7',
21
+ signnewbook: '/rank/1_5173_14',
22
+ pubnewbook: '/rank/1_5173_22',
23
+ newauthor: '/rank/1_5173_31',
24
+ };
25
+
26
+ export const platform = 'qidian';
27
+ export const label = '起点';
28
+
29
+ export const rankList = [
30
+ { id: 'hotsales', label: '畅销榜', channel: '1', type: '5173_2' },
31
+ { id: 'yuepiao', label: '月票榜', channel: '1', type: '5173_7' },
32
+ { id: 'signnewbook', label: '签约作者新书榜', channel: '1', type: '5173_14' },
33
+ { id: 'pubnewbook', label: '公众作者新书榜', channel: '1', type: '5173_22' },
34
+ { id: 'newauthor', label: '新人作者新书榜', channel: '1', type: '5173_31' },
35
+ ];
36
+
37
+ const HEADERS = {
38
+ 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1',
39
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
40
+ 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
41
+ 'Accept-Encoding': 'identity',
42
+ };
43
+
44
+ /**
45
+ * Scrape one rank list.
46
+ * @param {string} rankId - rank id (e.g. 'hotsales')
47
+ * @param {object} ctx - Cordis plugin context
48
+ * @param {AbortSignal} signal
49
+ * @returns {Promise<{ ok: boolean, books: object[], error?: string }>}
50
+ */
51
+ export async function scrapeRank(rankId, ctx, signal) {
52
+ const path = RANK_PATHS[rankId];
53
+ if (!path) return { ok: false, books: [], error: `unknown rank: ${rankId}` };
54
+
55
+ const url = MOBILE_BASE + path;
56
+
57
+ const { ok, status, body, error } = await httpGet(ctx, url, signal);
58
+ if (!ok) return { ok: false, books: [], error: error ?? `HTTP ${status}` };
59
+
60
+ // Extract pageContext from <script id="vite-plugin-ssr_pageContext">
61
+ const m = body.match(/<script[^>]+id=["']vite-plugin-ssr_pageContext["'][^>]*>([\s\S]*?)<\/script>/i);
62
+ if (!m) return { ok: false, books: [], error: 'pageContext not found' };
63
+
64
+ let pageContext;
65
+ try {
66
+ pageContext = JSON.parse(m[1]);
67
+ } catch {
68
+ return { ok: false, books: [], error: 'pageContext JSON parse failed' };
69
+ }
70
+
71
+ const records = pageContext?.pageContext?.pageProps?.pageData?.records ?? [];
72
+ if (!records.length) return { ok: false, books: [], error: 'no book records found' };
73
+
74
+ const books = records.map((r) => ({
75
+ rank: r.rank ?? 0,
76
+ title: r.title ?? '',
77
+ author: r.author ?? '',
78
+ category: r.categoryName ?? '',
79
+ status: r.statusName ?? '',
80
+ words: r.wordCount ?? 0,
81
+ intro: (r.description ?? '').slice(0, 150),
82
+ cover: r.cover ?? '',
83
+ bookId: r.bookId ?? '',
84
+ }));
85
+
86
+ return { ok: true, books };
87
+ }
88
+
89
+ async function httpGet(ctx, url, signal) {
90
+ let timerDispose;
91
+ const timeout = new Promise((_, reject) => {
92
+ timerDispose = ctx.timeout(() => reject(new Error('timeout')), 15000);
93
+ });
94
+ try {
95
+ const res = await Promise.race([
96
+ fetch(url, { headers: HEADERS, signal }),
97
+ timeout,
98
+ ]);
99
+ const body = await res.text();
100
+ return { ok: res.ok, status: res.status, body };
101
+ } catch (err) {
102
+ return { ok: false, status: 0, body: '', error: err.message };
103
+ } finally {
104
+ try { timerDispose?.(); } catch (_) {}
105
+ }
106
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * qimao.js — 七猫小说排行榜采集
3
+ *
4
+ * 策略:纯 HTTP。榜单页通过 XHR 加载 JSON 数据。
5
+ * 无需 Playwright。
6
+ */
7
+
8
+ export const platform = 'qimao';
9
+ export const label = '七猫';
10
+
11
+ export const rankList = [
12
+ { id: 'male_hot', label: '男生大热榜', channel: 'male', type: 'hot' },
13
+ { id: 'male_new', label: '男生新书榜', channel: 'male', type: 'new' },
14
+ { id: 'female_hot', label: '女生大热榜', channel: 'female', type: 'hot' },
15
+ { id: 'female_new', label: '女生新书榜', channel: 'female', type: 'new' },
16
+ ];
17
+
18
+ const RANK_URL = 'https://www.qimao.com/paihang';
19
+
20
+ /**
21
+ * Scrape one rank list.
22
+ * @param {string} rankId - e.g. 'male_hot'
23
+ * @param {object} ctx - Cordis plugin context
24
+ * @param {AbortSignal} signal
25
+ * @returns {Promise<{ ok: boolean, books: object[], error?: string }>}
26
+ */
27
+ export async function scrapeRank(rankId, ctx, signal) {
28
+ const [channelId, rankTypeId] = rankId.split('_');
29
+
30
+ const channelMap = { male: 'boy', female: 'girl' };
31
+ const rankTypeMap = { hot: 'hot', new: 'new' };
32
+ const channel = channelMap[channelId] ?? channelId;
33
+ const rankType = rankTypeMap[rankTypeId] ?? rankTypeId;
34
+
35
+ const url = `${RANK_URL}/${channel}/${rankType}/date/`;
36
+
37
+ const { ok, status, body, error } = await httpGet(ctx, url, signal);
38
+ if (!ok) return { ok: false, books: [], error: error ?? `HTTP ${status}` };
39
+
40
+ const books = parseBooks(body, channelId, rankTypeId);
41
+ return books.length > 0
42
+ ? { ok: true, books }
43
+ : { ok: false, books: [], error: 'no books found in page' };
44
+ }
45
+
46
+ async function httpGet(ctx, url, signal) {
47
+ let timerDispose;
48
+ const timeout = new Promise((_, reject) => {
49
+ timerDispose = ctx.timeout(() => reject(new Error('timeout')), 15000);
50
+ });
51
+ try {
52
+ const res = await Promise.race([
53
+ fetch(url, {
54
+ headers: {
55
+ 'User-Agent': 'Mozilla/5.0 (compatible; fancy-webnovel/1.0)',
56
+ 'Accept': 'text/html,application/xhtml+xml',
57
+ 'Accept-Language': 'zh-CN,zh;q=0.9',
58
+ },
59
+ signal,
60
+ }),
61
+ timeout,
62
+ ]);
63
+ const body = await res.text();
64
+ return { ok: res.ok, status: res.status, body };
65
+ } catch (err) {
66
+ return { ok: false, status: 0, body: '', error: err.message };
67
+ } finally {
68
+ try { timerDispose?.(); } catch (_) {}
69
+ }
70
+ }
71
+
72
+ function parseBooks(html, channelId, rankTypeId) {
73
+ const books = [];
74
+
75
+ // 尝试从 JSON 数据块提取
76
+ const jsonMatch = html.match(/var\s+RANK_DATA\s*=\s*(\{.*?\});/s)
77
+ ?? html.match(/window\.__INITIAL_STATE__\s*=\s*(\{.*?\});/s);
78
+
79
+ if (jsonMatch) {
80
+ try {
81
+ const data = JSON.parse(jsonMatch[1]);
82
+ const list = data?.rankList ?? data?.bookList ?? data?.books ?? [];
83
+ return normalizeBooks(list, channelId, rankTypeId);
84
+ } catch (_) { /* fall through */ }
85
+ }
86
+
87
+ // DOM 解析: 从页面 HTML 提取书籍信息
88
+ // 匹配模式: <a class="book-title">书名</a> ... <span class="author">作者</span>
89
+ const titleMatches = [...html.matchAll(/class="(?:book-)?title[^"]*"[^>]*>([^<]+)<\/a>/g)];
90
+ const authorMatches = [...html.matchAll(/class="author[^"]*"[^>]*>([^<]+)<\/a>/g)];
91
+ const rankMatches = [...html.matchAll(/class="(?:rank|num)[^"]*"[^>]*>(\d+)<\/span>/g)];
92
+
93
+ const count = Math.min(titleMatches.length, authorMatches.length);
94
+ for (let i = 0; i < count; i++) {
95
+ const title = (titleMatches[i][1] ?? '').trim();
96
+ const author = (authorMatches[i][1] ?? '').trim();
97
+ if (!title || /^(上一页|下一页|跳转)$/.test(title)) continue;
98
+ if (!author || /^(上一页|下一页|跳转|友情链接)$/.test(author)) continue;
99
+
100
+ books.push({
101
+ rank: rankMatches[i] ? parseInt(rankMatches[i][1], 10) : (i + 1),
102
+ title,
103
+ author,
104
+ category: '',
105
+ words: 0,
106
+ intro: '',
107
+ cover: '',
108
+ bookId: '',
109
+ });
110
+ }
111
+
112
+ return books;
113
+ }
114
+
115
+ function normalizeBooks(list, channelId, rankTypeId) {
116
+ if (!Array.isArray(list)) return [];
117
+ return list.map((b, i) => ({
118
+ rank: b.rank ?? (i + 1),
119
+ title: b.title ?? b.bookTitle ?? '',
120
+ author: b.author ?? '',
121
+ category: b.categoryName ?? b.typeName ?? '',
122
+ words: b.wordCount ?? b.words ?? 0,
123
+ intro: (b.description ?? '').slice(0, 150),
124
+ cover: b.cover ?? b.thumbnail ?? '',
125
+ bookId: b.bookId ?? b.id ?? '',
126
+ }));
127
+ }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * scraper.js — 扫榜编排层
3
+ *
4
+ * 职责:
5
+ * 1. 对每个平台/榜单调用对应的 platform scraper
6
+ * 2. 重试逻辑(每个榜单最多 3 次,间隔 5 秒)
7
+ * 3. 通过 DSH 事件实时汇报进度
8
+ * 4. 生成 Markdown 文件并写入项目目录
9
+ * 5. 所有操作纳入 ctx.effect() 生命周期,Ctrl+C 可终止
10
+ */
11
+
12
+ import { mkdirSync } from 'fs';
13
+ import { join } from 'path';
14
+ import { sleep, scanProgress, writeFile } from '../../infra.js';
15
+ import * as qidian from './platforms/qidian.js';
16
+ import * as fanqie from './platforms/fanqie.js';
17
+ import * as jinjiang from './platforms/jinjiang.js';
18
+ import * as qimao from './platforms/qimao.js';
19
+
20
+ const SCRAPER_TIMEOUT = 30 * 60 * 1000; // 30 分钟 per rank
21
+ const MAX_RETRIES = 3;
22
+ const RETRY_DELAY = 5000;
23
+
24
+ // Platform registry
25
+ const PLATFORMS = { qidian, fanqie, jinjiang, qimao };
26
+
27
+ function todayStr() {
28
+ const d = new Date();
29
+ return `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}`;
30
+ }
31
+
32
+ /**
33
+ * Run scans for a list of { platform, channel, type, label } entries.
34
+ * Uses ctx.effect() for lifecycle management and ctx.events for progress.
35
+ *
36
+ * @param {object} ctx - Cordis plugin context
37
+ * @param {string} projectRoot
38
+ * @param {Array} ranks - array of { platform, channel, type, label }
39
+ * @param {AbortSignal} signal
40
+ * @returns {Promise<{ totalBooks: number, totalFiles: number, failedRanks: Array, lastReceipt: object|null }>}
41
+ */
42
+ export async function runScans(ctx, projectRoot, ranks, signal) {
43
+ const scanDir = join(projectRoot, '扫榜数据');
44
+ mkdirSync(scanDir, { recursive: true });
45
+
46
+ const today = todayStr();
47
+ let totalBooks = 0;
48
+ let totalFiles = 0;
49
+ const failedRanks = [];
50
+ let lastReceipt = null;
51
+
52
+ for (const rank of ranks) {
53
+ if (signal.aborted) {
54
+ scanProgress(ctx, 'rank-done', {
55
+ platform: rank.platform, channel: rank.channel, type: rank.type,
56
+ ok: false, written: 0, failed: 0, errorMsg: 'aborted by user',
57
+ });
58
+ break;
59
+ }
60
+
61
+ const platform = PLATFORMS[rank.platform];
62
+ if (!platform) {
63
+ failedRanks.push({ rank, err: `unknown platform: ${rank.platform}` });
64
+ continue;
65
+ }
66
+
67
+ scanProgress(ctx, 'start', {
68
+ platform: rank.platform,
69
+ channel: rank.channel,
70
+ type: rank.type,
71
+ label: rank.label,
72
+ });
73
+
74
+ let result;
75
+ let attempt = 0;
76
+
77
+ while (attempt < MAX_RETRIES) {
78
+ if (signal.aborted) break;
79
+ attempt++;
80
+ try {
81
+ result = await withTimeout(
82
+ platform.scrapeRank(rank.type, ctx, signal),
83
+ ctx,
84
+ SCRAPER_TIMEOUT,
85
+ );
86
+ if (result.ok) break;
87
+ } catch (err) {
88
+ result = { ok: false, books: [], error: err.message };
89
+ }
90
+ if (attempt < MAX_RETRIES && !signal.aborted) {
91
+ await sleep(ctx, RETRY_DELAY);
92
+ }
93
+ }
94
+
95
+ if (signal.aborted) {
96
+ failedRanks.push({ rank, err: 'aborted' });
97
+ continue;
98
+ }
99
+
100
+ if (!result.ok || !result.books.length) {
101
+ failedRanks.push({ rank, err: result.error ?? 'no books' });
102
+ scanProgress(ctx, 'rank-done', {
103
+ platform: rank.platform, channel: rank.channel, type: rank.type,
104
+ ok: false, written: 0, failed: result.books?.length ?? 0,
105
+ errorMsg: result.error,
106
+ });
107
+ continue;
108
+ }
109
+
110
+ // Write markdown file
111
+ let filepath;
112
+ try {
113
+ const content = renderMarkdown(platform.label, rank.label, result.books);
114
+ const filename = `${platform.label}${rank.label}_${today}.md`;
115
+ filepath = join(scanDir, filename);
116
+ await writeFile(filepath, content);
117
+ totalFiles++;
118
+ totalBooks += result.books.length;
119
+ lastReceipt = {
120
+ platform: rank.platform,
121
+ date: today,
122
+ scan_files: [filepath],
123
+ topic_decision: join(scanDir, `topic_decision_${today}.md`),
124
+ };
125
+ } catch (err) {
126
+ failedRanks.push({ rank, err: `write failed: ${err.message}` });
127
+ scanProgress(ctx, 'rank-done', {
128
+ platform: rank.platform, channel: rank.channel, type: rank.type,
129
+ ok: false, written: 0, failed: result.books.length,
130
+ errorMsg: `write failed: ${err.message}`,
131
+ });
132
+ continue;
133
+ }
134
+
135
+ scanProgress(ctx, 'rank-done', {
136
+ platform: rank.platform, channel: rank.channel, type: rank.type,
137
+ ok: true, written: result.books.length, failed: 0,
138
+ });
139
+ }
140
+
141
+ scanProgress(ctx, 'done', {
142
+ platform: ranks[0]?.platform ?? '',
143
+ totalBooks,
144
+ totalFiles,
145
+ ok: failedRanks.length < ranks.length,
146
+ });
147
+
148
+ return { totalBooks, totalFiles, failedRanks, lastReceipt };
149
+ }
150
+
151
+ /**
152
+ * Wrap a promise with a ctx.timeout deadline.
153
+ * ctx.timeout(fn, ms) returns a synchronous dispose function that is safe
154
+ * to call multiple times (idempotent), so this is guaranteed leak-free.
155
+ */
156
+ async function withTimeout(promise, ctx, ms) {
157
+ let dispose;
158
+ const timeout = new Promise((_, reject) => {
159
+ dispose = ctx.timeout(() => reject(new Error(`timeout after ${ms}ms`)), ms);
160
+ });
161
+ try {
162
+ return await Promise.race([promise, timeout]);
163
+ } finally {
164
+ try { dispose?.(); } catch (_) {}
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Render books as Markdown.
170
+ */
171
+ function renderMarkdown(platformLabel, rankLabel, books) {
172
+ const lines = [
173
+ `# ${platformLabel} · ${rankLabel}`,
174
+ '',
175
+ `> 自动采集于 ${new Date().toLocaleString('zh-CN')}`,
176
+ '',
177
+ '| 排名 | 书名 | 作者 | 类别 | 字数 | 简介 |',
178
+ '|------|------|------|------|------|------|',
179
+ ];
180
+
181
+ for (const b of books) {
182
+ const title = escapeTable(b.title || '');
183
+ const author = escapeTable(b.author || '');
184
+ const category = escapeTable(b.category || '-');
185
+ const words = b.words ? `${(b.words / 10000).toFixed(1)}万` : '-';
186
+ const intro = escapeTable((b.intro || '').slice(0, 80));
187
+ lines.push(`| ${b.rank ?? ''} | ${title} | ${author} | ${category} | ${words} | ${intro} |`);
188
+ }
189
+
190
+ return lines.join('\n') + '\n';
191
+ }
192
+
193
+ function escapeTable(s) {
194
+ return String(s).replace(/\|/g, '\\|').replace(/\n/g, ' ');
195
+ }
package/cordis.patch.yml CHANGED
@@ -1,3 +1,17 @@
1
+ # @cloud411716/fancy-webnovel — DSH Cordis Plugin Patch
2
+ #
3
+ # IMPORTANT: This plugin creates project directories at user-specified paths
4
+ # (which may be OUTSIDE the DSH workspace). Therefore it REQUIRES
5
+ # `danger-full-access` sandbox mode. Add this to your profile's patch, e.g.:
6
+ #
7
+ # - insert:
8
+ # - id: sandbox-policy
9
+ # config:
10
+ # mode: danger-full-access
11
+ #
12
+ # All other services used (commands, userQuestions, timer, events, session)
13
+ # are part of dsh-base and available in every DSH UI (tui, web, desktop).
14
+
1
15
  - insert:
2
16
  - id: fancy-webnovel
3
17
  name: "@cloud411716/fancy-webnovel"
package/events.js ADDED
@@ -0,0 +1,53 @@
1
+ /**
2
+ * events.js — Scan progress event vocabulary
3
+ *
4
+ * These are pure in-memory plugin events emitted via ctx.events.emit().
5
+ * They flow through DSH's session log and are visible to the agent
6
+ * as structured data, enabling real-time progress reporting.
7
+ */
8
+
9
+ declare module '@deepseek-ai/cordis' {
10
+ interface Events {
11
+ /**
12
+ * Emitted when a rank scan starts.
13
+ * @param platform - platform id (qidian|fanqie|jinjiang|qimao)
14
+ * @param channel - channel id
15
+ * @param type - rank type id
16
+ * @param label - human-readable rank label
17
+ */
18
+ 'fancy/scan:start'(platform: string, channel: string, type: string, label: string): void
19
+
20
+ /**
21
+ * Emitted after each book is successfully scraped.
22
+ * @param platform - platform id
23
+ * @param channel - channel id
24
+ * @param type - rank type id
25
+ * @param index - 0-based index of this book in the rank
26
+ * @param total - total books expected (may be 0 if unknown)
27
+ * @param title - book title
28
+ * @param rank - displayed rank number
29
+ */
30
+ 'fancy/scan:book'(platform: string, channel: string, type: string, index: number, total: number, title: string, rank: number): void
31
+
32
+ /**
33
+ * Emitted when a rank scan finishes (success or failure).
34
+ * @param platform - platform id
35
+ * @param channel - channel id
36
+ * @param type - rank type id
37
+ * @param ok - true on success
38
+ * @param written - number of books written to file
39
+ * @param failed - number of books that failed
40
+ * @param errorMsg - error message if !ok
41
+ */
42
+ 'fancy/scan:rank-done'(platform: string, channel: string, type: string, ok: boolean, written: number, failed: number, errorMsg?: string): void
43
+
44
+ /**
45
+ * Emitted when all scans for a platform finish.
46
+ * @param platform - platform id
47
+ * @param totalBooks - total books collected
48
+ * @param totalFiles - total files written
49
+ * @param ok - true if at least one rank succeeded
50
+ */
51
+ 'fancy/scan:done'(platform: string, totalBooks: number, totalFiles: number, ok: boolean): void
52
+ }
53
+ }
package/index.js CHANGED
@@ -1,10 +1,44 @@
1
- // @cloud411716/fancy-webnovel unified entry point
2
- import { apply as applyBootstrap } from './plugins/fancy-bootstrap/index.js';
3
- import { apply as applyScan } from './plugins/fancy-scan/index.js';
1
+ /**
2
+ * @cloud411716/fancy-webnovel DSH Cordis Plugin
3
+ *
4
+ * Provides slash commands for web novel project workflow:
5
+ * /fancy-bootstrap — Initialize a project root
6
+ * /fancy-scan — Scan platform rank lists
7
+ *
8
+ * Architecture:
9
+ * - All scrapers run IN-PROCESS (no subprocess).
10
+ * DSH manages the lifecycle via ctx.effect() and AbortSignal.
11
+ * Progress is reported via ctx.events (fancy/scan:* events).
12
+ * - HTTP fetching uses Node.js native fetch (portable across all DSH UIs).
13
+ * - File operations use Node.js fs (project roots may be outside workspace).
14
+ * - ctx.timer (via ctx.timeout()) handles all delays/timeouts.
15
+ *
16
+ * Required Cordis services: commands, userQuestions, timer, events, session
17
+ * (all are part of dsh-base, so this plugin works on dsh-tui, dsh-web, dsh-desktop).
18
+ *
19
+ * IMPORTANT: This plugin needs `danger-full-access` sandbox mode because it
20
+ * creates project directories outside the DSH workspace (at paths the user
21
+ * specifies). Declare this in the profile's cordis.patch.yml.
22
+ */
4
23
 
24
+ import './events.js'; // registers fancy/scan:* event types with Cordis
25
+
26
+ import { apply as applyBootstrap } from './commands/bootstrap/index.js';
27
+ import { apply as applyScan } from './commands/scan/index.js';
28
+
29
+ /**
30
+ * Services this plugin requires from the Cordis context.
31
+ * All are provided by dsh-base, so this works on every DSH UI.
32
+ */
5
33
  export const inject = ['commands', 'userQuestions'];
6
34
 
7
- export async function apply(ctx) {
8
- await applyBootstrap(ctx);
9
- await applyScan(ctx);
35
+ /**
36
+ * Called once when the plugin is loaded into a Cordis context (fiber).
37
+ * Registers slash commands; actual logic lives in per-command modules.
38
+ *
39
+ * @param {object} ctx - Cordis plugin context
40
+ */
41
+ export function apply(ctx) {
42
+ applyBootstrap(ctx);
43
+ applyScan(ctx);
10
44
  }