@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,138 @@
1
+ /**
2
+ * commands/bootstrap.js — /fancy-bootstrap command
3
+ *
4
+ * Registers /fancy-bootstrap into ctx.commands.
5
+ * Validates path, confirms, then runs in-process project init.
6
+ */
7
+
8
+ import { existsSync } from 'fs';
9
+ import { join } from 'path';
10
+ import { notify } from '../../infra.js';
11
+ import { initProject } from './init-project.js';
12
+
13
+ export const inject = ['commands', 'userQuestions'];
14
+
15
+ function isValidPath(p) {
16
+ return (
17
+ !p.includes('..') &&
18
+ !/[<>:"'|?*[\x00-\x1f]/.test(p) &&
19
+ !/\s/.test(p)
20
+ );
21
+ }
22
+
23
+ const bootstrap = {
24
+ askPath() {
25
+ return {
26
+ question: '📂 请输入项目根路径(输入 . 代表当前路径)',
27
+ };
28
+ },
29
+
30
+ confirmOptions() {
31
+ return [
32
+ { label: '确认创建' },
33
+ { label: '取消' },
34
+ ];
35
+ },
36
+
37
+ notify({ type, projectRoot, err }) {
38
+ switch (type) {
39
+ case 'no_path':
40
+ return '⚠️ 未提供路径,已取消';
41
+ case 'using_cwd':
42
+ return `📂 使用当前目录:${projectRoot}`;
43
+ case 'invalid_path':
44
+ return '❌ 路径不合法(不能含空格、.. 或特殊字符)';
45
+ case 'already_initialized':
46
+ return '⚠️ 该目录已初始化(.fancy-deployed 已存在)';
47
+ case 'confirm':
48
+ return `❓ 确认将以下路径作为项目根?\n${projectRoot}`;
49
+ case 'cancelled':
50
+ return '🚫 已取消';
51
+ case 'initializing':
52
+ return `⏳ 正在初始化 ${projectRoot}...`;
53
+ case 'success':
54
+ return '✅ 项目初始化完成!\n下一步:运行 /fancy-scan 扫榜';
55
+ case 'error':
56
+ return '❌ 初始化失败:' + (typeof err === 'string' ? err : err?.message ?? String(err));
57
+ default:
58
+ return String(type);
59
+ }
60
+ },
61
+ };
62
+
63
+ /**
64
+ * @param {object} ctx - Cordis plugin context
65
+ */
66
+ export function apply(ctx) {
67
+ ctx.commands.register({
68
+ name: 'fancy-bootstrap',
69
+ description: '📁 初始化项目根 ( usage: /fancy-bootstrap [项目根] )',
70
+ handler: async (invocation) => {
71
+ const session = invocation.agent.session;
72
+ const rawInput = invocation.rawInput?.trim() ?? '';
73
+ const { signal } = invocation;
74
+
75
+ // ---------- resolve project root ----------
76
+ let projectRoot = rawInput;
77
+
78
+ if (!projectRoot) {
79
+ const result = await ctx.userQuestions.ask({
80
+ questions: [bootstrap.askPath()],
81
+ signal,
82
+ });
83
+ projectRoot = result.answers[0]?.custom?.trim() ?? '';
84
+ }
85
+
86
+ if (!projectRoot) {
87
+ notify(session, bootstrap.notify({ type: 'no_path' }));
88
+ return { kind: 'success', text: '' };
89
+ }
90
+
91
+ if (projectRoot === '.') {
92
+ projectRoot = process.cwd();
93
+ notify(session, bootstrap.notify({ type: 'using_cwd', projectRoot }));
94
+ }
95
+
96
+ if (!isValidPath(projectRoot)) {
97
+ notify(session, bootstrap.notify({ type: 'invalid_path' }));
98
+ return { kind: 'success', text: '' };
99
+ }
100
+
101
+ if (existsSync(join(projectRoot, '.fancy-deployed'))) {
102
+ notify(session, bootstrap.notify({ type: 'already_initialized' }));
103
+ return { kind: 'success', text: '' };
104
+ }
105
+
106
+ // ---------- confirm ----------
107
+ const confirm = await ctx.userQuestions.ask({
108
+ questions: [{
109
+ id: 'confirm',
110
+ question: bootstrap.notify({ type: 'confirm', projectRoot }),
111
+ detail: projectRoot,
112
+ hideCustomInput: true,
113
+ options: bootstrap.confirmOptions(),
114
+ }],
115
+ signal,
116
+ });
117
+ const ans = confirm.answers[0];
118
+ const chosen = ans?.selected?.[0] ?? ans?.custom;
119
+ if (chosen !== '确认创建') {
120
+ notify(session, bootstrap.notify({ type: 'cancelled' }));
121
+ return { kind: 'success', text: '' };
122
+ }
123
+
124
+ // ---------- init (in-process, no subprocess) ----------
125
+ notify(session, bootstrap.notify({ type: 'initializing', projectRoot }));
126
+
127
+ const result = await initProject(projectRoot);
128
+
129
+ if (result.ok) {
130
+ notify(session, bootstrap.notify({ type: 'success' }));
131
+ } else {
132
+ notify(session, bootstrap.notify({ type: 'error', err: result.error }));
133
+ }
134
+
135
+ return { kind: 'success', text: '' };
136
+ },
137
+ });
138
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * init-project.js — 项目初始化服务
3
+ *
4
+ * 在指定目录下创建标准项目结构。
5
+ * 所有操作使用 Node.js fs(直接文件系统访问,非 ctx.fs 沙盒),
6
+ * 因为项目根由用户指定,不一定在 DSH workspace 内。
7
+ */
8
+
9
+ const DIRS = [
10
+ '设定/角色', '设定/势力', '设定/物品', '设定/地点',
11
+ '大纲', '正文', '故事档案', '对标', '拆文库',
12
+ '审查报告', '图片', '发布',
13
+ '故事档案/备份', '故事档案/.快照', '故事档案/派生',
14
+ '调试', 'RAG索引',
15
+ ];
16
+
17
+ /**
18
+ * @param {string} projectRoot - absolute project root path
19
+ * @returns {Promise<{ ok: boolean, error?: string }>}
20
+ */
21
+ export async function initProject(projectRoot) {
22
+ const { mkdirSync, writeFileSync, existsSync } = await import('fs');
23
+ const { join } = await import('path');
24
+
25
+ // Validate
26
+ if (!projectRoot || typeof projectRoot !== 'string') {
27
+ return { ok: false, error: '无效的项目根路径' };
28
+ }
29
+
30
+ if (!existsSync(projectRoot)) {
31
+ return { ok: false, error: `目录不存在: ${projectRoot}` };
32
+ }
33
+
34
+ const marker = join(projectRoot, '.fancy-deployed');
35
+ if (existsSync(marker)) {
36
+ return { ok: false, error: '该项目已初始化(.fancy-deployed 已存在)' };
37
+ }
38
+
39
+ const now = new Date().toISOString().replace('.000Z', 'Z');
40
+
41
+ try {
42
+ // Create directories
43
+ for (const d of DIRS) {
44
+ mkdirSync(join(projectRoot, d), { recursive: true });
45
+ }
46
+
47
+ // Write .fancy-deployed
48
+ writeFileSync(marker, JSON.stringify({
49
+ schema_version: '2.0.0',
50
+ fancy_skills_version: '2.0.0',
51
+ deployed_at: now,
52
+ project_root_abs: projectRoot,
53
+ project_root_rel: '.',
54
+ current_book: '',
55
+ books: [],
56
+ active_book_index: 0,
57
+ }), 'utf-8');
58
+
59
+ // Write story archive files
60
+ const archive = join(projectRoot, '故事档案');
61
+ const writeJson = (filepath, data) =>
62
+ writeFileSync(filepath, JSON.stringify(data), 'utf-8');
63
+
64
+ writeJson(join(archive, '_story_state.json'), {
65
+ schema_version: '2.0.0', last_modified: now,
66
+ project: { name: '', created_at: now, last_modified: now, genre: '', target_words: 0, target_chapters: 0, platform: '', platform_other: '' },
67
+ phase: 'uninitialized', phase_history: [{ phase: 'uninitialized', at: now }],
68
+ state_machine: { current_volume: 1, last_chapter_committed: 0, next_chapter_to_write: 1, chapters_total_words: 0, committed_chapters: [], pause_reason: null, paused_at: null },
69
+ author_intent: { style: 'xiaobai', pacing: 'medium', pov: 'third', target_audience: 'general', preferences: [], hard_constraints: [], anti_tropes: [], core_summary: '', core_conflict: '', reader_promise: '' },
70
+ active_book: { project_root: '.', set_at: now },
71
+ });
72
+
73
+ writeJson(join(archive, '_timeline.json'), {
74
+ schema_version: '2.0.0', last_modified: now,
75
+ world_clock: { start_date: now, current_date: now, calendar_unit: 'day' },
76
+ events: [], character_status_at_chapter: {}, dead_characters: [], fact_log: [], discoveries: [],
77
+ });
78
+
79
+ writeJson(join(archive, '_character_state.json'), {
80
+ schema_version: '2.0.0', last_modified: now,
81
+ characters: {}, info_boundary_log: [],
82
+ });
83
+
84
+ writeJson(join(archive, '_foreshadows.json'), {
85
+ schema_version: '2.0.0', last_modified: now,
86
+ foreshadows: [], type_distribution: {}, open_count: 0,
87
+ });
88
+
89
+ writeJson(join(archive, '_chapter_index.json'), {
90
+ schema_version: '2.0.0', last_modified: now,
91
+ chapters: [],
92
+ });
93
+
94
+ // Write author intent markdown
95
+ const intentMd = [
96
+ '# 作者长期意图(Author Intent)',
97
+ '',
98
+ '> 由 fancy-bootstrap 自动生成。',
99
+ '> **注意**:书名/题材/平台/字数等信息在 fancy-topic 阶段填入。',
100
+ '',
101
+ '## 项目信息',
102
+ '> ⚠️ 以下由 fancy-topic 填入:书名 / 题材 / 平台 / 目标字数 / 目标章数',
103
+ '',
104
+ '## 故事核心',
105
+ '> ⚠️ 由 fancy-topic 填入:一句话 / 核心冲突 / 读者承诺',
106
+ '',
107
+ '## 风格基调',
108
+ '- 文风:xiaobai(待 fancy-bible 细化)',
109
+ '- 节奏:medium(待 fancy-bible 细化)',
110
+ '- POV:third(待 fancy-bible 细化)',
111
+ '',
112
+ `## 用户已声明的偏好`,
113
+ `- ${now}:项目目录创建(fancy-bootstrap)`,
114
+ ].join('\n');
115
+ writeFileSync(join(archive, '_author_intent.md'), intentMd, 'utf-8');
116
+
117
+ return { ok: true };
118
+ } catch (err) {
119
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
120
+ }
121
+ }
@@ -0,0 +1,226 @@
1
+ /**
2
+ * commands/scan.js — /fancy-scan command
3
+ *
4
+ * Registers /fancy-scan into ctx.commands.
5
+ * Guides user through platform → rank selection, then runs in-process scraper.
6
+ */
7
+
8
+ import { existsSync } from 'fs';
9
+ import { join } from 'path';
10
+ import { notify, llmFollowup } from '../../infra.js';
11
+ import { runScans } from './scraper.js';
12
+ import * as qidian from './platforms/qidian.js';
13
+ import * as fanqie from './platforms/fanqie.js';
14
+ import * as jinjiang from './platforms/jinjiang.js';
15
+ import * as qimao from './platforms/qimao.js';
16
+
17
+ export const inject = ['commands', 'userQuestions'];
18
+
19
+ const PLATFORM_TABLE = [
20
+ ['1', 'qidian', '起点'],
21
+ ['2', 'fanqie', '番茄'],
22
+ ['3', 'jinjiang', '晋江'],
23
+ ['4', 'qimao', '七猫'],
24
+ ];
25
+
26
+ function platformLabel(platform) {
27
+ const row = PLATFORM_TABLE.find(r => r[1] === platform);
28
+ return row ? row[2] : platform;
29
+ }
30
+
31
+ const scan = {
32
+ platformList() {
33
+ return (
34
+ '📋 支持的平台:\n' +
35
+ PLATFORM_TABLE.map(r => ` ${r[0]}. ${r[2]}`).join('\n')
36
+ );
37
+ },
38
+
39
+ askPlatform() {
40
+ return { question: '📊 请选择要扫的平台(输入编号 1-4):' };
41
+ },
42
+
43
+ rankOptions(platform) {
44
+ const scraper = [qidian, fanqie, jinjiang, qimao].find(p => p.platform === platform);
45
+ if (!scraper) return [];
46
+ return scraper.rankList.map(r => ({ id: r.id, label: r.label }));
47
+ },
48
+
49
+ askRankList(platform) {
50
+ const pLabel = platformLabel(platform);
51
+ const opts = scan.rankOptions(platform);
52
+ return {
53
+ question: `${pLabel} 有多个榜单,支持多选。请选择要采集的榜单:`,
54
+ hideCustomInput: true,
55
+ multiSelect: true,
56
+ options: opts,
57
+ };
58
+ },
59
+
60
+ notify({ type, platform, files, topicFile, projectRoot }) {
61
+ switch (type) {
62
+ case 'not_initialized':
63
+ return '⚠️ 当前目录尚未初始化。请先运行 /fancy-bootstrap 进行初始化。';
64
+ case 'invalid_choice':
65
+ return '❌ 无效选择,请输入 1-4 的编号。';
66
+ case 'cancelled':
67
+ return '🚫 已取消。';
68
+ case 'handover':
69
+ return (
70
+ `📊 采集完成,正在将数据交给 LLM 进行分析...\n` +
71
+ `📁 生成文件:\n${(files || []).map(f => '- ' + f.replace(projectRoot + '/', '')).join('\n')}\n` +
72
+ `💡 分析报告将写入:${(topicFile || '').replace(projectRoot + '/', '')}`
73
+ );
74
+ default:
75
+ return String(type);
76
+ }
77
+ },
78
+
79
+ llmAnalysisPrompt({ platform, date, files, topicFile, projectRoot }) {
80
+ const pLabel = platformLabel(platform);
81
+ const fileList = (files || [])
82
+ .map(f => '- ' + f.replace(projectRoot + '/', ''))
83
+ .join('\n');
84
+
85
+ return [
86
+ `## 扫榜数据分析任务`,
87
+ ``,
88
+ `平台:${pLabel}`,
89
+ `时间:${date || ''}`,
90
+ ``,
91
+ `已生成的文件:`,
92
+ fileList,
93
+ ``,
94
+ `请执行以下步骤:`,
95
+ `0. **禁止**使用覆盖的方式写文件,**必须**使用追加的方式`,
96
+ `1. 读取上述生成的原始数据文件`,
97
+ `2. 分析爆款书籍的题材、卖点、节奏、人设等特征`,
98
+ `3. 生成扫榜报告(包含:市场趋势、热门题材分析、用户画像、竞争度评估)`,
99
+ `4. 给出 3-5 个可行的选题方向建议`,
100
+ `5. 将完整分析报告追加写入:${topicFile}(如文件不存在则先新建再追加)`,
101
+ ``,
102
+ `报告要求:`,
103
+ `- 客观分析数据,不臆测`,
104
+ `- 选题建议要有差异化竞争力`,
105
+ `- 报告语言:中文`,
106
+ `- 以追加方式写入目标文件,不要覆盖现有内容`,
107
+ ].join('\n');
108
+ },
109
+ };
110
+
111
+ const SCRAPERS = { qidian, fanqie, jinjiang, qimao };
112
+
113
+ /**
114
+ * @param {object} ctx - Cordis plugin context
115
+ */
116
+ export function apply(ctx) {
117
+ ctx.commands.register({
118
+ name: 'fancy-scan',
119
+ description: '📊 扫榜分析 ( usage: /fancy-scan )',
120
+ handler: async (invocation) => {
121
+ const session = invocation.agent.session;
122
+ const { signal } = invocation;
123
+
124
+ // ---------- check project root ----------
125
+ const projectRoot = process.cwd();
126
+ if (!existsSync(join(projectRoot, '.fancy-deployed'))) {
127
+ notify(session, scan.notify({ type: 'not_initialized' }));
128
+ return { kind: 'success', text: '' };
129
+ }
130
+
131
+ // ---------- platform selection ----------
132
+ notify(session, scan.platformList());
133
+
134
+ const platformResult = await ctx.userQuestions.ask({
135
+ questions: [scan.askPlatform()],
136
+ signal,
137
+ });
138
+ const answer = platformResult.answers[0]?.custom?.trim();
139
+ if (!answer) {
140
+ notify(session, scan.notify({ type: 'cancelled' }));
141
+ return { kind: 'success', text: '' };
142
+ }
143
+ const numMatch = answer.match(/^(\d+)$/);
144
+ const row = numMatch ? PLATFORM_TABLE.find(r => r[0] === numMatch[1]) : null;
145
+ if (!row) {
146
+ notify(session, scan.notify({ type: 'invalid_choice' }));
147
+ return { kind: 'success', text: '' };
148
+ }
149
+ const platform = row[1];
150
+
151
+ // ---------- rank selection (multi-select) ----------
152
+ const rankResult = await ctx.userQuestions.ask({
153
+ questions: [scan.askRankList(platform)],
154
+ signal,
155
+ });
156
+ const selected = rankResult.answers[0]?.selected ?? [];
157
+ const custom = rankResult.answers[0]?.custom?.trim();
158
+
159
+ const rawChoices = selected.length > 0 ? selected : (custom ? [custom] : []);
160
+ if (rawChoices.length === 0) {
161
+ notify(session, scan.notify({ type: 'cancelled' }));
162
+ return { kind: 'success', text: '' };
163
+ }
164
+
165
+ const scraper = SCRAPERS[platform];
166
+ const platformRanks = scraper.rankList;
167
+
168
+ const isAll = rawChoices.includes('全选') || rawChoices.includes('__all__');
169
+ const rankIds = isAll
170
+ ? platformRanks.filter(r => r.id !== '__all__').map(r => r.id)
171
+ : rawChoices;
172
+
173
+ // Resolve id → full rank entry, dedup
174
+ const rankEntries = [];
175
+ const seen = new Set();
176
+ for (const id of rankIds) {
177
+ const entry = platformRanks.find(r => r.id === id || r.label === id);
178
+ if (entry) {
179
+ const key = `${entry.channel}__${entry.type}`;
180
+ if (!seen.has(key)) { seen.add(key); rankEntries.push(entry); }
181
+ }
182
+ }
183
+
184
+ if (rankEntries.length === 0) {
185
+ notify(session, scan.notify({ type: 'invalid_choice' }));
186
+ return { kind: 'success', text: '' };
187
+ }
188
+
189
+ // ---------- in-process scraping (no subprocess) ----------
190
+ const { totalBooks, totalFiles, failedRanks, lastReceipt } = await runScans(
191
+ ctx,
192
+ projectRoot,
193
+ rankEntries,
194
+ signal,
195
+ );
196
+
197
+ // ---------- output results ----------
198
+ if (lastReceipt && totalFiles > 0) {
199
+ notify(session, scan.notify({
200
+ type: 'handover',
201
+ platform,
202
+ files: lastReceipt.scan_files,
203
+ topicFile: lastReceipt.topic_decision || '',
204
+ projectRoot,
205
+ }));
206
+ const prompt = scan.llmAnalysisPrompt({
207
+ platform,
208
+ date: lastReceipt.date || '',
209
+ files: lastReceipt.scan_files,
210
+ topicFile: lastReceipt.topic_decision || '',
211
+ projectRoot,
212
+ });
213
+ llmFollowup(invocation, prompt);
214
+ }
215
+
216
+ if (failedRanks.length > 0) {
217
+ const lines = failedRanks.map(({ rank, err }) => {
218
+ return `• ${rank.label}:${err}`;
219
+ });
220
+ notify(session, `⚠️ 以下榜单采集失败(共 ${failedRanks.length} 项):\n${lines.join('\n')}`);
221
+ }
222
+
223
+ return { kind: 'success', text: '' };
224
+ },
225
+ });
226
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * fanqie.js — 番茄小说排行榜采集
3
+ *
4
+ * 策略(优先级递减):
5
+ * 1. 移动端页面(__INITIAL_STATE__ 内嵌数据)— 无需字体解密
6
+ * 2. Playwright 浏览器 — 仅作为 fallback
7
+ *
8
+ * Playwright 通过 ctx.effect() 注册生命周期,Ctrl+C 时自动清理。
9
+ * 使用 browserContext({ signal }) 确保 page.evaluate 也能响应中止信号。
10
+ */
11
+
12
+ export const platform = 'fanqie';
13
+ export const label = '番茄';
14
+
15
+ export const rankList = [
16
+ { id: '1_2', label: '男频阅读榜', channel: '1', type: '2' },
17
+ { id: '1_1', label: '男频新书榜', channel: '1', type: '1' },
18
+ { id: '0_2', label: '女频阅读榜', channel: '0', type: '2' },
19
+ { id: '0_1', label: '女频新书榜', channel: '0', type: '1' },
20
+ ];
21
+
22
+ // 移动端页面(__INITIAL_STATE__ 内嵌数据)
23
+ const MOBILE_BASE = 'https://m.reader.qq.com';
24
+
25
+ /**
26
+ * @param {object} ctx
27
+ * @param {string} channel
28
+ * @param {string} type
29
+ * @param {AbortSignal} signal
30
+ */
31
+ async function mobileFetch(ctx, channel, type, signal) {
32
+ const url = `${MOBILE_BASE}/rank/${channel}_${type}.html`;
33
+ let timerDispose;
34
+ const timeout = new Promise((_, reject) => {
35
+ timerDispose = ctx.timeout(() => reject(new Error('timeout')), 15000);
36
+ });
37
+ try {
38
+ const res = await Promise.race([
39
+ fetch(url, {
40
+ headers: {
41
+ 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)',
42
+ 'Accept': 'text/html',
43
+ },
44
+ signal,
45
+ }),
46
+ timeout,
47
+ ]);
48
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
49
+ const html = await res.text();
50
+ const m = html.match(/window\.__INITIAL_STATE__\s*=\s*(\{[\s\S]*?\});/);
51
+ if (!m) throw new Error('__INITIAL_STATE__ not found');
52
+ try {
53
+ return JSON.parse(m[1]);
54
+ } catch (_) {
55
+ throw new Error('__INITIAL_STATE__ JSON parse failed');
56
+ }
57
+ } finally {
58
+ try { timerDispose?.(); } catch (_) {}
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Scrape one rank list. Tries mobile → Playwright.
64
+ * @param {string} rankId - e.g. '1_2'
65
+ * @param {object} ctx - Cordis plugin context
66
+ * @param {AbortSignal} signal
67
+ * @returns {Promise<{ ok: boolean, books: object[], error?: string }>}
68
+ */
69
+ export async function scrapeRank(rankId, ctx, signal) {
70
+ const [channel, type] = rankId.split('_');
71
+
72
+ // 策略1: 移动端页面
73
+ try {
74
+ const data = await mobileFetch(ctx, channel, type, signal);
75
+ const books = parseMobileData(data);
76
+ if (books.length > 0) return { ok: true, books };
77
+ } catch (_) { /* try next strategy */ }
78
+
79
+ // 策略2: Playwright(通过 ctx.effect 注册生命周期,Ctrl+C 自动清理)
80
+ let browser;
81
+ const disposeEffect = ctx.effect(() => {
82
+ return () => { browser?.close(); };
83
+ }, 'fanqie: browser cleanup');
84
+
85
+ try {
86
+ const { chromium } = await import('playwright-core');
87
+ browser = await chromium.launch({ headless: true });
88
+ const context = await browser.newContext({ signal });
89
+ const page = await context.newPage();
90
+ const url = `https://www.fanqie.com/rank/${channel}_${type}/`;
91
+
92
+ await page.goto(url, { waitUntil: 'networkidle', signal });
93
+
94
+ const books = await page.evaluate(() => {
95
+ const s = window.__INITIAL_STATE__ || {};
96
+ let list = null;
97
+ for (const c of [s.rank, s.rankData, s.page]) {
98
+ if (c?.book_list) { list = c.book_list; break; }
99
+ if (c?.bookList) { list = c.bookList; break; }
100
+ if (c?.rankList) { list = c.rankList; break; }
101
+ }
102
+ if (!list || !Array.isArray(list)) return null;
103
+ return list.map((b, i) => ({
104
+ rank: b.rank ?? (i + 1),
105
+ title: b.title ?? b.bookName ?? '',
106
+ author: b.author ?? '',
107
+ category: '',
108
+ words: b.wordCount ?? 0,
109
+ intro: (b.description ?? '').slice(0, 150),
110
+ cover: b.coverUrl ?? '',
111
+ bookId: b.bookId ?? '',
112
+ }));
113
+ });
114
+
115
+ // 嵌套 try-finally:context.close() 失败不影响 disposeEffect 执行
116
+ try {
117
+ await context.close();
118
+ } finally {
119
+ disposeEffect();
120
+ }
121
+
122
+ if (books && books.length > 0) return { ok: true, books };
123
+ } catch (_) { /* exhausted */ }
124
+
125
+ return { ok: false, books: [], error: 'all strategies exhausted' };
126
+
127
+ return { ok: false, books: [], error: 'all strategies exhausted' };
128
+ }
129
+
130
+ function parseMobileData(data) {
131
+ if (!data || typeof data !== 'object') return [];
132
+ let list = null;
133
+ const cands = [data.rank?.book_list, data.rank?.bookList, data.page?.bookList, data];
134
+ for (const c of cands) {
135
+ if (Array.isArray(c) && c.length > 0) { list = c; break; }
136
+ }
137
+ if (!list) return [];
138
+ return list.map((b, i) => ({
139
+ rank: b.rank ?? (i + 1),
140
+ title: b.title ?? b.name ?? '',
141
+ author: b.author ?? '',
142
+ category: '',
143
+ words: b.wordCount ?? 0,
144
+ intro: (b.description ?? '').slice(0, 150),
145
+ cover: b.cover ?? '',
146
+ bookId: b.bookId ?? '',
147
+ }));
148
+ }