@cloud411716/fancy-webnovel 1.0.46 → 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 +369 -0
- package/cordis.patch.yml +4 -13
- 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 +26 -38
- package/lib/atomic-write.js +109 -0
- package/lib/chapter-order.js +100 -0
- package/lib/chinese-num.js +111 -0
- package/lib/confirmation.js +80 -0
- package/lib/db-init.js +123 -0
- package/lib/deployed-state.js +141 -0
- package/lib/direction-labels.js +265 -0
- package/lib/error-codes.js +104 -0
- package/lib/infra.js +594 -0
- package/lib/llm-wiki.js +109 -0
- 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 +305 -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 +62 -5
- package/plugins/fancy-analyze/analyze-definitions.md +519 -0
- package/plugins/fancy-analyze/command-input.js +65 -0
- package/plugins/fancy-analyze/index.js +2024 -0
- package/plugins/fancy-analyze/ingest-coverage.js +107 -0
- package/plugins/fancy-analyze/schema-analyze.md +627 -0
- package/plugins/fancy-analyze/schema-write.md +178 -0
- package/plugins/fancy-analyze/scripts/append-summary-chapter.js +612 -0
- package/plugins/fancy-analyze/scripts/split_source.js +292 -0
- package/plugins/fancy-analyze/summary-progress.js +495 -0
- package/plugins/fancy-analyze/templates.js +1179 -0
- package/plugins/fancy-bootstrap/index.js +313 -0
- package/plugins/fancy-bootstrap/scripts/init.js +177 -0
- package/plugins/fancy-bootstrap/scripts/path-utils.js +21 -0
- package/plugins/fancy-bootstrap/templates.js +71 -0
- 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-core.js +1118 -0
- package/plugins/fancy-import/import-driver.js +604 -0
- package/plugins/fancy-import/index.js +738 -0
- package/plugins/fancy-import/prompts/01-import-prompt.md +131 -0
- package/plugins/fancy-import/templates.js +58 -0
- 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 +708 -0
- 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 +667 -0
- package/plugins/fancy-scan/scripts/scrapers/fanqie-rank-scraper.js +923 -0
- package/plugins/fancy-scan/scripts/scrapers/jjwxc-rank-scraper.js +469 -0
- package/plugins/fancy-scan/scripts/scrapers/qimao-rank-scraper.js +629 -0
- 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 +694 -0
- 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/deploy-manifest.js +239 -0
- package/plugins/fancy-story-builder/deslop-driver.js +821 -0
- package/plugins/fancy-story-builder/flesh-output.js +168 -0
- package/plugins/fancy-story-builder/index.js +6861 -0
- package/plugins/fancy-story-builder/patch-driver.js +390 -0
- package/plugins/fancy-story-builder/prompts/01-skeleton-prompt.md +563 -0
- package/plugins/fancy-story-builder/prompts/02-flesh-prompt.md +602 -0
- package/plugins/fancy-story-builder/prompts/03-rhythm-prompt.md +539 -0
- package/plugins/fancy-story-builder/prompts/04-writing-prompt.md +225 -0
- package/plugins/fancy-story-builder/prompts/05-deslop-prompt.md +96 -0
- package/plugins/fancy-story-builder/purpose-template.md +83 -0
- package/plugins/fancy-story-builder/references/formulas-rhythm.json +302 -0
- package/plugins/fancy-story-builder/references/formulas-skeleton.json +402 -0
- package/plugins/fancy-story-builder/references/genre-profiles.json +3514 -0
- package/plugins/fancy-story-builder/references/glossary.md +594 -0
- package/plugins/fancy-story-builder/references/index.md +581 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/00-index.md +174 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/01-scene.md +150 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/02-chapter-workflow.md +99 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/03-opening-chapter.md +104 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/04-dialogue-mastery.md +274 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/05-genre-formulas.md +432 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/06-tone-ensemble.md +259 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/07-ledger-audit.md +222 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/08-diagnosis-revision.md +205 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/09-reading-power.md +135 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/10-deslop.md +129 -0
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/00-index.md +98 -0
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/01-upgrade-rhythm.md +300 -0
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/02-arc-rhythm.md +168 -0
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/03-highpoint-hooks.md +189 -0
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/04-satisfaction.md +226 -0
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/05-foreshadow-density.md +195 -0
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/06-chapter-outline.md +226 -0
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/07-audit.md +181 -0
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/00-index.md +100 -0
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/01-character.md +243 -0
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/02-worldbuilding.md +245 -0
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/03-relations.md +185 -0
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/04-emotion-methods.md +286 -0
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/05-audit.md +187 -0
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/00-index.md +91 -0
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/01-positioning.md +209 -0
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/02-packaging.md +135 -0
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/03-entities.md +305 -0
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/04-conflict.md +328 -0
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/05-quota.md +187 -0
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/06-audit.md +160 -0
- package/plugins/fancy-story-builder/rhythm-output.js +140 -0
- package/plugins/fancy-story-builder/schema-setting.json +1100 -0
- package/plugins/fancy-story-builder/schema-setting.md +232 -0
- package/plugins/fancy-story-builder/scripts/ai-patterns.js +619 -0
- package/plugins/fancy-story-builder/scripts/brief-validate.js +164 -0
- package/plugins/fancy-story-builder/scripts/check-guide-compliance.js +289 -0
- package/plugins/fancy-story-builder/scripts/content-quality.js +416 -0
- package/plugins/fancy-story-builder/scripts/deslop-validate.js +143 -0
- package/plugins/fancy-story-builder/scripts/error-classes.js +112 -0
- package/plugins/fancy-story-builder/scripts/flesh-validate.js +1312 -0
- package/plugins/fancy-story-builder/scripts/formula-engine.js +112 -0
- package/plugins/fancy-story-builder/scripts/graph-utils.js +1484 -0
- package/plugins/fancy-story-builder/scripts/label-coverage.js +171 -0
- package/plugins/fancy-story-builder/scripts/prompt-drift-check.js +441 -0
- package/plugins/fancy-story-builder/scripts/reading-power.js +298 -0
- package/plugins/fancy-story-builder/scripts/reference-log.js +110 -0
- package/plugins/fancy-story-builder/scripts/rhythm-validate.js +1312 -0
- package/plugins/fancy-story-builder/scripts/schema-pages.js +2668 -0
- package/plugins/fancy-story-builder/scripts/semantic-lint.js +381 -0
- package/plugins/fancy-story-builder/scripts/skeleton-quota.js +145 -0
- package/plugins/fancy-story-builder/scripts/skeleton-validate.js +1973 -0
- package/plugins/fancy-story-builder/scripts/stage-batches.js +1405 -0
- package/plugins/fancy-story-builder/scripts/stage-contract.js +429 -0
- package/plugins/fancy-story-builder/scripts/story-artifacts.js +933 -0
- package/plugins/fancy-story-builder/scripts/upstream-query.js +198 -0
- 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/scripts/writing-rules.js +167 -0
- package/plugins/fancy-story-builder/scripts/writing-validate.js +682 -0
- package/plugins/fancy-story-builder/skeleton-output.js +199 -0
- package/plugins/fancy-story-builder/stage-driver.js +1049 -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 +427 -0
- package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//347/253/240/350/212/202/347/273/206/347/272/262/346/250/241/346/235/277.json +34 -0
- package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//347/253/240/350/212/202/350/247/204/345/210/222/346/250/241/346/235/277.json +24 -0
- package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//350/212/202/345/245/217/345/217/202/346/225/260/346/250/241/346/235/277.json +27 -0
- 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 +168 -0
- 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 +184 -0
- 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 +131 -0
- 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 +85 -0
- 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 +103 -0
- 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 +68 -0
- 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 +142 -0
- 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 +292 -0
- 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 +266 -0
- 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 +336 -0
- package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//345/261/202/347/272/247/346/250/241/346/235/277.json +65 -0
- 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 +308 -0
- package/plugins/fancy-story-builder/templates.js +1446 -0
- package/plugins/fancy-story-builder/wiki-references.js +540 -0
- package/plugins/fancy-story-builder/writing-driver.js +1388 -0
- package/plugins/fancy-topic/index.js +1057 -0
- 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 +317 -0
- package/plugins/fancy-topic/templates.js +476 -0
- package/plugins/fancy-topic/tool-deploy.js +200 -0
- package/commands/bootstrap/index.js +0 -164
- package/commands/bootstrap/init-project.js +0 -121
- package/commands/scan/index.js +0 -283
- package/commands/scan/platforms/fanqie.js +0 -146
- package/commands/scan/platforms/jinjiang.js +0 -143
- package/commands/scan/platforms/qidian.js +0 -102
- package/commands/scan/platforms/qimao.js +0 -127
- package/commands/scan/scraper.js +0 -195
- package/infra.js +0 -160
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scraper-utils.js — 采集脚本公共工具
|
|
3
|
+
*
|
|
4
|
+
* 提供各平台 scraper 公用的纯函数,无外部依赖。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export function getArg(args, name) {
|
|
8
|
+
const i = args.indexOf(name);
|
|
9
|
+
if (i >= 0) return i + 1 < args.length ? args[i + 1] : null;
|
|
10
|
+
const prefix = `${name}=`;
|
|
11
|
+
const inline = args.find((arg) => String(arg).startsWith(prefix));
|
|
12
|
+
// 显式空值(--name=)返回 '',让调用方决定兜底
|
|
13
|
+
return inline === undefined ? null : String(inline).slice(prefix.length);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// 扫描方式(取数路径)
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 番茄的两种扫描方式。
|
|
22
|
+
*
|
|
23
|
+
* api —— 书名/作者/简介取自番茄自家明文接口 /api/book/info。
|
|
24
|
+
* 实测该接口返回与榜单页完全相同的文本(明文),零识别误差,
|
|
25
|
+
* 整榜耗时约为 OCR 路径的 1/5。
|
|
26
|
+
* ocr —— 渲染后截图 + tesseract 识别。不依赖番茄内部接口,接口失效时
|
|
27
|
+
* 仍能采到,但存在认字误差(实测简介字符错误率 8%~17%)。
|
|
28
|
+
*
|
|
29
|
+
* 这是插件层(templates/index)、编排层(run-scan)与 scraper 层之间的契约,
|
|
30
|
+
* 三处必须用同一套 id —— 不能各写各的字面量。
|
|
31
|
+
*/
|
|
32
|
+
export const SCAN_MODE_API = 'api';
|
|
33
|
+
export const SCAN_MODE_OCR = 'ocr';
|
|
34
|
+
export const SCAN_MODES = [SCAN_MODE_API, SCAN_MODE_OCR];
|
|
35
|
+
export const DEFAULT_SCAN_MODE = SCAN_MODE_API;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 把外部传入的扫描方式规整成合法值。
|
|
39
|
+
*
|
|
40
|
+
* 非法值 / 缺失一律回落到默认(api):不能把未知值透传给 scraper ——
|
|
41
|
+
* scraper 遇到不认识的 mode 若既不截图也不取接口,会产出"只有排名没有
|
|
42
|
+
* 任何文本"的空采集,而这种失败在 receipt 上看不出来。
|
|
43
|
+
*/
|
|
44
|
+
export function normalizeScanMode(raw) {
|
|
45
|
+
const s = String(raw == null ? '' : raw).trim().toLowerCase();
|
|
46
|
+
return SCAN_MODES.includes(s) ? s : DEFAULT_SCAN_MODE;
|
|
47
|
+
}
|
|
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
|
+
|
|
169
|
+
/**
|
|
170
|
+
* 归一化作品简介文本。
|
|
171
|
+
*
|
|
172
|
+
* 起点移动端详情页的 bookInfo.desc 是"文本 + 内嵌 HTML"的混合体
|
|
173
|
+
* (实测 25 本:56% 用 <br> 分行、36% 用 \n 分段、段首带全角缩进 U+3000),
|
|
174
|
+
* 部分作品末尾带【书籍标签:…】这类标签块(实测新书榜 20 本中 1 本)。
|
|
175
|
+
* 这里统一转成纯文本:
|
|
176
|
+
* - <br> 转成换行(行分隔)
|
|
177
|
+
* - 段落标签转成空行(段分隔)
|
|
178
|
+
* - 其余 HTML 标签去掉,常见实体反转义
|
|
179
|
+
* - 去掉行首全角缩进与行尾空白,压缩多余空行
|
|
180
|
+
*/
|
|
181
|
+
export function normalizeBookIntro(raw) {
|
|
182
|
+
if (raw === null || raw === undefined) return '';
|
|
183
|
+
return String(raw)
|
|
184
|
+
.replace(/<br\s*\/?>/gi, '\n')
|
|
185
|
+
.replace(/<\/?(p|div|li|h[1-6])\b[^>]*>/gi, '\n\n')
|
|
186
|
+
.replace(/<[^>]+>/g, '')
|
|
187
|
+
.replace(/ /gi, ' ')
|
|
188
|
+
.replace(/&/gi, '&')
|
|
189
|
+
.replace(/</gi, '<')
|
|
190
|
+
.replace(/>/gi, '>')
|
|
191
|
+
.replace(/"/gi, '"')
|
|
192
|
+
.replace(/'|'/gi, "'")
|
|
193
|
+
.replace(/&#(\d+);/g, (_m, d) => String.fromCodePoint(Number(d)))
|
|
194
|
+
.replace(/[ \t ]+$/gm, '')
|
|
195
|
+
.replace(/^[ \t ]+/gm, '')
|
|
196
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
197
|
+
.trim();
|
|
198
|
+
}
|
|
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
|
+
|
|
220
|
+
/**
|
|
221
|
+
* 本地日期戳 YYYYMMDD(本地时区,非 UTC)。
|
|
222
|
+
*
|
|
223
|
+
* 全仓唯一的日期实现:run-scan.js 与 fancy-scan/index.js 都用它,
|
|
224
|
+
* 避免同一份 topic_decision_<date>.md 在不同时区口径下算出两个名字。
|
|
225
|
+
*/
|
|
226
|
+
export function localDateStamp() {
|
|
227
|
+
const d = new Date();
|
|
228
|
+
return `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}`;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* @typedef {Object} CliOutcome
|
|
233
|
+
* @property {number} planned 计划处理的项数
|
|
234
|
+
* @property {number} written 成功处理的项数
|
|
235
|
+
* @property {number} failed 失败的项数
|
|
236
|
+
* @property {boolean} partial 是否部分成功
|
|
237
|
+
* @property {string[]} partialReasons 失败原因列表
|
|
238
|
+
* @property {string[]} [files] 生成的文件路径
|
|
239
|
+
*/
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* CLI 收尾:在所有 scraper 共享的 finally 关闭 browser 资源,
|
|
243
|
+
* 然后用结构化 JSON 写出结果。保证进程不会因为孤儿 chromium 堆积。
|
|
244
|
+
*
|
|
245
|
+
* @param {Promise<number|CliOutcome>} promise - main() 返回的 promise
|
|
246
|
+
* @param {string} label - scraper 名称
|
|
247
|
+
* @param {Object} [opts]
|
|
248
|
+
* @param {() => Promise<void>} [opts.beforeExit] - 退出前的清理 hook(关 browser 等)
|
|
249
|
+
*/
|
|
250
|
+
export function runCli(promise, label, opts = {}) {
|
|
251
|
+
const { beforeExit } = opts;
|
|
252
|
+
Promise.resolve()
|
|
253
|
+
.then(() => promise)
|
|
254
|
+
.then(async (result) => {
|
|
255
|
+
const outcome = Number.isInteger(result)
|
|
256
|
+
? { planned: result, written: result, failed: 0, partial: false, partialReasons: [] }
|
|
257
|
+
: result;
|
|
258
|
+
|
|
259
|
+
if (!outcome || !Number.isInteger(outcome.written)) {
|
|
260
|
+
throw new Error("no output was written");
|
|
261
|
+
}
|
|
262
|
+
if (outcome.written < 1) {
|
|
263
|
+
throw new Error("no output was written");
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// 写结构化 receipt 到 stdout
|
|
267
|
+
console.log(JSON.stringify(outcome));
|
|
268
|
+
|
|
269
|
+
const failed = Number.isInteger(outcome.failed) ? outcome.failed : 0;
|
|
270
|
+
if (outcome.partial || failed > 0) {
|
|
271
|
+
console.error(
|
|
272
|
+
`${label} partial: wrote ${outcome.written}/${outcome.planned || outcome.written + failed}`
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// 先清理再退出(关键:避免进程被强制 kill 导致孤儿 chromium)
|
|
277
|
+
if (typeof beforeExit === 'function') {
|
|
278
|
+
try {
|
|
279
|
+
await Promise.race([
|
|
280
|
+
beforeExit(),
|
|
281
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('cleanup timeout')), 5000)),
|
|
282
|
+
]);
|
|
283
|
+
} catch (e) {
|
|
284
|
+
console.error(`${label} cleanup warning: ${e.message}`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
process.exit(outcome.partial || failed > 0 ? 2 : 0);
|
|
288
|
+
})
|
|
289
|
+
.catch(async (error) => {
|
|
290
|
+
const msg = error && error.message ? error.message : String(error);
|
|
291
|
+
// 关键:即使 msg === "no output was written" 也要写出错误,否则 OOM 时一无所知
|
|
292
|
+
console.error(`${label} failed: ${msg}`);
|
|
293
|
+
process.stdout.write(JSON.stringify({ ok: false, error: msg }) + '\n');
|
|
294
|
+
// 清理资源
|
|
295
|
+
if (typeof beforeExit === 'function') {
|
|
296
|
+
try {
|
|
297
|
+
await Promise.race([
|
|
298
|
+
beforeExit(),
|
|
299
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('cleanup timeout')), 5000)),
|
|
300
|
+
]);
|
|
301
|
+
} catch (_) { /* 兜底 */ }
|
|
302
|
+
}
|
|
303
|
+
process.exit(1);
|
|
304
|
+
});
|
|
305
|
+
}
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local SurrealDB server lifecycle for one workspace.
|
|
3
|
+
*
|
|
4
|
+
* The server is a separate process, not an embedded engine. Each project gets
|
|
5
|
+
* its own database (and database-scoped user); the server itself is shared by
|
|
6
|
+
* every project in the workspace.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from 'fs';
|
|
10
|
+
import net from 'net';
|
|
11
|
+
import path from 'path';
|
|
12
|
+
import { spawn, spawnSync } from 'child_process';
|
|
13
|
+
import { Surreal, createRemoteEngines } from 'surrealdb';
|
|
14
|
+
import { readSecrets, readWorkspace, updateWorkspace, workspacePaths } from './workspace.js';
|
|
15
|
+
|
|
16
|
+
export const DEFAULT_SURREAL_VERSION = '2.3.10';
|
|
17
|
+
|
|
18
|
+
export class SurrealServerError extends Error {
|
|
19
|
+
constructor(code, message, detail) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.name = 'SurrealServerError';
|
|
22
|
+
this.code = code;
|
|
23
|
+
this.detail = detail;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function sleep(ms) {
|
|
28
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function healthUrl(workspace) {
|
|
32
|
+
const url = String(workspace.surreal?.url || 'http://127.0.0.1:8000/rpc');
|
|
33
|
+
return url.replace(/\/rpc\/?$/, '').replace(/\/$/, '') + '/health';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function rpcUrl(workspace) {
|
|
37
|
+
const url = String(workspace.surreal?.url || 'http://127.0.0.1:8000/rpc');
|
|
38
|
+
return /\/rpc\/?$/.test(url) ? url.replace(/\/$/, '') : url.replace(/\/$/, '') + '/rpc';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function findSurrealBinary(root) {
|
|
42
|
+
const candidates = [
|
|
43
|
+
process.env.FANCY_SURREAL_BIN,
|
|
44
|
+
root ? path.join(workspacePaths(root).fancy, 'bin', 'surreal') : null,
|
|
45
|
+
'/usr/local/bin/surreal',
|
|
46
|
+
'/usr/bin/surreal',
|
|
47
|
+
path.join(process.env.HOME || '', '.local', 'bin', 'surreal'),
|
|
48
|
+
].filter(Boolean);
|
|
49
|
+
for (const candidate of candidates) {
|
|
50
|
+
try {
|
|
51
|
+
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) return candidate;
|
|
52
|
+
} catch { /* ignore */ }
|
|
53
|
+
}
|
|
54
|
+
const which = spawnSync('sh', ['-lc', 'command -v surreal'], { encoding: 'utf8' });
|
|
55
|
+
if (which.status === 0 && which.stdout.trim()) return which.stdout.trim();
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function readHealth(workspace) {
|
|
60
|
+
const url = healthUrl(workspace);
|
|
61
|
+
try {
|
|
62
|
+
const controller = new AbortController();
|
|
63
|
+
const timer = setTimeout(() => controller.abort(), 1500);
|
|
64
|
+
const response = await fetch(url, { signal: controller.signal });
|
|
65
|
+
clearTimeout(timer);
|
|
66
|
+
if (!response.ok) return { ok: false, url, status: response.status };
|
|
67
|
+
return {
|
|
68
|
+
ok: true,
|
|
69
|
+
url,
|
|
70
|
+
version: response.headers.get('surreal-version') || '',
|
|
71
|
+
};
|
|
72
|
+
} catch (err) {
|
|
73
|
+
return { ok: false, url, error: err && err.message ? err.message : String(err) };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function readPid(workspace) {
|
|
78
|
+
const file = workspace.surreal?.pid_file || '';
|
|
79
|
+
if (!file) return null;
|
|
80
|
+
try {
|
|
81
|
+
const pid = Number(fs.readFileSync(file, 'utf8').trim());
|
|
82
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function processAlive(pid) {
|
|
89
|
+
if (!pid) return false;
|
|
90
|
+
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function parseBind(bind) {
|
|
94
|
+
const text = String(bind || '127.0.0.1:8000');
|
|
95
|
+
const idx = text.lastIndexOf(':');
|
|
96
|
+
if (idx < 0) return { host: '127.0.0.1', port: 8000 };
|
|
97
|
+
const host = text.slice(0, idx).replace(/^\[|\]$/g, '') || '127.0.0.1';
|
|
98
|
+
const port = Number(text.slice(idx + 1)) || 8000;
|
|
99
|
+
return { host, port };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function urlForBind(host, port) {
|
|
103
|
+
const h = host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host;
|
|
104
|
+
return `http://${h}:${port}/rpc`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function isPortFree(port, host = '127.0.0.1') {
|
|
108
|
+
return await new Promise((resolve) => {
|
|
109
|
+
const server = net.createServer();
|
|
110
|
+
server.once('error', () => resolve(false));
|
|
111
|
+
server.once('listening', () => server.close(() => resolve(true)));
|
|
112
|
+
server.listen(port, host);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function findFreePort(startPort, host = '127.0.0.1') {
|
|
117
|
+
const start = Math.max(1, Number(startPort) || 8000);
|
|
118
|
+
for (let port = start; port < start + 100; port += 1) {
|
|
119
|
+
if (await isPortFree(port, host)) return port;
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function rootCredentialsValid(workspace, secrets) {
|
|
125
|
+
const db = new Surreal({ engines: { ...createRemoteEngines() } });
|
|
126
|
+
try {
|
|
127
|
+
await db.connect(rpcUrl(workspace));
|
|
128
|
+
await db.signin({
|
|
129
|
+
username: secrets.surreal?.username || 'root',
|
|
130
|
+
password: secrets.surreal?.password,
|
|
131
|
+
});
|
|
132
|
+
await db.query('RETURN 1;');
|
|
133
|
+
return true;
|
|
134
|
+
} catch {
|
|
135
|
+
return false;
|
|
136
|
+
} finally {
|
|
137
|
+
try { await db.close(); } catch { /* ignore */ }
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function serverStatus(root) {
|
|
142
|
+
const workspace = readWorkspace(root);
|
|
143
|
+
const health = await readHealth(workspace);
|
|
144
|
+
const pid = readPid(workspace);
|
|
145
|
+
let credentialsOk = null;
|
|
146
|
+
if (health.ok) {
|
|
147
|
+
try { credentialsOk = await rootCredentialsValid(workspace, readSecrets(root)); }
|
|
148
|
+
catch { credentialsOk = false; }
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
workspace: root,
|
|
152
|
+
endpoint: rpcUrl(workspace),
|
|
153
|
+
health,
|
|
154
|
+
running: Boolean(health.ok),
|
|
155
|
+
credentials_ok: credentialsOk,
|
|
156
|
+
pid: processAlive(pid) ? pid : null,
|
|
157
|
+
stale_pid: pid && !processAlive(pid) ? pid : null,
|
|
158
|
+
data_dir: workspace.surreal?.data_dir || workspacePaths(root).surrealData,
|
|
159
|
+
binary: findSurrealBinary(root),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function waitForServer(workspace, timeoutMs = 15000) {
|
|
164
|
+
const deadline = Date.now() + timeoutMs;
|
|
165
|
+
let last = null;
|
|
166
|
+
while (Date.now() < deadline) {
|
|
167
|
+
last = await readHealth(workspace);
|
|
168
|
+
if (last.ok) return last;
|
|
169
|
+
await sleep(250);
|
|
170
|
+
}
|
|
171
|
+
return last || { ok: false, error: 'timeout' };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function startServer(root, opts = {}) {
|
|
175
|
+
let workspace = readWorkspace(root);
|
|
176
|
+
const secrets = readSecrets(root);
|
|
177
|
+
const username = secrets.surreal?.username || 'root';
|
|
178
|
+
const password = secrets.surreal?.password;
|
|
179
|
+
if (!password) {
|
|
180
|
+
throw new SurrealServerError('SURREAL_PASSWORD_MISSING', '工作区缺少 SurrealDB root 密码,请重新运行 /fancy-bootstrap。');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const current = await readHealth(workspace);
|
|
184
|
+
let endpointChanged = false;
|
|
185
|
+
let previousEndpoint = null;
|
|
186
|
+
let portChangedReason = null;
|
|
187
|
+
|
|
188
|
+
if (current.ok) {
|
|
189
|
+
if (await rootCredentialsValid(workspace, secrets)) {
|
|
190
|
+
return { started: false, already_running: true, ...(await serverStatus(root)) };
|
|
191
|
+
}
|
|
192
|
+
// Port 8000 may be occupied by a SurrealDB left over from a deleted or
|
|
193
|
+
// different workspace. Our new root password cannot authenticate to it;
|
|
194
|
+
// move this workspace to the next free port instead of failing.
|
|
195
|
+
previousEndpoint = rpcUrl(workspace);
|
|
196
|
+
const { host, port } = parseBind(workspace.surreal?.bind);
|
|
197
|
+
const nextPort = await findFreePort(port + 1, host);
|
|
198
|
+
if (!nextPort) {
|
|
199
|
+
throw new SurrealServerError('SURREAL_PORT_BUSY', '未找到可用的 SurrealDB 端口;请先停止占用端口 ' + port + ' 的进程。');
|
|
200
|
+
}
|
|
201
|
+
workspace = updateWorkspace(root, {
|
|
202
|
+
surreal: { bind: host + ':' + nextPort, url: urlForBind(host, nextPort) },
|
|
203
|
+
});
|
|
204
|
+
endpointChanged = true;
|
|
205
|
+
portChangedReason = 'auth_failed';
|
|
206
|
+
} else {
|
|
207
|
+
const { host, port } = parseBind(workspace.surreal?.bind);
|
|
208
|
+
if (!(await isPortFree(port, host))) {
|
|
209
|
+
const nextPort = await findFreePort(port + 1, host);
|
|
210
|
+
if (!nextPort) {
|
|
211
|
+
throw new SurrealServerError('SURREAL_PORT_BUSY', '未找到可用的 SurrealDB 端口;请先停止占用端口 ' + port + ' 的进程。');
|
|
212
|
+
}
|
|
213
|
+
workspace = updateWorkspace(root, {
|
|
214
|
+
surreal: { bind: host + ':' + nextPort, url: urlForBind(host, nextPort) },
|
|
215
|
+
});
|
|
216
|
+
endpointChanged = true;
|
|
217
|
+
portChangedReason = 'port_busy';
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const binary = opts.binary || findSurrealBinary(root);
|
|
222
|
+
if (!binary) {
|
|
223
|
+
throw new SurrealServerError(
|
|
224
|
+
'SURREAL_BINARY_MISSING',
|
|
225
|
+
'未找到 surreal 可执行文件。请先运行 /fancy-db install,或将 SurrealDB 加入 PATH。',
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const paths = workspacePaths(root);
|
|
230
|
+
fs.mkdirSync(paths.surrealData, { recursive: true });
|
|
231
|
+
fs.mkdirSync(path.dirname(paths.surrealLog), { recursive: true });
|
|
232
|
+
try { fs.unlinkSync(paths.surrealPid); } catch { /* ignore */ }
|
|
233
|
+
|
|
234
|
+
const bind = workspace.surreal?.bind || '127.0.0.1:8000';
|
|
235
|
+
// Keep the listen address visible in `ps`; keep the password in the
|
|
236
|
+
// environment so it never appears in the process command line.
|
|
237
|
+
const args = ['start', '--bind', bind, 'rocksdb:' + paths.surrealData];
|
|
238
|
+
const log = fs.openSync(paths.surrealLog, 'a');
|
|
239
|
+
const child = spawn(binary, args, {
|
|
240
|
+
detached: true,
|
|
241
|
+
stdio: ['ignore', log, log],
|
|
242
|
+
env: {
|
|
243
|
+
...process.env,
|
|
244
|
+
SURREAL_USER: username,
|
|
245
|
+
SURREAL_PASS: password,
|
|
246
|
+
SURREAL_NO_BANNER: 'true',
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
child.unref();
|
|
250
|
+
fs.writeFileSync(paths.surrealPid, String(child.pid) + '\n', { encoding: 'utf8', mode: 0o600 });
|
|
251
|
+
|
|
252
|
+
const health = await waitForServer(workspace, Number(opts.timeoutMs) || 15000);
|
|
253
|
+
if (!health.ok) {
|
|
254
|
+
try { process.kill(child.pid, 'SIGTERM'); } catch { /* ignore */ }
|
|
255
|
+
throw new SurrealServerError(
|
|
256
|
+
'SURREAL_START_FAILED',
|
|
257
|
+
'SurrealDB 未在预期时间内启动。请查看日志:' + paths.surrealLog,
|
|
258
|
+
health,
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
return {
|
|
262
|
+
started: true,
|
|
263
|
+
already_running: false,
|
|
264
|
+
pid: child.pid,
|
|
265
|
+
endpoint_changed: endpointChanged,
|
|
266
|
+
previous_endpoint: previousEndpoint,
|
|
267
|
+
port_changed_reason: portChangedReason,
|
|
268
|
+
...(await serverStatus(root)),
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export async function stopServer(root, opts = {}) {
|
|
273
|
+
const workspace = readWorkspace(root);
|
|
274
|
+
const status = await serverStatus(root);
|
|
275
|
+
if (!status.running && !status.pid) {
|
|
276
|
+
try { fs.unlinkSync(workspacePaths(root).surrealPid); } catch { /* ignore */ }
|
|
277
|
+
return { stopped: false, already_stopped: true };
|
|
278
|
+
}
|
|
279
|
+
const pid = status.pid || readPid(workspace);
|
|
280
|
+
if (pid) {
|
|
281
|
+
try { process.kill(pid, 'SIGTERM'); } catch { /* ignore */ }
|
|
282
|
+
const deadline = Date.now() + (Number(opts.timeoutMs) || 10000);
|
|
283
|
+
while (Date.now() < deadline && processAlive(pid)) await sleep(150);
|
|
284
|
+
if (processAlive(pid)) {
|
|
285
|
+
try { process.kill(pid, 'SIGKILL'); } catch { /* ignore */ }
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
try { fs.unlinkSync(workspacePaths(root).surrealPid); } catch { /* ignore */ }
|
|
289
|
+
return { stopped: true, already_stopped: false, pid };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function download(url, file) {
|
|
293
|
+
const response = await fetch(url, { redirect: 'follow' });
|
|
294
|
+
if (!response.ok) throw new SurrealServerError('SURREAL_DOWNLOAD', '下载 SurrealDB 失败:HTTP ' + response.status, { url });
|
|
295
|
+
const body = Buffer.from(await response.arrayBuffer());
|
|
296
|
+
fs.writeFileSync(file, body, { mode: 0o600 });
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Install a standalone SurrealDB binary into <workspace>/.fancy/bin.
|
|
301
|
+
* Uses the official GitHub release tarball for the current platform.
|
|
302
|
+
*/
|
|
303
|
+
export async function installSurreal(root, opts = {}) {
|
|
304
|
+
const version = opts.version || DEFAULT_SURREAL_VERSION;
|
|
305
|
+
const paths = workspacePaths(root);
|
|
306
|
+
const binDir = path.join(paths.fancy, 'bin');
|
|
307
|
+
fs.mkdirSync(binDir, { recursive: true });
|
|
308
|
+
const target = path.join(binDir, 'surreal');
|
|
309
|
+
if (fs.existsSync(target) && !opts.force) {
|
|
310
|
+
return { installed: false, already_installed: true, path: target, version: spawnSync(target, ['version'], { encoding: 'utf8' }).stdout.trim() };
|
|
311
|
+
}
|
|
312
|
+
const platform = process.platform;
|
|
313
|
+
const arch = process.arch === 'x64' ? 'amd64' : process.arch === 'arm64' ? 'arm64' : process.arch;
|
|
314
|
+
if (platform !== 'linux' && platform !== 'darwin') {
|
|
315
|
+
throw new SurrealServerError('SURREAL_PLATFORM', '当前平台不支持自动安装 SurrealDB:' + platform);
|
|
316
|
+
}
|
|
317
|
+
const ext = platform === 'linux' ? 'linux' : 'darwin';
|
|
318
|
+
const archive = `surreal-v${version}.${ext}-${arch}.tgz`;
|
|
319
|
+
const url = `https://github.com/surrealdb/surrealdb/releases/download/v${version}/${archive}`;
|
|
320
|
+
const tmp = path.join(binDir, `.download-${process.pid}.tgz`);
|
|
321
|
+
await download(url, tmp);
|
|
322
|
+
const extractDir = path.join(binDir, `.extract-${process.pid}`);
|
|
323
|
+
fs.mkdirSync(extractDir, { recursive: true });
|
|
324
|
+
const tar = spawnSync('tar', ['-xzf', tmp, '-C', extractDir], { encoding: 'utf8' });
|
|
325
|
+
if (tar.status !== 0) {
|
|
326
|
+
throw new SurrealServerError('SURREAL_EXTRACT', '解压 SurrealDB 压缩包失败:' + (tar.stderr || tar.stdout), { url });
|
|
327
|
+
}
|
|
328
|
+
const extracted = path.join(extractDir, 'surreal');
|
|
329
|
+
if (!fs.existsSync(extracted)) {
|
|
330
|
+
throw new SurrealServerError('SURREAL_EXTRACT', '压缩包中未找到 surreal 可执行文件。', { url });
|
|
331
|
+
}
|
|
332
|
+
fs.copyFileSync(extracted, target);
|
|
333
|
+
fs.chmodSync(target, 0o755);
|
|
334
|
+
try { fs.unlinkSync(tmp); } catch { /* ignore */ }
|
|
335
|
+
try { fs.rmSync(extractDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
336
|
+
const versionOut = spawnSync(target, ['version'], { encoding: 'utf8' }).stdout.trim();
|
|
337
|
+
return { installed: true, already_installed: false, path: target, version: versionOut };
|
|
338
|
+
}
|