@cloud411716/fancy-webnovel 2.2.0 → 3.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.
- package/client.js +201 -15
- package/cordis.patch.yml +5 -0
- package/docs/douyin-plugin.md +118 -0
- package/docs/material-library/README.md +404 -0
- package/docs/material-library/import.js +437 -0
- package/docs/material-library/prompt.md +370 -0
- package/docs/material-library/queries.surql +94 -0
- package/docs/material-library/sample.jsonl +14 -0
- package/docs/material-library/schema.surql +46 -0
- package/docs/mcp-profile.example.yml +24 -0
- package/docs/story-graph-design.md +602 -0
- package/docs/story-graph-validation.md +81 -0
- package/docs/workspace-mcp.md +284 -0
- package/index.js +11 -7
- package/lib/atomic-write.js +109 -0
- package/lib/chapter-order.js +100 -0
- package/lib/confirmation.js +80 -0
- package/lib/db-init.js +123 -0
- package/lib/deployed-state.js +11 -11
- package/lib/direction-labels.js +265 -0
- package/lib/error-codes.js +31 -31
- package/lib/infra.js +29 -4
- package/lib/material-db.js +149 -0
- package/lib/path-guard.js +150 -0
- package/lib/projects.js +400 -0
- package/lib/scraper-utils.js +140 -0
- package/lib/surreal-server.js +338 -0
- package/lib/topic-decision.js +860 -0
- package/lib/topic-fields.js +251 -0
- package/lib/topic-schema.js +225 -0
- package/lib/workspace.js +501 -0
- package/package.json +29 -4
- package/plugins/fancy-analyze/analyze-definitions.md +519 -0
- package/plugins/fancy-analyze/index.js +1128 -77
- package/plugins/fancy-analyze/ingest-coverage.js +107 -0
- package/plugins/fancy-analyze/schema-analyze.md +355 -107
- package/plugins/fancy-analyze/scripts/append-summary-chapter.js +612 -0
- package/plugins/fancy-analyze/summary-progress.js +71 -7
- package/plugins/fancy-analyze/templates.js +437 -139
- package/plugins/fancy-bootstrap/index.js +276 -131
- package/plugins/fancy-bootstrap/scripts/init.js +59 -25
- package/plugins/fancy-bootstrap/templates.js +35 -18
- package/plugins/fancy-db/index.js +172 -0
- package/plugins/fancy-douyin/deploy.js +134 -0
- package/plugins/fancy-douyin/index.js +551 -0
- package/plugins/fancy-douyin/prompts/01-analysis-prompt.md +84 -0
- package/plugins/fancy-douyin/scripts/crawl-note.cjs +194 -0
- package/plugins/fancy-douyin/scripts/lib.cjs +197 -0
- package/plugins/fancy-douyin/scripts/scan-account.cjs +128 -0
- package/plugins/fancy-douyin/scripts/start-chrome.cjs +203 -0
- package/plugins/fancy-douyin/state.js +197 -0
- package/plugins/fancy-douyin/templates.js +127 -0
- package/plugins/fancy-export/exporter.js +805 -0
- package/plugins/fancy-export/index.js +63 -0
- package/plugins/fancy-import/import-driver.js +29 -6
- package/plugins/fancy-import/index.js +13 -5
- package/plugins/fancy-import/prompts/01-import-prompt.md +39 -14
- package/plugins/fancy-import/templates.js +17 -8
- package/plugins/fancy-material/exporter.js +153 -0
- package/plugins/fancy-material/genres.js +256 -0
- package/plugins/fancy-material/index.js +835 -0
- package/plugins/fancy-material/inventory.js +143 -0
- package/plugins/fancy-material/kinds.js +313 -0
- package/plugins/fancy-material/queue.js +55 -0
- package/plugins/fancy-material/scripts/query.js +254 -0
- package/plugins/fancy-material/templates.js +258 -0
- package/plugins/fancy-material/tool-deploy.js +138 -0
- package/plugins/fancy-material/validate.js +149 -0
- package/plugins/fancy-project/index.js +133 -0
- package/plugins/fancy-scan/existing-block.js +100 -0
- package/plugins/fancy-scan/index.js +410 -21
- package/plugins/fancy-scan/prompts/01-analysis-prompt.md +302 -0
- package/plugins/fancy-scan/scripts/append-block.js +725 -0
- package/plugins/fancy-scan/scripts/report-validate.js +824 -0
- package/plugins/fancy-scan/scripts/run-scan.js +169 -22
- package/plugins/fancy-scan/scripts/scrapers/fanqie-rank-scraper.js +145 -16
- package/plugins/fancy-scan/scripts/scrapers/jjwxc-rank-scraper.js +179 -49
- package/plugins/fancy-scan/scripts/scrapers/qimao-rank-scraper.js +38 -20
- package/plugins/fancy-scan/templates/blocks.js +155 -0
- package/plugins/fancy-scan/templates/platforms.js +372 -0
- package/plugins/fancy-scan/templates/report-schema.js +90 -0
- package/plugins/fancy-scan/templates.js +545 -146
- package/plugins/fancy-scan/tool-deploy.js +335 -0
- package/plugins/fancy-scan/wait-turn.js +46 -0
- package/plugins/fancy-story-builder/db/README.md +106 -0
- package/plugins/fancy-story-builder/db/client.js +228 -0
- package/plugins/fancy-story-builder/db/commands.js +145 -0
- package/plugins/fancy-story-builder/db/ids.js +39 -0
- package/plugins/fancy-story-builder/db/import.js +798 -0
- package/plugins/fancy-story-builder/db/project.js +299 -0
- package/plugins/fancy-story-builder/db/query.js +361 -0
- package/plugins/fancy-story-builder/db/schema.js +417 -0
- package/plugins/fancy-story-builder/db/stage-sync.js +238 -0
- package/plugins/fancy-story-builder/flesh-output.js +19 -6
- package/plugins/fancy-story-builder/index.js +4105 -396
- package/plugins/fancy-story-builder/patch-driver.js +390 -0
- package/plugins/fancy-story-builder/prompts/01-skeleton-prompt.md +29 -19
- package/plugins/fancy-story-builder/prompts/02-flesh-prompt.md +72 -10
- package/plugins/fancy-story-builder/prompts/03-rhythm-prompt.md +40 -5
- package/plugins/fancy-story-builder/prompts/04-writing-prompt.md +37 -0
- package/plugins/fancy-story-builder/purpose-template.md +42 -66
- package/plugins/fancy-story-builder/references/formulas-skeleton.json +229 -56
- package/plugins/fancy-story-builder/references/genre-profiles.json +3514 -0
- package/plugins/fancy-story-builder/references/glossary.md +15 -6
- package/plugins/fancy-story-builder/references/index.md +97 -6
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/00-index.md +1 -1
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/07-audit.md +25 -7
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/01-character.md +5 -1
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/03-relations.md +6 -2
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/05-audit.md +15 -5
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/00-index.md +1 -1
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/01-positioning.md +2 -1
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/03-entities.md +1 -1
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/06-audit.md +19 -1
- package/plugins/fancy-story-builder/schema-setting.json +1100 -0
- package/plugins/fancy-story-builder/schema-setting.md +201 -293
- package/plugins/fancy-story-builder/scripts/brief-validate.js +164 -0
- package/plugins/fancy-story-builder/scripts/content-quality.js +4 -1
- package/plugins/fancy-story-builder/scripts/flesh-validate.js +135 -12
- package/plugins/fancy-story-builder/scripts/graph-utils.js +146 -47
- package/plugins/fancy-story-builder/scripts/reference-log.js +110 -0
- package/plugins/fancy-story-builder/scripts/rhythm-validate.js +100 -4
- package/plugins/fancy-story-builder/scripts/schema-pages.js +2668 -0
- package/plugins/fancy-story-builder/scripts/semantic-lint.js +5 -1
- package/plugins/fancy-story-builder/scripts/skeleton-validate.js +158 -17
- package/plugins/fancy-story-builder/scripts/stage-batches.js +36 -2
- package/plugins/fancy-story-builder/scripts/stage-contract.js +12 -0
- package/plugins/fancy-story-builder/scripts/story-artifacts.js +933 -0
- package/plugins/fancy-story-builder/scripts/upstream-query.js +1 -1
- package/plugins/fancy-story-builder/scripts/v2/baseline.js +112 -0
- package/plugins/fancy-story-builder/scripts/v2/cast.js +517 -0
- package/plugins/fancy-story-builder/scripts/v2/context.js +434 -0
- package/plugins/fancy-story-builder/scripts/v2/contracts.js +493 -0
- package/plugins/fancy-story-builder/scripts/v2/core.js +419 -0
- package/plugins/fancy-story-builder/scripts/v2/engine.js +1068 -0
- package/plugins/fancy-story-builder/scripts/v2/export-markdown.js +794 -0
- package/plugins/fancy-story-builder/scripts/v2/genre.js +195 -0
- package/plugins/fancy-story-builder/scripts/v2/knowledge.js +321 -0
- package/plugins/fancy-story-builder/scripts/v2/macro.js +409 -0
- package/plugins/fancy-story-builder/scripts/v2/manifest.js +538 -0
- package/plugins/fancy-story-builder/scripts/v2/migrate.js +137 -0
- package/plugins/fancy-story-builder/scripts/v2/prompts.js +1335 -0
- package/plugins/fancy-story-builder/scripts/v2/rhythm.js +618 -0
- package/plugins/fancy-story-builder/scripts/v2/scale.js +843 -0
- package/plugins/fancy-story-builder/scripts/v2/snapshot.js +121 -0
- package/plugins/fancy-story-builder/scripts/v2/stages.js +273 -0
- package/plugins/fancy-story-builder/scripts/v2/writing.js +216 -0
- package/plugins/fancy-story-builder/storydb/README.md +271 -0
- package/plugins/fancy-story-builder/storydb/cli.js +458 -0
- package/plugins/fancy-story-builder/storydb/client.js +351 -0
- package/plugins/fancy-story-builder/storydb/compile.js +452 -0
- package/plugins/fancy-story-builder/storydb/context.js +288 -0
- package/plugins/fancy-story-builder/storydb/definitions.js +699 -0
- package/plugins/fancy-story-builder/storydb/driver.js +106 -0
- package/plugins/fancy-story-builder/storydb/dsh-tools.js +313 -0
- package/plugins/fancy-story-builder/storydb/export.js +175 -0
- package/plugins/fancy-story-builder/storydb/ids.js +31 -0
- package/plugins/fancy-story-builder/storydb/impact.js +170 -0
- package/plugins/fancy-story-builder/storydb/import.js +559 -0
- package/plugins/fancy-story-builder/storydb/mcp.js +99 -0
- package/plugins/fancy-story-builder/storydb/prompts.js +952 -0
- package/plugins/fancy-story-builder/storydb/stage-command.js +379 -0
- package/plugins/fancy-story-builder/storydb/stage.js +426 -0
- package/plugins/fancy-story-builder/storydb/store.js +891 -0
- package/plugins/fancy-story-builder/storydb/tools.js +592 -0
- package/plugins/fancy-story-builder/storydb/validation.js +183 -0
- package/plugins/fancy-story-builder/templates/v2/10-scale/scale-template.json +144 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/emotion-template.json +15 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/endings-template.json +16 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/lines-template.json +28 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/roles-template.json +10 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/story-template.json +19 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/volumes-template.json +24 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/world-template.json +24 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/characters-template.json +49 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/climaxes-template.json +14 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/conflicts-template.json +30 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/factions-template.json +18 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/foreshadows-template.json +29 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/information-template.json +29 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/items-template.json +13 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/locations-template.json +12 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/payoffs-template.json +16 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/relations-template.json +44 -0
- package/plugins/fancy-story-builder/templates/v2/40-rhythm/chapter-map-template.json +81 -0
- package/plugins/fancy-story-builder/templates/v2/40-rhythm/outline-template.json +162 -0
- package/plugins/fancy-story-builder/templates/v2/50-writing/ledger-template.json +46 -0
- package/plugins/fancy-story-builder/templates/v2/60-deslop/report-template.json +10 -0
- package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//346/227/266/345/272/217/346/230/240/345/260/204/346/250/241/346/235/277.json +267 -44
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//344/270/226/347/225/214/350/247/202/350/247/204/345/210/231/346/250/241/346/235/277.json +121 -19
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/205/263/351/224/256/351/201/223/345/205/267/346/250/241/346/235/277.json +129 -21
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/205/266/344/273/226/345/256/236/344/275/223/346/250/241/346/235/277.json +62 -0
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/206/262/347/252/201/346/250/241/346/235/277.json +76 -17
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/211/247/346/203/205/346/250/241/346/235/277.json +56 -8
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/212/277/345/212/233/346/250/241/346/235/277.json +63 -13
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/234/260/347/202/271/346/250/241/346/235/277.json +34 -6
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//347/273/223/345/261/200/346/250/241/346/235/277.json +81 -18
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//350/247/222/350/211/262/346/250/241/346/235/277.json +202 -42
- package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//345/205/263/347/263/273/346/250/241/346/235/277.json +160 -27
- package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//345/256/236/344/275/223/346/250/241/346/235/277.json +217 -41
- package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//346/241/206/346/236/266/346/250/241/346/235/277.json +215 -41
- package/plugins/fancy-story-builder/templates.js +272 -74
- package/plugins/fancy-story-builder/wiki-references.js +429 -93
- package/plugins/fancy-story-builder/writing-driver.js +336 -9
- package/plugins/fancy-topic/index.js +414 -61
- package/plugins/fancy-topic/prompts/01-exploration-prompt.md +115 -0
- package/plugins/fancy-topic/scripts/save-suggestion.js +219 -0
- package/plugins/fancy-topic/scripts/select-topic.js +106 -63
- package/plugins/fancy-topic/templates.js +225 -122
- package/plugins/fancy-topic/tool-deploy.js +200 -0
- package/lib/safe-rm.js +0 -94
package/lib/projects.js
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project registry, project directories, and project-scoped database users.
|
|
3
|
+
*
|
|
4
|
+
* The registry lives in one shared SurrealDB database
|
|
5
|
+
* (`fancy_registry`). A project's novel data lives in its own database
|
|
6
|
+
* (`proj_<uuid>`), and the project directory only stores connection metadata:
|
|
7
|
+
*
|
|
8
|
+
* <workspace>/projects/<uuid>/.fancy-project.json
|
|
9
|
+
*
|
|
10
|
+
* The novel title is NOT duplicated on disk. Renaming a project updates the
|
|
11
|
+
* registry and the project's `story:main` record; the directory name (the
|
|
12
|
+
* UUID) never changes.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import fs from 'fs';
|
|
16
|
+
import path from 'path';
|
|
17
|
+
import crypto from 'crypto';
|
|
18
|
+
import { Surreal, createRemoteEngines } from 'surrealdb';
|
|
19
|
+
import {
|
|
20
|
+
WorkspaceError,
|
|
21
|
+
atomicWriteJson,
|
|
22
|
+
readJsonFile,
|
|
23
|
+
localIsoString,
|
|
24
|
+
readSecrets,
|
|
25
|
+
readWorkspace,
|
|
26
|
+
resolveWorkspace,
|
|
27
|
+
setActiveProject,
|
|
28
|
+
workspacePaths,
|
|
29
|
+
getActiveProject,
|
|
30
|
+
} from './workspace.js';
|
|
31
|
+
import { rpcUrl, startServer } from './surreal-server.js';
|
|
32
|
+
|
|
33
|
+
export const PROJECT_SCHEMA_VERSION = 1;
|
|
34
|
+
export const PROJECT_CONFIG_FILE = '.fancy-project.json';
|
|
35
|
+
export const REGISTRY_TABLE = 'project';
|
|
36
|
+
|
|
37
|
+
export class ProjectError extends Error {
|
|
38
|
+
constructor(code, message, detail) {
|
|
39
|
+
super(message);
|
|
40
|
+
this.name = 'ProjectError';
|
|
41
|
+
this.code = code;
|
|
42
|
+
this.detail = detail;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function projectRecordId(projectId) {
|
|
47
|
+
return 'project:' + String(projectId).replace(/-/g, '_');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function databaseName(projectId) {
|
|
51
|
+
return 'proj_' + String(projectId).replace(/-/g, '');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function databaseUser(projectId) {
|
|
55
|
+
return 'u_' + String(projectId).replace(/-/g, '').slice(0, 16);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function projectConfigPath(dir) {
|
|
59
|
+
return path.join(dir, PROJECT_CONFIG_FILE);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function readProjectConfig(dir) {
|
|
63
|
+
const file = projectConfigPath(dir);
|
|
64
|
+
const data = readJsonFile(file);
|
|
65
|
+
if (!data || typeof data !== 'object' || Array.isArray(data) || !data.project_id) return null;
|
|
66
|
+
return data;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function writeProjectConfig(dir, config) {
|
|
70
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
71
|
+
return atomicWriteJson(projectConfigPath(dir), {
|
|
72
|
+
schema_version: PROJECT_SCHEMA_VERSION,
|
|
73
|
+
...config,
|
|
74
|
+
}, { mode: 0o600 });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function projectDir(root, projectId) {
|
|
78
|
+
return workspacePaths(root).project(projectId);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function normalizeProject(row) {
|
|
82
|
+
if (!row) return null;
|
|
83
|
+
const topic = row.topic && typeof row.topic === 'object' ? row.topic : {};
|
|
84
|
+
return {
|
|
85
|
+
...row,
|
|
86
|
+
project_id: String(row.project_id || ''),
|
|
87
|
+
name: String(row.name || topic.book_name || row.project_id || ''),
|
|
88
|
+
db_name: String(row.db_name || ''),
|
|
89
|
+
db_user: String(row.db_user || ''),
|
|
90
|
+
dir: String(row.dir || ''),
|
|
91
|
+
status: String(row.status || 'active'),
|
|
92
|
+
topic,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function connectAdmin(root) {
|
|
97
|
+
// startServer verifies the root credentials, and moves the workspace to a
|
|
98
|
+
// free port if another (possibly stale) SurrealDB is squatting on ours.
|
|
99
|
+
await startServer(root);
|
|
100
|
+
const workspace = readWorkspace(root);
|
|
101
|
+
const secrets = readSecrets(root);
|
|
102
|
+
const db = new Surreal({ engines: { ...createRemoteEngines() } });
|
|
103
|
+
await db.connect(rpcUrl(workspace));
|
|
104
|
+
await db.signin({
|
|
105
|
+
username: secrets.surreal?.username || 'root',
|
|
106
|
+
password: secrets.surreal?.password,
|
|
107
|
+
});
|
|
108
|
+
return { db, workspace, secrets };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const REGISTRY_DDL = [
|
|
112
|
+
`DEFINE TABLE IF NOT EXISTS ${REGISTRY_TABLE} SCHEMAFULL;`,
|
|
113
|
+
`DEFINE FIELD IF NOT EXISTS project_id ON ${REGISTRY_TABLE} TYPE string;`,
|
|
114
|
+
`DEFINE FIELD IF NOT EXISTS name ON ${REGISTRY_TABLE} TYPE string;`,
|
|
115
|
+
`DEFINE FIELD IF NOT EXISTS db_name ON ${REGISTRY_TABLE} TYPE string;`,
|
|
116
|
+
`DEFINE FIELD IF NOT EXISTS db_user ON ${REGISTRY_TABLE} TYPE string;`,
|
|
117
|
+
`DEFINE FIELD IF NOT EXISTS dir ON ${REGISTRY_TABLE} TYPE string;`,
|
|
118
|
+
`DEFINE FIELD IF NOT EXISTS status ON ${REGISTRY_TABLE} TYPE string DEFAULT 'active';`,
|
|
119
|
+
`DEFINE FIELD IF NOT EXISTS topic ON ${REGISTRY_TABLE} TYPE object FLEXIBLE;`,
|
|
120
|
+
`DEFINE FIELD IF NOT EXISTS created_at ON ${REGISTRY_TABLE} TYPE datetime VALUE time::now();`,
|
|
121
|
+
`DEFINE FIELD IF NOT EXISTS updated_at ON ${REGISTRY_TABLE} TYPE datetime VALUE time::now();`,
|
|
122
|
+
`DEFINE INDEX IF NOT EXISTS project_id_unique ON ${REGISTRY_TABLE} FIELDS project_id UNIQUE;`,
|
|
123
|
+
`DEFINE INDEX IF NOT EXISTS project_name_unique ON ${REGISTRY_TABLE} FIELDS name UNIQUE;`,
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
export async function withRegistry(root, fn) {
|
|
127
|
+
const { db, workspace } = await connectAdmin(root);
|
|
128
|
+
try {
|
|
129
|
+
await db.use({ namespace: workspace.surreal?.namespace || 'fancy', database: workspace.surreal?.registry_database || 'fancy_registry' });
|
|
130
|
+
for (const statement of REGISTRY_DDL) await db.query(statement);
|
|
131
|
+
return await fn(db, workspace);
|
|
132
|
+
} finally {
|
|
133
|
+
try { await db.close(); } catch { /* ignore */ }
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function listProjects(root) {
|
|
138
|
+
return withRegistry(root, async (db) => {
|
|
139
|
+
const rows = await db.query(`SELECT * FROM ${REGISTRY_TABLE} WHERE status != 'deleted' ORDER BY name;`);
|
|
140
|
+
const list = Array.isArray(rows) && Array.isArray(rows[0]) ? rows[0] : [];
|
|
141
|
+
return list.map(normalizeProject);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function getProject(root, nameOrId) {
|
|
146
|
+
const key = String(nameOrId || '').trim();
|
|
147
|
+
if (!key) return null;
|
|
148
|
+
const projects = await listProjects(root);
|
|
149
|
+
const lower = key.toLowerCase();
|
|
150
|
+
return projects.find((row) => row.name === key
|
|
151
|
+
|| row.project_id === key
|
|
152
|
+
|| row.db_name === key
|
|
153
|
+
|| row.dir === key
|
|
154
|
+
|| path.basename(row.dir) === key
|
|
155
|
+
|| row.name.toLowerCase() === lower)
|
|
156
|
+
|| projects.find((row) => row.name.includes(key) || row.project_id.startsWith(key))
|
|
157
|
+
|| null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function findProjects(root, query) {
|
|
161
|
+
const key = String(query || '').trim();
|
|
162
|
+
const projects = await listProjects(root);
|
|
163
|
+
if (!key) return projects;
|
|
164
|
+
const lower = key.toLowerCase();
|
|
165
|
+
return projects.filter((row) => row.name.toLowerCase().includes(lower)
|
|
166
|
+
|| row.project_id.startsWith(key)
|
|
167
|
+
|| row.db_name === key
|
|
168
|
+
|| path.basename(row.dir) === key);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function assertNameAvailable(db, name, exceptId = null) {
|
|
172
|
+
const rows = await db.query(
|
|
173
|
+
`SELECT project_id, name FROM ${REGISTRY_TABLE} WHERE name = $name AND status != 'deleted';`,
|
|
174
|
+
{ name },
|
|
175
|
+
);
|
|
176
|
+
const list = Array.isArray(rows) && Array.isArray(rows[0]) ? rows[0] : [];
|
|
177
|
+
const conflict = list.find((row) => row.project_id !== exceptId);
|
|
178
|
+
if (conflict) {
|
|
179
|
+
throw new ProjectError('PROJECT_NAME_EXISTS', '小说名已被占用:' + name + '(已有项目 UUID:' + conflict.project_id + ')。请更换小说名后重试。');
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export async function createProject(root, opts = {}) {
|
|
184
|
+
const title = String(opts.title || opts.name || '').trim();
|
|
185
|
+
if (!title) throw new ProjectError('PROJECT_TITLE_REQUIRED', '创建项目时必须提供小说名。');
|
|
186
|
+
const projectId = opts.projectId || crypto.randomUUID();
|
|
187
|
+
const dbName = databaseName(projectId);
|
|
188
|
+
const dbUser = databaseUser(projectId);
|
|
189
|
+
const password = crypto.randomBytes(24).toString('base64url');
|
|
190
|
+
const dir = projectDir(root, projectId);
|
|
191
|
+
const topic = opts.topic && typeof opts.topic === 'object' ? opts.topic : {};
|
|
192
|
+
|
|
193
|
+
const result = await withRegistry(root, async (db, workspace) => {
|
|
194
|
+
await assertNameAvailable(db, title);
|
|
195
|
+
const namespace = workspace.surreal?.namespace || 'fancy';
|
|
196
|
+
await db.query(`DEFINE DATABASE IF NOT EXISTS ${dbName};`);
|
|
197
|
+
// DEFINE USER ... ON DATABASE is resolved against the connection's
|
|
198
|
+
// currently selected database. Switch to the project database before
|
|
199
|
+
// defining its user, then switch back to the registry.
|
|
200
|
+
await db.use({ namespace, database: dbName });
|
|
201
|
+
await db.query(`DEFINE USER IF NOT EXISTS ${dbUser} ON DATABASE PASSWORD '${password}' ROLES VIEWER;`);
|
|
202
|
+
await db.use({ namespace, database: workspace.surreal?.registry_database || 'fancy_registry' });
|
|
203
|
+
const created = await db.query(
|
|
204
|
+
`CREATE ${REGISTRY_TABLE} SET
|
|
205
|
+
project_id = $project_id,
|
|
206
|
+
name = $name,
|
|
207
|
+
db_name = $db_name,
|
|
208
|
+
db_user = $db_user,
|
|
209
|
+
dir = $dir,
|
|
210
|
+
status = 'active',
|
|
211
|
+
topic = $topic;`,
|
|
212
|
+
{ project_id: projectId, name: title, db_name: dbName, db_user: dbUser, dir, topic },
|
|
213
|
+
);
|
|
214
|
+
const row = Array.isArray(created) && Array.isArray(created[0]) ? created[0][0] : null;
|
|
215
|
+
return { row: normalizeProject(row), namespace };
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
219
|
+
fs.mkdirSync(path.join(dir, '导出'), { recursive: true });
|
|
220
|
+
fs.mkdirSync(path.join(dir, '设定'), { recursive: true });
|
|
221
|
+
writeProjectConfig(dir, {
|
|
222
|
+
project_id: projectId,
|
|
223
|
+
workspace: root,
|
|
224
|
+
database: dbName,
|
|
225
|
+
namespace: result.namespace,
|
|
226
|
+
db_user: dbUser,
|
|
227
|
+
created_at: localIsoString(),
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
const secrets = readSecrets(root);
|
|
231
|
+
secrets.projects = secrets.projects || {};
|
|
232
|
+
secrets.projects[projectId] = { username: dbUser, password };
|
|
233
|
+
atomicWriteJson(path.join(root, '.fancy', 'secrets.json'), secrets, { mode: 0o600 });
|
|
234
|
+
|
|
235
|
+
if (opts.makeActive !== false) setActiveProject(root, projectId);
|
|
236
|
+
return getProject(root, projectId);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export async function updateProjectTopic(root, nameOrId, topic) {
|
|
240
|
+
const project = await getProject(root, nameOrId);
|
|
241
|
+
if (!project) throw new ProjectError('PROJECT_NOT_FOUND', '未找到匹配的项目:' + nameOrId + '。可运行 /fancy-project list 查看当前工作区的项目。');
|
|
242
|
+
const value = topic && typeof topic === 'object' ? topic : {};
|
|
243
|
+
await withRegistry(root, async (db) => {
|
|
244
|
+
await db.query(
|
|
245
|
+
`UPDATE ${REGISTRY_TABLE} SET topic = $topic, updated_at = time::now() WHERE project_id = $id;`,
|
|
246
|
+
{ topic: value, id: project.project_id },
|
|
247
|
+
);
|
|
248
|
+
});
|
|
249
|
+
try {
|
|
250
|
+
const { openStoryGraph } = await import('../plugins/fancy-story-builder/storydb/client.js');
|
|
251
|
+
const sdb = await openStoryGraph(project.dir, { project, admin: true });
|
|
252
|
+
try {
|
|
253
|
+
await sdb.query(
|
|
254
|
+
`UPDATE story:main SET payload.topic = $topic, title = $title, updated_at = time::now();`,
|
|
255
|
+
{ topic: value, title: value.book_name || value.title || project.name },
|
|
256
|
+
);
|
|
257
|
+
} finally {
|
|
258
|
+
await sdb.close();
|
|
259
|
+
}
|
|
260
|
+
} catch { /* registry is authoritative for lookup */ }
|
|
261
|
+
return getProject(root, project.project_id);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export async function renameProject(root, nameOrId, newTitle) {
|
|
265
|
+
const title = String(newTitle || '').trim();
|
|
266
|
+
if (!title) throw new ProjectError('PROJECT_TITLE_REQUIRED', '新小说名不能为空。');
|
|
267
|
+
const project = await getProject(root, nameOrId);
|
|
268
|
+
if (!project) throw new ProjectError('PROJECT_NOT_FOUND', '未找到匹配的项目:' + nameOrId + '。可运行 /fancy-project list 查看当前工作区的项目。');
|
|
269
|
+
const topic = {
|
|
270
|
+
...(project.topic || {}),
|
|
271
|
+
book_name: title,
|
|
272
|
+
title,
|
|
273
|
+
};
|
|
274
|
+
await withRegistry(root, async (db) => {
|
|
275
|
+
await assertNameAvailable(db, title, project.project_id);
|
|
276
|
+
await db.query(
|
|
277
|
+
`UPDATE ${REGISTRY_TABLE} SET name = $name, topic = $topic, updated_at = time::now()
|
|
278
|
+
WHERE project_id = $id;`,
|
|
279
|
+
{ name: title, topic, id: project.project_id },
|
|
280
|
+
);
|
|
281
|
+
});
|
|
282
|
+
// Keep the story record in sync; if the project DB is unavailable, the
|
|
283
|
+
// registry rename still wins and the next open will repair it.
|
|
284
|
+
try {
|
|
285
|
+
const { openStoryGraph } = await import('../plugins/fancy-story-builder/storydb/client.js');
|
|
286
|
+
const sdb = await openStoryGraph(project.dir, { admin: true });
|
|
287
|
+
try {
|
|
288
|
+
await sdb.query('UPDATE story:main SET title = $title, updated_at = time::now();', { title });
|
|
289
|
+
} finally {
|
|
290
|
+
await sdb.close();
|
|
291
|
+
}
|
|
292
|
+
} catch { /* registry is authoritative for lookup */ }
|
|
293
|
+
return getProject(root, project.project_id);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export async function deleteProject(root, nameOrId, opts = {}) {
|
|
297
|
+
const project = await getProject(root, nameOrId);
|
|
298
|
+
if (!project) throw new ProjectError('PROJECT_NOT_FOUND', '未找到匹配的项目:' + nameOrId + '。可运行 /fancy-project list 查看当前工作区的项目。');
|
|
299
|
+
if (opts.confirm !== true) {
|
|
300
|
+
throw new ProjectError('CONFIRMATION_REQUIRED', '删除项目需要明确确认(confirm=true)。', { project });
|
|
301
|
+
}
|
|
302
|
+
await withRegistry(root, async (db) => {
|
|
303
|
+
await db.query(`REMOVE DATABASE ${project.db_name};`);
|
|
304
|
+
await db.query(`UPDATE ${REGISTRY_TABLE} SET status = 'deleted', updated_at = time::now() WHERE project_id = $id;`, { id: project.project_id });
|
|
305
|
+
});
|
|
306
|
+
const dir = path.resolve(project.dir || projectDir(root, project.project_id));
|
|
307
|
+
if (opts.purge === true) {
|
|
308
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
309
|
+
} else if (fs.existsSync(dir)) {
|
|
310
|
+
const trash = path.join(workspacePaths(root).fancy, 'trash', `${project.project_id}-${Date.now()}`);
|
|
311
|
+
fs.mkdirSync(path.dirname(trash), { recursive: true });
|
|
312
|
+
try { fs.renameSync(dir, trash); } catch { /* ignore */ }
|
|
313
|
+
}
|
|
314
|
+
const active = getActiveProject(root);
|
|
315
|
+
if (active === project.project_id) setActiveProject(root, null);
|
|
316
|
+
return { project, removed: true, purged: opts.purge === true };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export async function setProjectActive(root, nameOrId) {
|
|
320
|
+
const project = await getProject(root, nameOrId);
|
|
321
|
+
if (!project) throw new ProjectError('PROJECT_NOT_FOUND', '未找到匹配的项目:' + nameOrId + '。可运行 /fancy-project list 查看当前工作区的项目。');
|
|
322
|
+
setActiveProject(root, project.project_id);
|
|
323
|
+
return project;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export function projectFromCwd(root, cwd) {
|
|
327
|
+
if (!cwd) return null;
|
|
328
|
+
let current = path.resolve(cwd);
|
|
329
|
+
const workspace = path.resolve(root);
|
|
330
|
+
for (;;) {
|
|
331
|
+
const config = readProjectConfig(current);
|
|
332
|
+
if (config) return { config, dir: current };
|
|
333
|
+
if (current === workspace) return null;
|
|
334
|
+
const parent = path.dirname(current);
|
|
335
|
+
if (parent === current) return null;
|
|
336
|
+
current = parent;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Resolve a project without requiring the registry when the cwd already points
|
|
342
|
+
* at a project directory. If only the workspace is known, use the active
|
|
343
|
+
* project or, when there is exactly one, that project.
|
|
344
|
+
*/
|
|
345
|
+
export async function resolveProject(opts = {}) {
|
|
346
|
+
const root = opts.workspace || resolveWorkspace({ cwd: opts.cwd, explicit: opts.explicitWorkspace, required: true });
|
|
347
|
+
const fromCwd = projectFromCwd(root, opts.cwd || process.cwd());
|
|
348
|
+
if (fromCwd) {
|
|
349
|
+
let record = null;
|
|
350
|
+
try { record = await getProject(root, fromCwd.config.project_id); } catch { /* registry may be down */ }
|
|
351
|
+
return {
|
|
352
|
+
...(record || {}),
|
|
353
|
+
project_id: fromCwd.config.project_id,
|
|
354
|
+
name: (record && record.name) || fromCwd.config.project_id,
|
|
355
|
+
db_name: fromCwd.config.database,
|
|
356
|
+
db_user: fromCwd.config.db_user,
|
|
357
|
+
dir: fromCwd.dir,
|
|
358
|
+
config: fromCwd.config,
|
|
359
|
+
workspace: root,
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (opts.project) {
|
|
364
|
+
const project = await getProject(root, opts.project);
|
|
365
|
+
if (!project) throw new ProjectError('PROJECT_NOT_FOUND', '未找到匹配的项目:' + opts.project + '。可运行 /fancy-project list 查看当前工作区的项目。');
|
|
366
|
+
return { ...project, config: readProjectConfig(project.dir), workspace: root };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const active = getActiveProject(root);
|
|
370
|
+
if (active) {
|
|
371
|
+
const project = await getProject(root, active);
|
|
372
|
+
if (project) return { ...project, config: readProjectConfig(project.dir), workspace: root };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const projects = await listProjects(root);
|
|
376
|
+
if (projects.length === 1) {
|
|
377
|
+
return { ...projects[0], config: readProjectConfig(projects[0].dir), workspace: root };
|
|
378
|
+
}
|
|
379
|
+
if (!projects.length) {
|
|
380
|
+
throw new ProjectError('PROJECT_NOT_FOUND', '当前工作区尚未创建任何项目。请先运行 /fancy-topic setup 创建项目。');
|
|
381
|
+
}
|
|
382
|
+
throw new ProjectError(
|
|
383
|
+
'PROJECT_AMBIGUOUS',
|
|
384
|
+
'当前工作区存在多个项目,无法自动选择。请使用 --project <小说名|UUID> 指定项目,或先运行 /fancy-project use <小说名|UUID> 设置当前项目。',
|
|
385
|
+
{ projects: projects.map((p) => ({ id: p.project_id, name: p.name })) },
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export async function ensureProjectSchemaReady(project, opts = {}) {
|
|
390
|
+
const { openStoryGraph } = await import('../plugins/fancy-story-builder/storydb/client.js');
|
|
391
|
+
const sdb = await openStoryGraph(project.dir, { admin: true, ...opts });
|
|
392
|
+
try {
|
|
393
|
+
return sdb;
|
|
394
|
+
} catch (err) {
|
|
395
|
+
await sdb.close();
|
|
396
|
+
throw err;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export { WorkspaceError };
|
package/lib/scraper-utils.js
CHANGED
|
@@ -46,6 +46,126 @@ export function normalizeScanMode(raw) {
|
|
|
46
46
|
return SCAN_MODES.includes(s) ? s : DEFAULT_SCAN_MODE;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Markdown 元字符的“保形替换表”。
|
|
51
|
+
*
|
|
52
|
+
* 扫榜数据来自平台页面,书名/作者/简介里可能出现 `#`、`*`、`[]`、`---`、
|
|
53
|
+
* `1.` 等写法。直接写进 Markdown 会变成标题、列表、分隔线或链接语法,
|
|
54
|
+
* 渲染后就不再是原文。统一替换成全角等价字符:看起来仍是同一个符号,
|
|
55
|
+
* 但不再触发 Markdown 语法。
|
|
56
|
+
*/
|
|
57
|
+
const MARKDOWN_REPLACEMENTS = {
|
|
58
|
+
'#': '#', '*': '*', '[': '[', ']': ']', '_': '_',
|
|
59
|
+
'`': '`', '>': '>', '<': '<', '|': '|', '~': '~',
|
|
60
|
+
'\\': '\', '-': '-', '+': '+', '=': '=', '&': '&',
|
|
61
|
+
};
|
|
62
|
+
const MARKDOWN_INLINE_RE = /[#*\[\]_`<>|~\\\-+=&]/g;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 处理单行/内联文本里的 Markdown 元字符。
|
|
66
|
+
* 保留换行;用于作者、书名、标签、分类名等单行字段。
|
|
67
|
+
*/
|
|
68
|
+
export function sanitizeMarkdownInline(value) {
|
|
69
|
+
return String(value == null ? '' : value)
|
|
70
|
+
.replace(MARKDOWN_INLINE_RE, (ch) => MARKDOWN_REPLACEMENTS[ch] || ch);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 处理一行正文:先拆掉行首的有序列表标记(`1. `),再处理其余元字符。
|
|
75
|
+
* 无序列表/标题/引用/分隔线的标记字符已在 sanitizeMarkdownInline 中替换。
|
|
76
|
+
*/
|
|
77
|
+
export function sanitizeMarkdownLine(value) {
|
|
78
|
+
const text = String(value == null ? '' : value)
|
|
79
|
+
.replace(/^[\s ]+|[\s ]+$/g, '')
|
|
80
|
+
// 1. / 1) 都算有序列表标记;即使点号后没有空格也要处理
|
|
81
|
+
.replace(/^(\d+)[.)](?=\s|$)/, (_m, num) => num + '.');
|
|
82
|
+
return sanitizeMarkdownInline(text);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 把一本书里要写进 Markdown 的单行字段做保形替换。 */
|
|
86
|
+
export function sanitizeBookFields(book) {
|
|
87
|
+
if (!book || typeof book !== 'object') return book;
|
|
88
|
+
const out = { ...book };
|
|
89
|
+
for (const key of [
|
|
90
|
+
'title', 'author', 'genre', 'type', 'status', 'words', 'wordCount',
|
|
91
|
+
'group', 'category', 'subGenre', 'rankChange',
|
|
92
|
+
]) {
|
|
93
|
+
if (typeof out[key] === 'string') out[key] = sanitizeMarkdownInline(out[key]);
|
|
94
|
+
}
|
|
95
|
+
if (Array.isArray(out.tags)) out.tags = out.tags.map(sanitizeMarkdownInline);
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 把作品简介渲染成 markdown 行(四个平台共用一套格式)。
|
|
101
|
+
*
|
|
102
|
+
* ## 为什么抽出来(2026-10-29)
|
|
103
|
+
*
|
|
104
|
+
* 四个平台的简介此前各有各的写法:
|
|
105
|
+
*
|
|
106
|
+
* 起点 `**简介:** 首段` + 后续段落各自成段 ← 只有起点带冒号
|
|
107
|
+
* 番茄 空行 + `**简介**` + 简介全文整块
|
|
108
|
+
* 晋江 空行 + `**简介**` + 简介全文整块
|
|
109
|
+
* 七猫 空行 + `**简介**` + 分段
|
|
110
|
+
*
|
|
111
|
+
* 同一个插件产出的四份文件,字段格式却对不上:
|
|
112
|
+
* 番茄/晋江/七猫的"简介"没有冒号(不像 `**标签:**` 那样一眼看出是字段),
|
|
113
|
+
* 而且简介前多一个空行,跟前面的字段行断开了。
|
|
114
|
+
*
|
|
115
|
+
* 统一成一个无序列表项(2026-11-05 调整):
|
|
116
|
+
*
|
|
117
|
+
* - **简介:** 首段
|
|
118
|
+
*
|
|
119
|
+
* 第二段
|
|
120
|
+
*
|
|
121
|
+
* 规则:
|
|
122
|
+
* - 简介本身是一个 `- ` 列表项,和作者/状态/在读等字段同一层级;
|
|
123
|
+
* - 单换行 = 段内硬换行(行尾两个空格),四个平台统一;
|
|
124
|
+
* - 空行 = 段落分隔,用缩进 + 空行留在同一个列表项里;
|
|
125
|
+
* - **每段首尾的空白字符都去掉**(半角空格 / 全角空格 / 换行)。
|
|
126
|
+
*
|
|
127
|
+
* @param {string|string[]} raw - 简介正文,或已切好的段落数组
|
|
128
|
+
* @returns {string[]} markdown 行;没有简介时返回空数组
|
|
129
|
+
*/
|
|
130
|
+
export function renderIntroLines(raw) {
|
|
131
|
+
const source = Array.isArray(raw)
|
|
132
|
+
? raw
|
|
133
|
+
: String(raw == null ? '' : raw)
|
|
134
|
+
.replace(/\r\n?/g, '\n')
|
|
135
|
+
// 只有空行(允许空行里夹空格/全角空格)才是段落分隔
|
|
136
|
+
.replace(/\n[ \t ]*\n/g, '\n\n')
|
|
137
|
+
.split(/\n{2,}/);
|
|
138
|
+
const paras = source
|
|
139
|
+
// 去掉每段首尾的空白字符:\s 已含全角空格 U+3000,这里再写一遍是防它被漏
|
|
140
|
+
.map(p => String(p).replace(/^[\s ]+|[\s ]+$/g, ''))
|
|
141
|
+
.filter(Boolean);
|
|
142
|
+
if (!paras.length) return [];
|
|
143
|
+
|
|
144
|
+
// 简介本身是列表项;段落之间用空行 + 两格缩进,段落内部的单换行
|
|
145
|
+
// (番茄/起点的 <br> 转出来的)用两个尾随空格做硬换行。
|
|
146
|
+
const lines = [];
|
|
147
|
+
let first = true;
|
|
148
|
+
for (const para of paras) {
|
|
149
|
+
const parts = String(para)
|
|
150
|
+
.split('\n')
|
|
151
|
+
.map((line) => sanitizeMarkdownLine(line.trim()))
|
|
152
|
+
.filter(Boolean);
|
|
153
|
+
for (let i = 0; i < parts.length; i += 1) {
|
|
154
|
+
const text = parts[i];
|
|
155
|
+
if (first) {
|
|
156
|
+
lines.push('- **简介:** ' + text);
|
|
157
|
+
first = false;
|
|
158
|
+
} else if (i === 0) {
|
|
159
|
+
lines.push('', ' ' + text);
|
|
160
|
+
} else {
|
|
161
|
+
lines[lines.length - 1] += ' ';
|
|
162
|
+
lines.push(' ' + text);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return lines;
|
|
167
|
+
}
|
|
168
|
+
|
|
49
169
|
/**
|
|
50
170
|
* 归一化作品简介文本。
|
|
51
171
|
*
|
|
@@ -77,6 +197,26 @@ export function normalizeBookIntro(raw) {
|
|
|
77
197
|
.trim();
|
|
78
198
|
}
|
|
79
199
|
|
|
200
|
+
/**
|
|
201
|
+
* 去掉数字里的千分位逗号。
|
|
202
|
+
*
|
|
203
|
+
* 晋江榜单页的积分是 "1,099,899,136" 这样带逗号的(10 亿级必带),
|
|
204
|
+
* 字数也可能到百万级带逗号。采集文件里存纯数字:
|
|
205
|
+
* - 下游才好比较 / 求和 / 算份额;
|
|
206
|
+
* - 模型照抄数字时不会把逗号一起抄走("327,650,784" 和 "327650784"
|
|
207
|
+
* 在模型的算术里不是一回事)。
|
|
208
|
+
*
|
|
209
|
+
* 只处理"数字 + 逗号 + 数字"的形态,其余字符原样保留 ——
|
|
210
|
+
* 英文正文里的逗号("共 3, 本")后面跟的不是数字,不会被动到。
|
|
211
|
+
*
|
|
212
|
+
* @param {string|number|null|undefined} raw
|
|
213
|
+
* @returns {string}
|
|
214
|
+
*/
|
|
215
|
+
export function stripThousands(raw) {
|
|
216
|
+
if (raw === null || raw === undefined) return '';
|
|
217
|
+
return String(raw).replace(/(\d),(?=\d)/g, '$1');
|
|
218
|
+
}
|
|
219
|
+
|
|
80
220
|
/**
|
|
81
221
|
* 本地日期戳 YYYYMMDD(本地时区,非 UTC)。
|
|
82
222
|
*
|