@waterwx/dsh-novel-forge 1.1.2 → 1.1.3

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/lib/index.js CHANGED
@@ -63,6 +63,10 @@ const NOVEL_API = {
63
63
  world: "/api/dsh-novel-forge/world",
64
64
  /** 封面:GET 读取(dataUrl)/ POST 上传或移除。 */
65
65
  cover: "/api/dsh-novel-forge/blurb/cover",
66
+ /** 剧情线管理:增删改 + 关联章节。 */
67
+ plotlines: "/api/dsh-novel-forge/plotlines",
68
+ /** 敏感词检查:全书已写章节或指定文本。 */
69
+ sensitiveCheck: "/api/dsh-novel-forge/sensitive-check",
66
70
  config: "/api/dsh-novel-forge/config",
67
71
  openFolder: "/api/dsh-novel-forge/open-folder"
68
72
  };
@@ -769,6 +773,7 @@ function loadProject(outputDir) {
769
773
  if (!Array.isArray(raw.assets.auxiliaryProgressions)) raw.assets.auxiliaryProgressions = [];
770
774
  if (!Array.isArray(raw.assets.styleAssets)) raw.assets.styleAssets = [];
771
775
  if (!Array.isArray(raw.facts)) raw.facts = [];
776
+ if (!Array.isArray(raw.plotlines)) raw.plotlines = [];
772
777
  return raw;
773
778
  } catch {
774
779
  return;
@@ -779,6 +784,354 @@ function saveProject(outputDir, project) {
779
784
  mkdirSync(outputDir, { recursive: true });
780
785
  writeFileSync(join(outputDir, PROJECT_FILE), JSON.stringify(project, null, 2), "utf8");
781
786
  }
787
+ /**
788
+ * 内置违禁词库(网文平台常见审查类别)。只做硬匹配提示,不代替人工判断。
789
+ * 词语刻意保持常见写法;作者可自行判断是否修改。
790
+ */
791
+ const SENSITIVE_WORDS = [
792
+ {
793
+ word: "共匪",
794
+ category: "政治"
795
+ },
796
+ {
797
+ word: "独裁",
798
+ category: "政治"
799
+ },
800
+ {
801
+ word: "法轮",
802
+ category: "政治"
803
+ },
804
+ {
805
+ word: "六四",
806
+ category: "政治"
807
+ },
808
+ {
809
+ word: "天安门事件",
810
+ category: "政治"
811
+ },
812
+ {
813
+ word: "翻墙",
814
+ category: "政治"
815
+ },
816
+ {
817
+ word: "政治敏感",
818
+ category: "政治"
819
+ },
820
+ {
821
+ word: "乳沟",
822
+ category: "擦边"
823
+ },
824
+ {
825
+ word: "酥胸",
826
+ category: "擦边"
827
+ },
828
+ {
829
+ word: "淫荡",
830
+ category: "擦边"
831
+ },
832
+ {
833
+ word: "做爱",
834
+ category: "擦边"
835
+ },
836
+ {
837
+ word: "上床",
838
+ category: "擦边"
839
+ },
840
+ {
841
+ word: "裸体",
842
+ category: "擦边"
843
+ },
844
+ {
845
+ word: "一丝不挂",
846
+ category: "擦边"
847
+ },
848
+ {
849
+ word: "胴体",
850
+ category: "擦边"
851
+ },
852
+ {
853
+ word: "春药",
854
+ category: "擦边"
855
+ },
856
+ {
857
+ word: "催情",
858
+ category: "擦边"
859
+ },
860
+ {
861
+ word: "迷奸",
862
+ category: "擦边"
863
+ },
864
+ {
865
+ word: "强暴",
866
+ category: "擦边"
867
+ },
868
+ {
869
+ word: "轮奸",
870
+ category: "擦边"
871
+ },
872
+ {
873
+ word: "援交",
874
+ category: "擦边"
875
+ },
876
+ {
877
+ word: "嫖娼",
878
+ category: "擦边"
879
+ },
880
+ {
881
+ word: "卖淫",
882
+ category: "擦边"
883
+ },
884
+ {
885
+ word: "色情",
886
+ category: "擦边"
887
+ },
888
+ {
889
+ word: "情色",
890
+ category: "擦边"
891
+ },
892
+ {
893
+ word: "撸管",
894
+ category: "擦边"
895
+ },
896
+ {
897
+ word: "自慰",
898
+ category: "擦边"
899
+ },
900
+ {
901
+ word: "口交",
902
+ category: "擦边"
903
+ },
904
+ {
905
+ word: "打炮",
906
+ category: "擦边"
907
+ },
908
+ {
909
+ word: "约炮",
910
+ category: "擦边"
911
+ },
912
+ {
913
+ word: "一夜情",
914
+ category: "擦边"
915
+ },
916
+ {
917
+ word: "碎尸",
918
+ category: "暴力"
919
+ },
920
+ {
921
+ word: "分尸",
922
+ category: "暴力"
923
+ },
924
+ {
925
+ word: "凌迟",
926
+ category: "暴力"
927
+ },
928
+ {
929
+ word: "剥皮",
930
+ category: "暴力"
931
+ },
932
+ {
933
+ word: "开膛",
934
+ category: "暴力"
935
+ },
936
+ {
937
+ word: "剖腹",
938
+ category: "暴力"
939
+ },
940
+ {
941
+ word: "挖心",
942
+ category: "暴力"
943
+ },
944
+ {
945
+ word: "虐杀",
946
+ category: "暴力"
947
+ },
948
+ {
949
+ word: "凌辱",
950
+ category: "暴力"
951
+ },
952
+ {
953
+ word: "血腥",
954
+ category: "暴力"
955
+ },
956
+ {
957
+ word: "大屠杀",
958
+ category: "暴力"
959
+ },
960
+ {
961
+ word: "灭门",
962
+ category: "暴力"
963
+ },
964
+ {
965
+ word: "满门抄斩",
966
+ category: "暴力"
967
+ },
968
+ {
969
+ word: "腰斩",
970
+ category: "暴力"
971
+ },
972
+ {
973
+ word: "活埋",
974
+ category: "暴力"
975
+ },
976
+ {
977
+ word: "点天灯",
978
+ category: "暴力"
979
+ },
980
+ {
981
+ word: "傻逼",
982
+ category: "辱骂"
983
+ },
984
+ {
985
+ word: "傻B",
986
+ category: "辱骂"
987
+ },
988
+ {
989
+ word: "草泥马",
990
+ category: "辱骂"
991
+ },
992
+ {
993
+ word: "妈的",
994
+ category: "辱骂"
995
+ },
996
+ {
997
+ word: "尼玛",
998
+ category: "辱骂"
999
+ },
1000
+ {
1001
+ word: "去死",
1002
+ category: "辱骂"
1003
+ },
1004
+ {
1005
+ word: "废物",
1006
+ category: "辱骂"
1007
+ },
1008
+ {
1009
+ word: "垃圾",
1010
+ category: "辱骂"
1011
+ },
1012
+ {
1013
+ word: "人渣",
1014
+ category: "辱骂"
1015
+ },
1016
+ {
1017
+ word: "贱人",
1018
+ category: "辱骂"
1019
+ },
1020
+ {
1021
+ word: "婊子",
1022
+ category: "辱骂"
1023
+ },
1024
+ {
1025
+ word: "狗日的",
1026
+ category: "辱骂"
1027
+ },
1028
+ {
1029
+ word: "加微信",
1030
+ category: "广告"
1031
+ },
1032
+ {
1033
+ word: "加QQ",
1034
+ category: "广告"
1035
+ },
1036
+ {
1037
+ word: "微信公众号",
1038
+ category: "广告"
1039
+ },
1040
+ {
1041
+ word: "淘宝",
1042
+ category: "广告"
1043
+ },
1044
+ {
1045
+ word: "拼多多",
1046
+ category: "广告"
1047
+ },
1048
+ {
1049
+ word: "刷单",
1050
+ category: "广告"
1051
+ },
1052
+ {
1053
+ word: "充值返利",
1054
+ category: "广告"
1055
+ },
1056
+ {
1057
+ word: "扫码领",
1058
+ category: "广告"
1059
+ },
1060
+ {
1061
+ word: "加群领",
1062
+ category: "广告"
1063
+ },
1064
+ {
1065
+ word: "vx",
1066
+ category: "广告"
1067
+ },
1068
+ {
1069
+ word: "扣扣",
1070
+ category: "广告"
1071
+ },
1072
+ {
1073
+ word: "赌博",
1074
+ category: "其他"
1075
+ },
1076
+ {
1077
+ word: "赌场",
1078
+ category: "其他"
1079
+ },
1080
+ {
1081
+ word: "毒品",
1082
+ category: "其他"
1083
+ },
1084
+ {
1085
+ word: "冰毒",
1086
+ category: "其他"
1087
+ },
1088
+ {
1089
+ word: "摇头丸",
1090
+ category: "其他"
1091
+ },
1092
+ {
1093
+ word: "自杀方法",
1094
+ category: "其他"
1095
+ },
1096
+ {
1097
+ word: "邪教",
1098
+ category: "其他"
1099
+ },
1100
+ {
1101
+ word: "传销",
1102
+ category: "其他"
1103
+ },
1104
+ {
1105
+ word: "军火",
1106
+ category: "其他"
1107
+ },
1108
+ {
1109
+ word: "枪支",
1110
+ category: "其他"
1111
+ },
1112
+ {
1113
+ word: "管制刀具",
1114
+ category: "其他"
1115
+ }
1116
+ ];
1117
+ /** 对一段文本做违禁词硬匹配,返回命中(词/类别/次数)。 */
1118
+ function checkSensitiveText(text) {
1119
+ const hits = [];
1120
+ for (const entry of SENSITIVE_WORDS) {
1121
+ let count = 0;
1122
+ let idx = text.indexOf(entry.word);
1123
+ while (idx !== -1) {
1124
+ count++;
1125
+ idx = text.indexOf(entry.word, idx + entry.word.length);
1126
+ }
1127
+ if (count > 0) hits.push({
1128
+ word: entry.word,
1129
+ category: entry.category,
1130
+ count
1131
+ });
1132
+ }
1133
+ return hits;
1134
+ }
782
1135
  /** List generated chapter files in the output dir (sorted). */
783
1136
  function listChapterFiles(outputDir) {
784
1137
  if (!existsSync(outputDir)) return [];
@@ -971,7 +1324,8 @@ async function extractBible(ctx, config, outline) {
971
1324
  ].includes(entry.role) ? entry.role : "other",
972
1325
  traits: strArray(entry.traits),
973
1326
  goals: typeof entry.goals === "string" ? entry.goals : "",
974
- relations: typeof entry.relations === "string" ? entry.relations : ""
1327
+ relations: typeof entry.relations === "string" ? entry.relations : "",
1328
+ knowledge: strArray(entry.knowledge)
975
1329
  })).filter((card) => card.name !== "") : [];
976
1330
  const bible = {
977
1331
  genre: typeof raw.genre === "string" ? raw.genre : "",
@@ -1067,6 +1421,7 @@ function writeSystemPrompt(project) {
1067
1421
  other: "其他"
1068
1422
  }[card.role];
1069
1423
  sections.push(`- ${card.name}(${roleName}):${card.traits.join("、")}${card.goals !== "" ? `;目标:${card.goals}` : ""}${card.relations !== "" ? `;关系:${card.relations}` : ""}`);
1424
+ if (Array.isArray(card.knowledge) && card.knowledge.length > 0) sections.push(` 已知信息(该角色知道的:${card.knowledge.join(";")};未列出的信息该角色一律不知道,不得写其知晓或提及)`);
1070
1425
  }
1071
1426
  }
1072
1427
  if (bible.redLines.length > 0) sections.push("写作红线(违反即失败):\n" + bible.redLines.map((r) => `- ${r}`).join("\n"));
@@ -1085,6 +1440,17 @@ function writeSystemPrompt(project) {
1085
1440
  sections.push("==================== 活跃伏笔(近期需推进或回收的线索) ====================");
1086
1441
  for (const f of active) sections.push(`- [${f.status === "planted" ? "已埋设" : "推进中"}] ${f.description}${f.targetChapter !== void 0 ? `(预计 ${f.targetChapter} 章回收)` : ""}`);
1087
1442
  }
1443
+ const lines = (project.plotlines ?? []).filter((l) => l.status === "active" || l.status === "paused");
1444
+ if (lines.length > 0) {
1445
+ const kindName = {
1446
+ main: "主线",
1447
+ branch: "支线",
1448
+ character: "人物线",
1449
+ mystery: "悬念线"
1450
+ };
1451
+ sections.push("==================== 剧情线(本章应推进至少一条活跃线) ====================");
1452
+ for (const l of lines) sections.push(`- [${kindName[l.kind]}${l.status === "paused" ? "·暂停中" : ""}] ${l.name}:${l.goal}${l.progress !== "" ? `(当前进度:${l.progress})` : ""}`);
1453
+ }
1088
1454
  sections.push("");
1089
1455
  sections.push("写作硬性要求:");
1090
1456
  sections.push("1. 每章 3000-4000 字(按中文字符计),只输出章节正文,不要输出标题、章回名、作者的话或任何 Markdown 标记。");
@@ -1187,7 +1553,10 @@ function reviewSystemPrompt(project) {
1187
1553
  if (bible.worldRules.length > 0) sections.push("世界规则:\n" + bible.worldRules.map((r) => `- ${r}`).join("\n"));
1188
1554
  if (bible.characters.length > 0) {
1189
1555
  sections.push("角色卡:");
1190
- for (const card of bible.characters) sections.push(`- ${card.name}(${card.role}):${card.traits.join("、")}`);
1556
+ for (const card of bible.characters) {
1557
+ sections.push(`- ${card.name}(${card.role}):${card.traits.join("、")}`);
1558
+ if (Array.isArray(card.knowledge) && card.knowledge.length > 0) sections.push(` 该角色知道:${card.knowledge.join(";")}(未列出的信息该角色不知道)`);
1559
+ }
1191
1560
  }
1192
1561
  if (bible.redLines.length > 0) sections.push("红线:\n" + bible.redLines.map((r) => `- ${r}`).join("\n"));
1193
1562
  }
@@ -3867,6 +4236,19 @@ function makeRoutes(deps) {
3867
4236
  if (Array.isArray(body?.worldRules)) project.bible.worldRules = body.worldRules.filter((r) => r.trim() !== "");
3868
4237
  if (Array.isArray(body?.redLines)) project.bible.redLines = body.redLines.filter((r) => r.trim() !== "");
3869
4238
  if (Array.isArray(body?.style)) project.bible.style = body.style.filter((r) => r.trim() !== "");
4239
+ if (Array.isArray(body?.characters)) project.bible.characters = body.characters.filter((c) => c !== void 0 && c !== null && typeof c.name === "string" && c.name.trim() !== "").map((c) => ({
4240
+ name: c.name.trim(),
4241
+ role: [
4242
+ "protagonist",
4243
+ "supporting",
4244
+ "antagonist",
4245
+ "other"
4246
+ ].includes(c.role) ? c.role : "other",
4247
+ traits: Array.isArray(c.traits) ? c.traits.filter((t) => typeof t === "string" && t.trim() !== "") : [],
4248
+ goals: typeof c.goals === "string" ? c.goals : "",
4249
+ relations: typeof c.relations === "string" ? c.relations : "",
4250
+ knowledge: Array.isArray(c.knowledge) ? c.knowledge.filter((k) => typeof k === "string" && k.trim() !== "") : void 0
4251
+ }));
3870
4252
  project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
3871
4253
  saveProject(config.outputDir, project);
3872
4254
  writeJson(res, 200, { bible: project.bible });
@@ -4029,6 +4411,108 @@ function makeRoutes(deps) {
4029
4411
  writeJson(res, 200, { bookName: project.bookName });
4030
4412
  }
4031
4413
  },
4414
+ {
4415
+ kind: "exact",
4416
+ path: NOVEL_API.plotlines,
4417
+ handler: async (req, res) => {
4418
+ if (!guard(req, res, "POST")) return;
4419
+ const config = getConfig();
4420
+ const project = requireProject(res);
4421
+ if (project === void 0) return;
4422
+ const body = await readJsonBody(req);
4423
+ if (project.plotlines === void 0) project.plotlines = [];
4424
+ const op = body?.op;
4425
+ if (op === "add" && body?.line !== void 0) {
4426
+ const line = body.line;
4427
+ project.plotlines.push({
4428
+ id: line.id !== "" ? line.id : `pl-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
4429
+ name: line.name.slice(0, 40),
4430
+ kind: line.kind,
4431
+ goal: line.goal.slice(0, 300),
4432
+ progress: line.progress.slice(0, 300),
4433
+ status: line.status,
4434
+ chapters: Array.isArray(line.chapters) ? line.chapters.filter((n) => typeof n === "number") : [],
4435
+ createdAt: line.createdAt !== "" ? line.createdAt : (/* @__PURE__ */ new Date()).toISOString()
4436
+ });
4437
+ } else if (op === "update" && body?.line !== void 0 && body.line.id !== "") {
4438
+ const idx = project.plotlines.findIndex((l) => l.id === body.line.id);
4439
+ if (idx !== -1) {
4440
+ const line = body.line;
4441
+ project.plotlines[idx] = {
4442
+ ...project.plotlines[idx],
4443
+ name: line.name.slice(0, 40),
4444
+ kind: line.kind,
4445
+ goal: line.goal.slice(0, 300),
4446
+ progress: line.progress.slice(0, 300),
4447
+ status: line.status
4448
+ };
4449
+ }
4450
+ } else if (op === "remove" && body?.id !== void 0) project.plotlines = project.plotlines.filter((l) => l.id !== body.id);
4451
+ else if (op === "link" && body?.id !== void 0 && typeof body.chapterNo === "number" && body.chapterNo > 0) {
4452
+ const line = project.plotlines.find((l) => l.id === body.id);
4453
+ if (line !== void 0 && !line.chapters.includes(body.chapterNo)) {
4454
+ line.chapters.push(body.chapterNo);
4455
+ if (line.status === "active" && line.progress === "") line.progress = `推进至第 ${body.chapterNo} 章`;
4456
+ }
4457
+ }
4458
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
4459
+ saveProject(config.outputDir, project);
4460
+ writeJson(res, 200, { plotlines: project.plotlines });
4461
+ }
4462
+ },
4463
+ {
4464
+ kind: "exact",
4465
+ path: NOVEL_API.sensitiveCheck,
4466
+ handler: async (req, res) => {
4467
+ if (!guard(req, res, "POST")) return;
4468
+ const config = getConfig();
4469
+ const project = requireProject(res);
4470
+ if (project === void 0) return;
4471
+ const body = await readJsonBody(req);
4472
+ const hits = [];
4473
+ let scanned = 0;
4474
+ if (body?.text !== void 0) for (const hit of checkSensitiveText(body.text)) hits.push({
4475
+ chapterNo: 0,
4476
+ word: hit.word,
4477
+ category: hit.category,
4478
+ count: hit.count
4479
+ });
4480
+ else if (typeof body?.chapterNo === "number") {
4481
+ const chapter = project.chapters.find((c) => c.no === body.chapterNo);
4482
+ if (chapter !== void 0) {
4483
+ const text = readChapterFile(config.outputDir, chapter);
4484
+ if (text !== void 0) {
4485
+ scanned = 1;
4486
+ for (const hit of checkSensitiveText(text)) hits.push({
4487
+ chapterNo: chapter.no,
4488
+ word: hit.word,
4489
+ category: hit.category,
4490
+ count: hit.count
4491
+ });
4492
+ }
4493
+ }
4494
+ } else if (body?.all === true) for (const chapter of project.chapters) {
4495
+ if (chapter.status === "pending" || chapter.status === "generating") continue;
4496
+ const text = readChapterFile(config.outputDir, chapter);
4497
+ if (text === void 0) continue;
4498
+ scanned++;
4499
+ for (const hit of checkSensitiveText(text)) hits.push({
4500
+ chapterNo: chapter.no,
4501
+ word: hit.word,
4502
+ category: hit.category,
4503
+ count: hit.count
4504
+ });
4505
+ }
4506
+ else {
4507
+ writeJson(res, 400, { error: "请提供 chapterNo / text / all 之一" });
4508
+ return;
4509
+ }
4510
+ writeJson(res, 200, {
4511
+ hits,
4512
+ scannedChapters: scanned
4513
+ });
4514
+ }
4515
+ },
4032
4516
  {
4033
4517
  kind: "exact",
4034
4518
  path: NOVEL_API.config,