@waterwx/dsh-novel-forge 1.3.0 → 1.3.1-alpha
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/README.md +287 -279
- package/lib/client.js +56031 -12138
- package/lib/index.js +14206 -5585
- package/lib/types/action-guard.d.ts +61 -0
- package/lib/types/ai-scan.d.ts +36 -0
- package/lib/types/assets.d.ts +24 -9
- package/lib/types/assistant.d.ts +29 -0
- package/lib/types/author-assets.d.ts +25 -0
- package/lib/types/bookshelf.d.ts +11 -1
- package/lib/types/client/api.d.ts +186 -10
- package/lib/types/client/docx.d.ts +2 -0
- package/lib/types/client/index.d.ts +1 -1
- package/lib/types/client/llmLive.d.ts +31 -0
- package/lib/types/client/locales.d.ts +28 -67
- package/lib/types/client/panel/AdaptModeView.d.ts +5 -0
- package/lib/types/client/panel/AiTag.d.ts +1 -0
- package/lib/types/client/panel/AssistantTab.d.ts +4 -1
- package/lib/types/client/panel/AuthorAssetsView.d.ts +4 -0
- package/lib/types/client/panel/AuthorHome.d.ts +34 -0
- package/lib/types/client/panel/BookAnalysisView.d.ts +4 -0
- package/lib/types/client/panel/CmdPalette.d.ts +12 -0
- package/lib/types/client/panel/CreateBookView.d.ts +5 -1
- package/lib/types/client/panel/DirectorView.d.ts +7 -0
- package/lib/types/client/panel/GlobalAssetLibraryView.d.ts +4 -0
- package/lib/types/client/panel/IdeaInspirationView.d.ts +6 -0
- package/lib/types/client/panel/ImportModal.d.ts +7 -0
- package/lib/types/client/panel/KnowledgeBaseView.d.ts +4 -0
- package/lib/types/client/panel/LiveFeedLog.d.ts +13 -0
- package/lib/types/client/panel/MarketRadarView.d.ts +5 -0
- package/lib/types/client/panel/ModelManager.d.ts +14 -0
- package/lib/types/client/panel/NovelPanel.d.ts +6 -1
- package/lib/types/client/panel/PageHeader.d.ts +18 -0
- package/lib/types/client/panel/ProgressConsole.d.ts +17 -0
- package/lib/types/client/panel/ProgressHomeView.d.ts +5 -0
- package/lib/types/client/panel/PromptSlotsView.d.ts +11 -0
- package/lib/types/client/panel/ReasoningSection.d.ts +10 -0
- package/lib/types/client/panel/SettingsView.d.ts +19 -0
- package/lib/types/client/panel/ShelfView.d.ts +12 -1
- package/lib/types/client/panel/SubPage.d.ts +27 -0
- package/lib/types/client/panel/TensionView.d.ts +18 -0
- package/lib/types/client/panel/TimelineView.d.ts +18 -0
- package/lib/types/client/panel/helpers.d.ts +5 -3
- package/lib/types/client/panel/toast.d.ts +2 -0
- package/lib/types/client/panel/views.d.ts +25 -7
- package/lib/types/client/run-status.d.ts +19 -0
- package/lib/types/client/text.d.ts +9 -0
- package/lib/types/docx-core.d.ts +16 -0
- package/lib/types/docx.d.ts +2 -3
- package/lib/types/engine.d.ts +186 -45
- package/lib/types/global-assets.d.ts +6 -0
- package/lib/types/index.d.ts +22 -8
- package/lib/types/llm-live.d.ts +75 -0
- package/lib/types/llm-retry.d.ts +38 -0
- package/lib/types/market-radar-scan.d.ts +19 -0
- package/lib/types/market-radar-sources.d.ts +31 -0
- package/lib/types/migrate-output.d.ts +25 -0
- package/lib/types/novel-context.d.ts +80 -0
- package/lib/types/prompt-slots.d.ts +57 -0
- package/lib/types/prompt-utils.d.ts +35 -0
- package/lib/types/prompting.d.ts +10 -0
- package/lib/types/protocol.d.ts +1356 -166
- package/lib/types/review-policy.d.ts +97 -0
- package/lib/types/revision.d.ts +76 -0
- package/lib/types/routes.d.ts +4 -0
- package/lib/types/run.d.ts +11 -1
- package/lib/types/snapshots.d.ts +46 -0
- package/lib/types/stage-contract.d.ts +60 -0
- package/lib/types/tension.d.ts +53 -0
- package/lib/types/timeline.d.ts +37 -0
- package/package.json +52 -27
- package/scripts/patch-schemastery.mjs +47 -0
- package/src/action-guard.ts +127 -0
- package/src/ai-scan.ts +179 -0
- package/src/assets.ts +1806 -520
- package/src/assistant.ts +1098 -770
- package/src/author-assets.ts +149 -0
- package/src/bookshelf.ts +187 -151
- package/src/client/api.ts +378 -21
- package/src/client/docx.ts +37 -69
- package/src/client/index.ts +3 -1
- package/src/client/llmLive.ts +111 -0
- package/src/client/locales.ts +28 -253
- package/src/client/mount.tsx +1 -1
- package/src/client/panel/AdaptModeView.tsx +535 -0
- package/src/client/panel/AiTag.tsx +7 -0
- package/src/client/panel/AssetsTab.tsx +448 -458
- package/src/client/panel/AssistantTab.tsx +355 -301
- package/src/client/panel/AuthorAssetsView.tsx +206 -0
- package/src/client/panel/AuthorHome.tsx +309 -0
- package/src/client/panel/BookAnalysisView.tsx +63 -0
- package/src/client/panel/CmdPalette.tsx +102 -0
- package/src/client/panel/CreateBookView.tsx +366 -348
- package/src/client/panel/DirectorView.tsx +200 -0
- package/src/client/panel/GlobalAssetLibraryView.tsx +168 -0
- package/src/client/panel/IdeaInspirationView.tsx +76 -0
- package/src/client/panel/ImportModal.tsx +303 -0
- package/src/client/panel/KnowledgeBaseView.tsx +125 -0
- package/src/client/panel/LiveFeedLog.tsx +204 -0
- package/src/client/panel/MarketRadarView.tsx +377 -0
- package/src/client/panel/ModelManager.tsx +205 -0
- package/src/client/panel/NovelPanel.tsx +3135 -2651
- package/src/client/panel/PageHeader.tsx +37 -0
- package/src/client/panel/ProgressConsole.tsx +63 -0
- package/src/client/panel/PromptSlotsView.tsx +97 -0
- package/src/client/panel/ReaderView.tsx +306 -306
- package/src/client/panel/ReasoningSection.tsx +31 -0
- package/src/client/panel/RunPanel.tsx +223 -204
- package/src/client/panel/SettingsView.tsx +340 -0
- package/src/client/panel/ShelfView.tsx +266 -236
- package/src/client/panel/SubPage.tsx +42 -0
- package/src/client/panel/TensionView.tsx +212 -0
- package/src/client/panel/TimelineView.tsx +208 -0
- package/src/client/panel/WorldTab.tsx +202 -202
- package/src/client/panel/helpers.ts +7 -5
- package/src/client/panel/panel.module.css +5764 -2941
- package/src/client/panel/toast.ts +51 -0
- package/src/client/panel/views.tsx +401 -314
- package/src/client/run-status.ts +143 -0
- package/src/client/sidebar-entry.ts +1 -1
- package/src/client/text.ts +28 -0
- package/src/docx-core.ts +53 -0
- package/src/docx.ts +44 -83
- package/src/engine.ts +4672 -2943
- package/src/global-assets.ts +59 -0
- package/src/index.ts +89 -33
- package/src/llm-live.ts +260 -0
- package/src/llm-retry.ts +77 -0
- package/src/market-radar-scan.ts +41 -0
- package/src/market-radar-sources.ts +173 -0
- package/src/migrate-output.ts +76 -0
- package/src/novel-context.ts +286 -0
- package/src/prompt-slots.ts +125 -0
- package/src/prompting.ts +26 -0
- package/src/protocol.ts +1382 -158
- package/src/review-policy.ts +161 -0
- package/src/revision.ts +283 -0
- package/src/routes.ts +1934 -434
- package/src/run.ts +81 -20
- package/src/snapshots.ts +174 -0
- package/src/stage-contract.ts +189 -0
- package/src/tension.ts +251 -0
- package/src/timeline.ts +159 -0
- package/lib/client.js.map +0 -1
- package/lib/index.js.map +0 -1
- package/lib/types/comic-presets.d.ts +0 -12
- package/src/client/panel/BookshelfBar.tsx +0 -170
- package/src/comic-presets.ts +0 -67
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 热门题材雷达:真实榜单扫榜(聚合各平台榜单源,容错)。
|
|
3
|
+
*/
|
|
4
|
+
import { collectMarketSource, MARKET_RADAR_SOURCES, type CollectedRankingItem, type MarketRadarPlatform, type MarketRadarListSource } from './market-radar-sources.ts'
|
|
5
|
+
|
|
6
|
+
export interface MarketRadarGroup {
|
|
7
|
+
platform: MarketRadarPlatform
|
|
8
|
+
platformLabel: string
|
|
9
|
+
listKey: string
|
|
10
|
+
listLabel: string
|
|
11
|
+
status: 'ok' | 'error'
|
|
12
|
+
error?: string
|
|
13
|
+
items: CollectedRankingItem[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface MarketScanResult {
|
|
17
|
+
scannedAt: string
|
|
18
|
+
groups: MarketRadarGroup[]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let lastScan: MarketScanResult | null = null
|
|
22
|
+
|
|
23
|
+
export function getLastMarketScan(): MarketScanResult | null {
|
|
24
|
+
return lastScan
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function scanMarketRanking(platforms?: string[]): Promise<MarketScanResult> {
|
|
28
|
+
const wanted = new Set<string>(platforms?.length ? platforms : ['fanqie', 'qidian', 'jinjiang'])
|
|
29
|
+
const sources = MARKET_RADAR_SOURCES.filter(s => wanted.has(s.platform))
|
|
30
|
+
const groups: MarketRadarGroup[] = []
|
|
31
|
+
await Promise.all(sources.map(async (source: MarketRadarListSource) => {
|
|
32
|
+
try {
|
|
33
|
+
const items = await collectMarketSource(source)
|
|
34
|
+
groups.push({ platform: source.platform, platformLabel: source.platformLabel, listKey: source.listKey, listLabel: source.listLabel, status: 'ok', items })
|
|
35
|
+
} catch (error) {
|
|
36
|
+
groups.push({ platform: source.platform, platformLabel: source.platformLabel, listKey: source.listKey, listLabel: source.listLabel, status: 'error', error: (error as Error).message, items: [] })
|
|
37
|
+
}
|
|
38
|
+
}))
|
|
39
|
+
lastScan = { scannedAt: new Date().toISOString(), groups }
|
|
40
|
+
return lastScan
|
|
41
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「热门题材雷达」真实榜单抓取源(公开榜单页面的地址与选择器,属事实性配置)。
|
|
3
|
+
* 用全局 fetch 抓取公开榜单移动版页面,正则抽取元数据;无第三方依赖。
|
|
4
|
+
*/
|
|
5
|
+
export type MarketRadarPlatform = 'fanqie' | 'qidian' | 'jinjiang'
|
|
6
|
+
|
|
7
|
+
export interface MarketRadarListSource {
|
|
8
|
+
platform: MarketRadarPlatform
|
|
9
|
+
platformLabel: string
|
|
10
|
+
listKey: string
|
|
11
|
+
listLabel: string
|
|
12
|
+
channel: string
|
|
13
|
+
sourceUrl: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface CollectedRankingItem {
|
|
17
|
+
rank: number
|
|
18
|
+
title: string
|
|
19
|
+
author?: string
|
|
20
|
+
category?: string
|
|
21
|
+
tags: string[]
|
|
22
|
+
synopsis?: string
|
|
23
|
+
heatLabel?: string
|
|
24
|
+
serialStatus?: string
|
|
25
|
+
sourceUrl: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const MARKET_RADAR_SOURCES: MarketRadarListSource[] = [
|
|
29
|
+
{ platform: 'fanqie', platformLabel: '番茄小说', listKey: 'reading', listLabel: '阅读榜', channel: 'general', sourceUrl: 'https://fanqienovel.com/rank' },
|
|
30
|
+
{ platform: 'fanqie', platformLabel: '番茄小说', listKey: 'new_book', listLabel: '新书榜', channel: 'general', sourceUrl: 'https://fanqienovel.com/rank/1_1' },
|
|
31
|
+
{ platform: 'qidian', platformLabel: '起点中文网', listKey: 'hotsales', listLabel: '畅销榜', channel: 'male', sourceUrl: 'https://m.qidian.com/rank/hotsales/' },
|
|
32
|
+
{ platform: 'qidian', platformLabel: '起点中文网', listKey: 'monthly_ticket', listLabel: '月票榜', channel: 'male', sourceUrl: 'https://m.qidian.com/rank/yuepiao/' },
|
|
33
|
+
{ platform: 'qidian', platformLabel: '起点中文网', listKey: 'new_book', listLabel: '新书榜', channel: 'male', sourceUrl: 'https://m.qidian.com/rank/' },
|
|
34
|
+
{ platform: 'jinjiang', platformLabel: '晋江文学城', listKey: 'monthly', listLabel: '月度榜', channel: 'female', sourceUrl: 'https://m.jjwxc.net/rank/naturalmore/5' },
|
|
35
|
+
{ platform: 'jinjiang', platformLabel: '晋江文学城', listKey: 'quarterly', listLabel: '季度榜', channel: 'female', sourceUrl: 'https://m.jjwxc.net/rank/naturalmore/6' },
|
|
36
|
+
{ platform: 'jinjiang', platformLabel: '晋江文学城', listKey: 'new_author', listLabel: '新晋作者榜', channel: 'female', sourceUrl: 'https://m.jjwxc.net/rank/naturalmore/29' },
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
const NAMED_ENTITIES: Record<string, string> = {
|
|
40
|
+
amp: '&', apos: "'", gt: '>', lt: '<', nbsp: ' ', quot: '"',
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function decodeEntities(value: string): string {
|
|
44
|
+
return value.replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (_, key: string) => {
|
|
45
|
+
if (key.startsWith('#')) {
|
|
46
|
+
const hexadecimal = key[1]?.toLowerCase() === 'x'
|
|
47
|
+
const code = Number.parseInt(key.slice(hexadecimal ? 2 : 1), hexadecimal ? 16 : 10)
|
|
48
|
+
return Number.isFinite(code) ? String.fromCodePoint(code) : ''
|
|
49
|
+
}
|
|
50
|
+
return NAMED_ENTITIES[key.toLowerCase()] ?? ''
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function plainText(value: string | undefined): string {
|
|
55
|
+
return decodeEntities((value ?? '').replace(/<[^>]*>/g, ' ')).replace(/\s+/g, ' ').trim()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function absoluteUrl(base: string, value: string): string {
|
|
59
|
+
return new URL(value.startsWith('//') ? `https:${value}` : value, base).toString()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function extractTags(synopsis: string): string[] {
|
|
63
|
+
const bracketed = synopsis.match(/^[【〖\[]([^】〗\]]+)[】〗\]]/g) ?? []
|
|
64
|
+
return bracketed.flatMap((part) => plainText(part).replace(/^[【〖\[]|[】〗\]]$/g, '').split(/[++、|]/))
|
|
65
|
+
.map((tag) => tag.trim()).filter(Boolean).slice(0, 12)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function hasPrivateUseCharacters(value: string | null | undefined): boolean {
|
|
69
|
+
return /[\uE000-\uF8FF]/u.test(value ?? '')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function parseFanqieRanking(html: string, source: MarketRadarListSource): CollectedRankingItem[] {
|
|
73
|
+
const blocks = html.match(/<div class="rank-book-item">[\s\S]*?(?=<div class="rank-book-item">|<\/main>|<footer|<\/body>)/g) ?? []
|
|
74
|
+
return blocks.slice(0, 30).flatMap((block, index) => {
|
|
75
|
+
const titleMatch = block.match(/<div class="title">\s*<a href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/)
|
|
76
|
+
if (!titleMatch) return []
|
|
77
|
+
const synopsis = plainText(block.match(/<div class="desc abstract[^>]*>([\s\S]*?)<\/div>/)?.[1])
|
|
78
|
+
const footer = plainText(block.match(/<div class="book-item-footer">([\s\S]*?)<\/div>/)?.[1])
|
|
79
|
+
return [{
|
|
80
|
+
rank: Number(block.match(/book-item-index"><h1>(\d+)<\/h1>/)?.[1] ?? index + 1),
|
|
81
|
+
title: plainText(titleMatch[2]),
|
|
82
|
+
author: plainText(block.match(/<div class="author">[\s\S]*?<span[^>]*>([\s\S]*?)<\/span>/)?.[1]),
|
|
83
|
+
tags: extractTags(synopsis),
|
|
84
|
+
synopsis: synopsis.slice(0, 800),
|
|
85
|
+
heatLabel: footer.match(/在读[^\s,。]*/)?.[0],
|
|
86
|
+
serialStatus: footer.match(/连载中|已完结/)?.[0],
|
|
87
|
+
sourceUrl: absoluteUrl(source.sourceUrl, titleMatch[1]),
|
|
88
|
+
}]
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function parseFanqieDetail(html: string, item: CollectedRankingItem): CollectedRankingItem {
|
|
93
|
+
const title = plainText(html.match(/<h1[^>]*>([\s\S]*?)<\/h1>/)?.[1])
|
|
94
|
+
const author = plainText(html.match(/author-name-text[^>]*>([\s\S]*?)<\/a>/)?.[1])
|
|
95
|
+
const synopsis = plainText(html.match(/<div class="page-abstract-content"[^>]*>\s*<p[^>]*>([\s\S]*?)<\/p>/)?.[1])
|
|
96
|
+
if (!title || hasPrivateUseCharacters(title)) throw new Error('作品详情页未提供可读书名')
|
|
97
|
+
return {
|
|
98
|
+
...item,
|
|
99
|
+
title,
|
|
100
|
+
author: author && !hasPrivateUseCharacters(author) ? author : undefined,
|
|
101
|
+
synopsis: synopsis && !hasPrivateUseCharacters(synopsis) ? synopsis.slice(0, 800) : undefined,
|
|
102
|
+
tags: synopsis && !hasPrivateUseCharacters(synopsis) ? extractTags(synopsis) : [],
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function parseQidianRanking(html: string, source: MarketRadarListSource): CollectedRankingItem[] {
|
|
107
|
+
const escapedLabel = source.listLabel.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
108
|
+
const itemPattern = new RegExp(`<a[^>]+href="([^"]*\\/book\\/[^"?]+)[^"]*"[^>]*>[\\s\\S]*?<h2[^>]+title="${escapedLabel}第(\\d+)位"[^>]*>([\\s\\S]*?)<\\/h2>[\\s\\S]*?<p[^>]*class="[^"]*subTitle[^"]*"[^>]*>([\\s\\S]*?)<\\/p>[\\s\\S]*?<\\/a>`, 'g')
|
|
109
|
+
return Array.from(html.matchAll(itemPattern)).slice(0, 30).map((match) => {
|
|
110
|
+
const subTitle = plainText(match[4])
|
|
111
|
+
const parts = subTitle.split('·').map((part) => part.trim()).filter(Boolean)
|
|
112
|
+
return {
|
|
113
|
+
rank: Number(match[2]),
|
|
114
|
+
title: plainText(match[3]),
|
|
115
|
+
author: parts[0],
|
|
116
|
+
category: parts[1],
|
|
117
|
+
tags: parts[1] ? [parts[1]] : [],
|
|
118
|
+
heatLabel: parts[2],
|
|
119
|
+
sourceUrl: absoluteUrl(source.sourceUrl, match[1]),
|
|
120
|
+
}
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function parseJinjiangRanking(html: string, source: MarketRadarListSource): CollectedRankingItem[] {
|
|
125
|
+
const items = Array.from(html.matchAll(/<li[^>]*>\s*<a href="(\/book2\/\d+)"[^>]*>([\s\S]*?)<\/a>[\s\S]*?<\/li>/g))
|
|
126
|
+
return items.slice(0, 30).map((match, index) => ({
|
|
127
|
+
rank: index + 1,
|
|
128
|
+
title: plainText(match[2]),
|
|
129
|
+
tags: [],
|
|
130
|
+
sourceUrl: absoluteUrl(source.sourceUrl, match[1]),
|
|
131
|
+
}))
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function fetchHtml(url: string): Promise<string> {
|
|
135
|
+
const response = await fetch(url, {
|
|
136
|
+
headers: { 'user-agent': 'Mozilla/5.0 (compatible; dsh-novel-forge-radar/1.0; public-ranking-metadata-only)' },
|
|
137
|
+
signal: AbortSignal.timeout(20_000),
|
|
138
|
+
})
|
|
139
|
+
if (!response.ok) throw new Error(`榜单页面返回 HTTP ${response.status}`)
|
|
140
|
+
const bytes = await response.arrayBuffer()
|
|
141
|
+
const headerCharset = response.headers.get('content-type')?.match(/charset=([^;]+)/i)?.[1]?.trim()
|
|
142
|
+
const metaProbe = new TextDecoder('latin1').decode(bytes.slice(0, 4096))
|
|
143
|
+
const metaCharset = metaProbe.match(/charset\s*=\s*["']?([\w-]+)/i)?.[1]
|
|
144
|
+
const charset = headerCharset || metaCharset || 'utf-8'
|
|
145
|
+
return new TextDecoder(charset).decode(bytes)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function hydrateFanqieItems(items: CollectedRankingItem[]): Promise<CollectedRankingItem[]> {
|
|
149
|
+
const hydrated: CollectedRankingItem[] = []
|
|
150
|
+
for (let offset = 0; offset < items.length; offset += 4) {
|
|
151
|
+
const batch = await Promise.all(items.slice(offset, offset + 4).map(async (item) => {
|
|
152
|
+
const containsObfuscatedText = [item.title, item.author, item.synopsis].some(hasPrivateUseCharacters)
|
|
153
|
+
if (!containsObfuscatedText) return item
|
|
154
|
+
try { return parseFanqieDetail(await fetchHtml(item.sourceUrl), item) }
|
|
155
|
+
catch { return null }
|
|
156
|
+
}))
|
|
157
|
+
hydrated.push(...batch.filter((item): item is CollectedRankingItem => item !== null))
|
|
158
|
+
}
|
|
159
|
+
return hydrated
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function collectMarketSource(source: MarketRadarListSource): Promise<CollectedRankingItem[]> {
|
|
163
|
+
const html = await fetchHtml(source.sourceUrl)
|
|
164
|
+
const parsers: Record<MarketRadarPlatform, (value: string, item: MarketRadarListSource) => CollectedRankingItem[]> = {
|
|
165
|
+
fanqie: parseFanqieRanking,
|
|
166
|
+
qidian: parseQidianRanking,
|
|
167
|
+
jinjiang: parseJinjiangRanking,
|
|
168
|
+
}
|
|
169
|
+
const parsed = parsers[source.platform](html, source)
|
|
170
|
+
const items = source.platform === 'fanqie' ? await hydrateFanqieItems(parsed) : parsed
|
|
171
|
+
if (items.length === 0) throw new Error('榜单页面结构可能已变化,未识别到公开作品元数据')
|
|
172
|
+
return items
|
|
173
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 输出目录迁移(Migrate Output Dir)— 把一本书/默认输出目录的内容搬到新位置。
|
|
3
|
+
*
|
|
4
|
+
* 安全纪律(数据零丢失排序):
|
|
5
|
+
* 1. copy 全部内容到新目录(源目录始终原样保留);
|
|
6
|
+
* 2. 调用方完成配置写入 / 书架重指向后,最后一步才删源目录;
|
|
7
|
+
* 3. 中途任何失败,最坏情况只是新目录多一份副本,源数据零丢失。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { existsSync, mkdirSync, readdirSync, cpSync, rmSync, statSync } from 'node:fs'
|
|
11
|
+
import { join, resolve, sep } from 'node:path'
|
|
12
|
+
|
|
13
|
+
/** 目录内容清单(相对路径 + 总字节)。 */
|
|
14
|
+
export interface DirContents {
|
|
15
|
+
files: string[]
|
|
16
|
+
bytes: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** 规范化路径用于比较(统一分隔符,Windows 忽略大小写)。 */
|
|
20
|
+
export function normalizeDir(dir: string): string {
|
|
21
|
+
const r = resolve(dir.trim())
|
|
22
|
+
return process.platform === 'win32' ? r.toLowerCase() : r
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 列出目录全部内容(递归;子目录以 `name/` 形式占位列出)。 */
|
|
26
|
+
export function listDirContents(dir: string): DirContents {
|
|
27
|
+
const files: string[] = []
|
|
28
|
+
let bytes = 0
|
|
29
|
+
const walk = (rel: string): void => {
|
|
30
|
+
const abs = rel === '' ? dir : join(dir, rel)
|
|
31
|
+
for (const name of readdirSync(abs, { withFileTypes: true })) {
|
|
32
|
+
const childRel = rel === '' ? name.name : `${rel}/${name.name}`
|
|
33
|
+
if (name.isDirectory()) {
|
|
34
|
+
files.push(`${childRel}/`)
|
|
35
|
+
walk(childRel)
|
|
36
|
+
} else {
|
|
37
|
+
files.push(childRel)
|
|
38
|
+
try { bytes += statSync(join(dir, childRel)).size } catch { /* 竞态删除则跳过 */ }
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (existsSync(dir)) walk('')
|
|
43
|
+
return { files, bytes }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 把 from 全部内容复制到 to(源目录保留,删除由调用方在收尾执行)。
|
|
48
|
+
* 目标必须不存在或为空目录(防覆盖无关数据);禁止互相嵌套的目录对。
|
|
49
|
+
* @returns 复制的顶层条目数。
|
|
50
|
+
*/
|
|
51
|
+
export function copyDirContents(from: string, to: string): number {
|
|
52
|
+
if (!existsSync(from)) throw new Error(`源目录不存在:${from}`)
|
|
53
|
+
const nf = normalizeDir(from)
|
|
54
|
+
const nt = normalizeDir(to)
|
|
55
|
+
if (nf === nt) throw new Error('新目录与当前目录相同')
|
|
56
|
+
// 嵌套防护:copy 进入自身会无限递归 / 自我复制。
|
|
57
|
+
if (nt.startsWith(nf + sep.toLowerCase()) || nf.startsWith(nt + sep.toLowerCase()) ||
|
|
58
|
+
nt.startsWith(nf + '/') || nf.startsWith(nt + '/')) {
|
|
59
|
+
throw new Error('新目录不能是当前目录的子目录(或反之)')
|
|
60
|
+
}
|
|
61
|
+
if (existsSync(to)) {
|
|
62
|
+
const entries = readdirSync(to)
|
|
63
|
+
if (entries.length > 0) throw new Error(`目标目录非空(${entries.length} 个条目),为防覆盖已中止:${to}`)
|
|
64
|
+
} else {
|
|
65
|
+
mkdirSync(to, { recursive: true })
|
|
66
|
+
}
|
|
67
|
+
const entries = readdirSync(from)
|
|
68
|
+
if (entries.length === 0) return 0
|
|
69
|
+
cpSync(from, to, { recursive: true, errorOnExist: true, force: false })
|
|
70
|
+
return entries.length
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 删除源目录(仅复制 + 配置/书架重指向全部成功后调用)。 */
|
|
74
|
+
export function removeDir(dir: string): void {
|
|
75
|
+
if (existsSync(dir)) rmSync(dir, { recursive: true, force: true })
|
|
76
|
+
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 统一上下文组装与相关设定检索
|
|
3
|
+
*
|
|
4
|
+
* 取代各阶段各自拼装 prompt,消除「生成看得到、审稿看不到」的断层。
|
|
5
|
+
* 所有阶段通过 buildChapterContext 获取同一来源的事实与设定。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ProjectState, ChapterPlan, StoryBible, RoleRecord, ChapterFact, Foreshadow, Plotline } from './protocol'
|
|
9
|
+
import { readFileSync, existsSync } from 'node:fs'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
|
|
12
|
+
export interface ChapterContext {
|
|
13
|
+
/** 平台合规红线(永远全量) */
|
|
14
|
+
complianceRedLines: string[]
|
|
15
|
+
/** 本书写作红线(永远全量) */
|
|
16
|
+
bookRedLines: string[]
|
|
17
|
+
/** 当前章相关角色卡(全量信息) */
|
|
18
|
+
relevantRoles: Array<{ name: string; role: string; traits: string[]; goals?: string; knowledge?: string[] }>
|
|
19
|
+
/** 当前章相关道藏规则 */
|
|
20
|
+
relevantWorldRules: string[]
|
|
21
|
+
/** 活跃剧情线 */
|
|
22
|
+
activePlotlines: Plotline[]
|
|
23
|
+
/** 未回收伏笔 */
|
|
24
|
+
activeForeshadows: Foreshadow[]
|
|
25
|
+
/** 上一章结尾原文 */
|
|
26
|
+
prevChapterTail: string
|
|
27
|
+
/** 上一章摘要 */
|
|
28
|
+
prevChapterSummary?: string
|
|
29
|
+
/** 最近事实(近因记忆,top20) */
|
|
30
|
+
recentFacts: string[]
|
|
31
|
+
/** 相关旧事实(按本章 beats 检索,top15) */
|
|
32
|
+
relatedFacts: string[]
|
|
33
|
+
/** 当前卷大纲 */
|
|
34
|
+
currentVolumeOutline: string
|
|
35
|
+
/** 本章 beats */
|
|
36
|
+
beats: string
|
|
37
|
+
/** 本章标题 */
|
|
38
|
+
chapterTitle: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ContextBuildOptions {
|
|
42
|
+
/** 阶段:writing/review/revise/polish/plan */
|
|
43
|
+
stage?: 'writing' | 'review' | 'revise' | 'polish' | 'plan'
|
|
44
|
+
/** 是否注入完整角色卡(false 时只注入角色名+定位) */
|
|
45
|
+
fullRoleCards?: boolean
|
|
46
|
+
/** 相关事实检索上限 */
|
|
47
|
+
relatedFactsLimit?: number
|
|
48
|
+
/** 最近事实数量 */
|
|
49
|
+
recentFactsLimit?: number
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 相关事实检索:trigram 重合度 + 角色名命中加权 + 近因加权
|
|
54
|
+
* 从 generateChapterStream 抽取,扩展为通用函数。
|
|
55
|
+
*/
|
|
56
|
+
export function retrieveRelatedFacts(
|
|
57
|
+
facts: ChapterFact[],
|
|
58
|
+
beatsText: string,
|
|
59
|
+
roleNames: string[],
|
|
60
|
+
limit = 15,
|
|
61
|
+
): string[] {
|
|
62
|
+
if (facts.length === 0) return []
|
|
63
|
+
|
|
64
|
+
const trigrams = (s: string): Set<string> => {
|
|
65
|
+
const out = new Set<string>()
|
|
66
|
+
for (let i = 0; i + 3 <= s.length; i++) {
|
|
67
|
+
const tri = s.slice(i, i + 3)
|
|
68
|
+
if (tri.trim() !== '') out.add(tri)
|
|
69
|
+
}
|
|
70
|
+
return out
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const beatsTri = trigrams(beatsText)
|
|
74
|
+
const beatRoles = roleNames.filter(n => beatsText.includes(n))
|
|
75
|
+
|
|
76
|
+
const recentTexts = new Set(facts.slice(-20).map(f => f.text))
|
|
77
|
+
|
|
78
|
+
return facts
|
|
79
|
+
.map((f, idx) => {
|
|
80
|
+
const head = f.text.slice(0, 80)
|
|
81
|
+
let score = 0
|
|
82
|
+
for (const tri of trigrams(head)) if (beatsTri.has(tri)) score += 1
|
|
83
|
+
if (beatRoles.length > 0) {
|
|
84
|
+
for (const n of beatRoles) if (head.includes(n)) score += 8
|
|
85
|
+
}
|
|
86
|
+
// 近因加权:越新越优先(封顶 40 章)
|
|
87
|
+
score += Math.min(idx, 40) / 10
|
|
88
|
+
return { f, score }
|
|
89
|
+
})
|
|
90
|
+
.filter(x => x.score >= 3)
|
|
91
|
+
.sort((a, b) => b.score - a.score)
|
|
92
|
+
.slice(0, limit)
|
|
93
|
+
.map(x => `[第${x.f.chapterNo}章] ${x.f.text}`)
|
|
94
|
+
.filter(t => !recentTexts.has(t.slice(t.indexOf(']') + 2)))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 相关角色检索:beats 中出现的角色 + 主角/主要反派永远全量
|
|
99
|
+
*/
|
|
100
|
+
export function retrieveRelevantRoles(
|
|
101
|
+
bible: StoryBible | undefined,
|
|
102
|
+
roles: RoleRecord[] | undefined,
|
|
103
|
+
beatsText: string,
|
|
104
|
+
): ChapterContext['relevantRoles'] {
|
|
105
|
+
const result: ChapterContext['relevantRoles'] = []
|
|
106
|
+
const seen = new Set<string>()
|
|
107
|
+
|
|
108
|
+
// 主角/主要反派永远全量
|
|
109
|
+
const alwaysRoles = (bible?.characters ?? []).filter(c =>
|
|
110
|
+
c.role === 'protagonist' || c.role === 'antagonist'
|
|
111
|
+
)
|
|
112
|
+
for (const card of alwaysRoles) {
|
|
113
|
+
if (seen.has(card.name)) continue
|
|
114
|
+
seen.add(card.name)
|
|
115
|
+
result.push({
|
|
116
|
+
name: card.name,
|
|
117
|
+
role: card.role,
|
|
118
|
+
traits: card.traits,
|
|
119
|
+
goals: card.goals !== '' ? card.goals : undefined,
|
|
120
|
+
knowledge: Array.isArray(card.knowledge) && card.knowledge.length > 0 ? card.knowledge : undefined,
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// beats 中出现的角色
|
|
125
|
+
const beatRoles = (bible?.characters ?? []).filter(c => beatsText.includes(c.name))
|
|
126
|
+
for (const card of beatRoles) {
|
|
127
|
+
if (seen.has(card.name)) continue
|
|
128
|
+
seen.add(card.name)
|
|
129
|
+
result.push({
|
|
130
|
+
name: card.name,
|
|
131
|
+
role: card.role,
|
|
132
|
+
traits: card.traits,
|
|
133
|
+
goals: card.goals !== '' ? card.goals : undefined,
|
|
134
|
+
knowledge: Array.isArray(card.knowledge) && card.knowledge.length > 0 ? card.knowledge : undefined,
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// 角色库补充(不在道藏里的)
|
|
139
|
+
for (const r of roles ?? []) {
|
|
140
|
+
if (seen.has(r.name)) continue
|
|
141
|
+
if (!beatsText.includes(r.name) && r.roleLabel !== 'protagonist' && r.roleLabel !== 'antagonist') continue
|
|
142
|
+
seen.add(r.name)
|
|
143
|
+
result.push({
|
|
144
|
+
name: r.name,
|
|
145
|
+
role: r.roleLabel,
|
|
146
|
+
traits: Array.isArray(r.traits) ? r.traits : [],
|
|
147
|
+
goals: r.goals !== undefined && r.goals !== '' ? r.goals : undefined,
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return result
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* 构建章节统一上下文
|
|
156
|
+
*
|
|
157
|
+
* 各阶段调用此函数获取同一来源的事实与设定,消除上下文断层。
|
|
158
|
+
* 检索结果不足时自动回退全量,保证不丢失关键信息。
|
|
159
|
+
*/
|
|
160
|
+
export function buildChapterContext(
|
|
161
|
+
project: ProjectState,
|
|
162
|
+
chapter: ChapterPlan,
|
|
163
|
+
outputDir: string,
|
|
164
|
+
options: ContextBuildOptions = {},
|
|
165
|
+
): ChapterContext {
|
|
166
|
+
const {
|
|
167
|
+
fullRoleCards = true,
|
|
168
|
+
relatedFactsLimit = 15,
|
|
169
|
+
recentFactsLimit = 20,
|
|
170
|
+
} = options
|
|
171
|
+
|
|
172
|
+
const bible = project.bible
|
|
173
|
+
const allFacts = project.facts ?? []
|
|
174
|
+
const roleNames = (project.roles ?? []).map(r => r.name).filter((n): n is string => typeof n === 'string' && n !== '')
|
|
175
|
+
|
|
176
|
+
// 上一章结尾 + 摘要
|
|
177
|
+
let prevChapterTail = ''
|
|
178
|
+
let prevChapterSummary: string | undefined
|
|
179
|
+
const prev = project.chapters.find(c => c.no === chapter.no - 1)
|
|
180
|
+
if (prev?.file !== undefined) {
|
|
181
|
+
try {
|
|
182
|
+
const prevPath = join(outputDir, prev.file)
|
|
183
|
+
if (existsSync(prevPath)) {
|
|
184
|
+
const text = readFileSync(prevPath, 'utf8')
|
|
185
|
+
prevChapterTail = text.replace(/^#\s+.*$/m, '').trim().slice(-900)
|
|
186
|
+
}
|
|
187
|
+
} catch { /* 文件缺失时忽略 */ }
|
|
188
|
+
}
|
|
189
|
+
prevChapterSummary = prev?.summary
|
|
190
|
+
|
|
191
|
+
// 事实:最近 + 相关
|
|
192
|
+
const recentFacts = allFacts.slice(-recentFactsLimit).map(f => `[第${f.chapterNo}章] ${f.text}`)
|
|
193
|
+
const relatedFacts = retrieveRelatedFacts(allFacts, chapter.beats, roleNames, relatedFactsLimit)
|
|
194
|
+
|
|
195
|
+
// 角色:相关角色
|
|
196
|
+
const relevantRoles = retrieveRelevantRoles(bible, project.roles, chapter.beats)
|
|
197
|
+
|
|
198
|
+
// 道藏规则:全部(规则数量通常不多,全量注入更安全)
|
|
199
|
+
const relevantWorldRules = bible?.worldRules ?? []
|
|
200
|
+
|
|
201
|
+
// 活跃剧情线
|
|
202
|
+
const activePlotlines = (project.plotlines ?? []).filter(l =>
|
|
203
|
+
l.status === 'active' || l.status === 'paused'
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
// 未回收伏笔
|
|
207
|
+
const activeForeshadows = (project.foreshadows ?? []).filter(f =>
|
|
208
|
+
f.status === 'planted' || f.status === 'progressing'
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
// 当前卷大纲
|
|
212
|
+
const volumeNo = chapter.volume ?? 0
|
|
213
|
+
const volume = project.volumes?.find(v => v.no === volumeNo)
|
|
214
|
+
const currentVolumeOutline = volume !== undefined
|
|
215
|
+
? `第${volume.no}卷《${volume.title}》:${volume.summary}`
|
|
216
|
+
: ''
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
complianceRedLines: [], // 由调用方注入 COMPLIANCE_REDLINES
|
|
220
|
+
bookRedLines: bible?.redLines ?? [],
|
|
221
|
+
relevantRoles: fullRoleCards ? relevantRoles : relevantRoles.map(r => ({ name: r.name, role: r.role, traits: [] })),
|
|
222
|
+
relevantWorldRules,
|
|
223
|
+
activePlotlines,
|
|
224
|
+
activeForeshadows,
|
|
225
|
+
prevChapterTail,
|
|
226
|
+
prevChapterSummary,
|
|
227
|
+
recentFacts,
|
|
228
|
+
relatedFacts,
|
|
229
|
+
currentVolumeOutline,
|
|
230
|
+
beats: chapter.beats,
|
|
231
|
+
chapterTitle: chapter.title,
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* 将上下文渲染为 prompt 文本块
|
|
237
|
+
* 各阶段可按需选择注入哪些块。
|
|
238
|
+
*/
|
|
239
|
+
export function renderContextBlocks(ctx: ChapterContext): {
|
|
240
|
+
rolesBlock: string
|
|
241
|
+
worldRulesBlock: string
|
|
242
|
+
factsBlock: string
|
|
243
|
+
plotlinesBlock: string
|
|
244
|
+
foreshadowsBlock: string
|
|
245
|
+
continuityBlock: string
|
|
246
|
+
redLinesBlock: string
|
|
247
|
+
} {
|
|
248
|
+
const roleName = { protagonist: '主角', supporting: '配角', antagonist: '反派', other: '其他', female_lead: '女主', female_support: '女配', support: '配角', extra: '路人' } as Record<string, string>
|
|
249
|
+
|
|
250
|
+
const rolesBlock = ctx.relevantRoles.length > 0
|
|
251
|
+
? '==================== 相关角色卡 ====================\n' +
|
|
252
|
+
ctx.relevantRoles.map(r =>
|
|
253
|
+
`- ${r.name}(${roleName[r.role] ?? r.role}):${r.traits.join('、')}${r.goals ? `;目标:${r.goals}` : ''}${r.knowledge ? `\n 已知信息:${r.knowledge.join(';')}(未列出的该角色不知道)` : ''}`
|
|
254
|
+
).join('\n')
|
|
255
|
+
: ''
|
|
256
|
+
|
|
257
|
+
const worldRulesBlock = ctx.relevantWorldRules.length > 0
|
|
258
|
+
? '==================== 世界规则 ====================\n' + ctx.relevantWorldRules.map(r => `- ${r}`).join('\n')
|
|
259
|
+
: ''
|
|
260
|
+
|
|
261
|
+
const factsBlock = (ctx.recentFacts.length > 0 || ctx.relatedFacts.length > 0)
|
|
262
|
+
? '==================== 事实库(最近 + 相关) ====================\n' +
|
|
263
|
+
(ctx.recentFacts.length > 0 ? `【最近 ${ctx.recentFacts.length} 条】\n${ctx.recentFacts.join('\n')}` : '') +
|
|
264
|
+
(ctx.relatedFacts.length > 0 ? `\n【相关旧事实 ${ctx.relatedFacts.length} 条】\n${ctx.relatedFacts.join('\n')}` : '')
|
|
265
|
+
: ''
|
|
266
|
+
|
|
267
|
+
const plotlinesBlock = ctx.activePlotlines.length > 0
|
|
268
|
+
? '==================== 活跃剧情线 ====================\n' +
|
|
269
|
+
ctx.activePlotlines.map(l => `- [${l.kind}${l.status === 'paused' ? '·暂停' : ''}] ${l.name}:${l.goal}${l.progress ? `(进度:${l.progress})` : ''}`).join('\n')
|
|
270
|
+
: ''
|
|
271
|
+
|
|
272
|
+
const foreshadowsBlock = ctx.activeForeshadows.length > 0
|
|
273
|
+
? '==================== 活跃伏笔 ====================\n' +
|
|
274
|
+
ctx.activeForeshadows.map(f => `- [${f.status === 'planted' ? '已埋设' : '推进中'}] ${f.description}${f.targetChapter ? `(预计第${f.targetChapter}章回收)` : ''}`).join('\n')
|
|
275
|
+
: ''
|
|
276
|
+
|
|
277
|
+
const continuityBlock = ctx.prevChapterTail !== ''
|
|
278
|
+
? `==================== 上一章结尾(紧接此状态继续) ====================\n${ctx.prevChapterTail}`
|
|
279
|
+
: ''
|
|
280
|
+
|
|
281
|
+
const redLinesBlock = ctx.bookRedLines.length > 0
|
|
282
|
+
? '==================== 本书红线 ====================\n' + ctx.bookRedLines.map(r => `- ${r}`).join('\n')
|
|
283
|
+
: ''
|
|
284
|
+
|
|
285
|
+
return { rolesBlock, worldRulesBlock, factsBlock, plotlinesBlock, foreshadowsBlock, continuityBlock, redLinesBlock }
|
|
286
|
+
}
|