@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,824 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* report-validate.js —— 扫榜报告正文的**机械校验**
|
|
3
|
+
*
|
|
4
|
+
* ## 定位
|
|
5
|
+
*
|
|
6
|
+
* 只校验**能被机械判定**的东西:章节在不在、表头对不对、方向几个、
|
|
7
|
+
* 标签齐不齐、字数够不够、有没有占位符、有没有命令痕迹。
|
|
8
|
+
*
|
|
9
|
+
* **不**校验"洞察深不深""选题好不好" —— 那是模型的事。
|
|
10
|
+
* 脚本一旦开始管内容质量,模型就会为了过校验而写废话。
|
|
11
|
+
*
|
|
12
|
+
* ## 谁在用
|
|
13
|
+
*
|
|
14
|
+
* - `scripts/append-block.js`:写之前校验(不合格根本不碰目标文件);
|
|
15
|
+
* - `plugins/fancy-scan/index.js`:模型回合结束后再复核一次
|
|
16
|
+
* (哪怕模型绕过脚本,插件也会发现并告诉用户)。
|
|
17
|
+
*
|
|
18
|
+
* 两边共用这一份实现,口径不会漂。
|
|
19
|
+
*/
|
|
20
|
+
import { existsSync, readFileSync, openSync, readSync, closeSync, statSync } from 'fs';
|
|
21
|
+
import { basename } from 'path';
|
|
22
|
+
import { createHash } from 'crypto';
|
|
23
|
+
import {
|
|
24
|
+
SECTION_TITLES,
|
|
25
|
+
REPORT_RULES,
|
|
26
|
+
tableHeaders,
|
|
27
|
+
TABLE_SEP_RE,
|
|
28
|
+
DIRECTION_RE,
|
|
29
|
+
DIRECTION_LABELS,
|
|
30
|
+
} from '../templates/report-schema.js';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 文件**前 n 字节**的 sha256。
|
|
34
|
+
*
|
|
35
|
+
* 用来证明"写之前就有的内容一个字节都没被动过"——
|
|
36
|
+
* 写完之后重新算一遍同样长度的前缀,哈希必须一模一样。
|
|
37
|
+
*/
|
|
38
|
+
export function hashPrefix(path, nBytes) {
|
|
39
|
+
if (!existsSync(path) || !nBytes) return '';
|
|
40
|
+
const fd = openSync(path, 'r');
|
|
41
|
+
try {
|
|
42
|
+
const h = createHash('sha256');
|
|
43
|
+
const chunk = Buffer.alloc(64 * 1024);
|
|
44
|
+
let read = 0;
|
|
45
|
+
while (read < nBytes) {
|
|
46
|
+
const want = Math.min(chunk.length, nBytes - read);
|
|
47
|
+
const got = readSync(fd, chunk, 0, want, read);
|
|
48
|
+
if (got <= 0) break;
|
|
49
|
+
h.update(chunk.subarray(0, got));
|
|
50
|
+
read += got;
|
|
51
|
+
}
|
|
52
|
+
return h.digest('hex');
|
|
53
|
+
} finally {
|
|
54
|
+
closeSync(fd);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 记下文件此刻的状态(写之前存一份,写之后用来比对) */
|
|
59
|
+
export function captureFileState(path) {
|
|
60
|
+
if (!existsSync(path)) return { size: 0, hash: '' };
|
|
61
|
+
let size = 0;
|
|
62
|
+
try {
|
|
63
|
+
size = statSync(path).size;
|
|
64
|
+
} catch {
|
|
65
|
+
return { size: 0, hash: '' };
|
|
66
|
+
}
|
|
67
|
+
return { size, hash: hashPrefix(path, size) };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 数汉字(CJK 统一表意文字,含扩展 A) */
|
|
71
|
+
export function cjkCount(text) {
|
|
72
|
+
let n = 0;
|
|
73
|
+
for (const ch of String(text)) {
|
|
74
|
+
const c = ch.codePointAt(0);
|
|
75
|
+
if ((c >= 0x4e00 && c <= 0x9fff) || (c >= 0x3400 && c <= 0x4dbf)) n++;
|
|
76
|
+
}
|
|
77
|
+
return n;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 读数据文件头部的 `抓取时间` / `条目数`。
|
|
82
|
+
*
|
|
83
|
+
* 报告必须引用**当前文件**的抓取时间:模型在同一会话里连续跑多个回合时,
|
|
84
|
+
* 很容易直接复用上一轮上下文里的旧数字(实测 91f7ae4e 第三轮就这样),
|
|
85
|
+
* 而数据文件可能已经被重新采集覆盖。时间戳是最便宜、最确定的对账锚点。
|
|
86
|
+
*/
|
|
87
|
+
/** 把“12.3万”“1,234”“1234”统一成数字;解析不了返回 null */
|
|
88
|
+
function parseMetricValue(raw) {
|
|
89
|
+
const s = String(raw || '').replace(/[,,\s]/g, '');
|
|
90
|
+
const m = s.match(/(\d+(?:\.\d+)?)\s*(万|亿)?/);
|
|
91
|
+
if (!m) return null;
|
|
92
|
+
let n = Number(m[1]);
|
|
93
|
+
if (m[2] === '万') n *= 10000;
|
|
94
|
+
if (m[2] === '亿') n *= 100000000;
|
|
95
|
+
return Number.isFinite(n) ? n : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function escapeRe(s) {
|
|
99
|
+
return String(s || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 从引用括号里取主指标值。
|
|
104
|
+
*
|
|
105
|
+
* 先找“月票/在读/积分/热度 + 数字”的形式(最精确),没有再按
|
|
106
|
+
* 张/人/分/积分/热度 这些单位找。这样起点的“19245 张”、番茄的
|
|
107
|
+
* “在读 59.8万”、晋江的“积分 1053978496 分”、七猫的“热度 3853.8万”
|
|
108
|
+
* 都能对账;不认识的写法返回 null,避免误报。
|
|
109
|
+
*/
|
|
110
|
+
function extractMetricValue(text, metricLabel) {
|
|
111
|
+
const label = String(metricLabel || '').trim();
|
|
112
|
+
if (label) {
|
|
113
|
+
const re = new RegExp(escapeRe(label) + '\\s*[::]?\\s*(\\d+(?:\\.\\d+)?\\s*(?:万|亿)?)', 'g');
|
|
114
|
+
const hits = [...String(text || '').matchAll(re)];
|
|
115
|
+
if (hits.length) return parseMetricValue(hits[hits.length - 1][1]);
|
|
116
|
+
}
|
|
117
|
+
const unitRe = /(\d+(?:\.\d+)?\s*(?:万|亿)?)\s*(?:张|人|分|积分|热度)/g;
|
|
118
|
+
const hits = [...String(text || '').matchAll(unitRe)];
|
|
119
|
+
if (hits.length) return parseMetricValue(hits[hits.length - 1][1]);
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function readDataFileHeaders(paths, metricLabel = '月票', knownLists = []) {
|
|
124
|
+
const out = [];
|
|
125
|
+
// 字段行现在统一是 Markdown 列表项:`- **月票:** 100`。
|
|
126
|
+
// 兼容旧产物(没有 `- ` 前缀),否则引用对账会读不到指标值。
|
|
127
|
+
const metricRe = new RegExp(
|
|
128
|
+
'^(?:-\\s+)?\\*\\*' + escapeRe(metricLabel) + ':\\*\\*\\s*(.+)$'
|
|
129
|
+
);
|
|
130
|
+
for (const p of Array.isArray(paths) ? paths : []) {
|
|
131
|
+
try {
|
|
132
|
+
const text = readFileSync(p, 'utf-8');
|
|
133
|
+
const lines = text.split('\n');
|
|
134
|
+
const head = lines.slice(0, 20).join('\n');
|
|
135
|
+
const tm = /抓取时间\s*[::]\s*([^\s]+)/.exec(head);
|
|
136
|
+
const cm = /条目数\s*[::]\s*(\d+)/.exec(head);
|
|
137
|
+
const titleLine = lines.find(l => /^#\s+/.test(l)) || '';
|
|
138
|
+
// 平台文件头不一定是「# 平台 · 榜单」:
|
|
139
|
+
// 番茄 # 番茄 · 男频阅读榜 · 全 N 题材
|
|
140
|
+
// 七猫 # 七猫 · 男生 · 大热月榜
|
|
141
|
+
// 先用 manifest 里的全部榜单名做最长匹配,匹配不到再退回第一段。
|
|
142
|
+
const norm = s => String(s || '').replace(/[·\s]/g, '');
|
|
143
|
+
const known = Array.isArray(knownLists) ? knownLists : [];
|
|
144
|
+
let list = '';
|
|
145
|
+
let listNorm = '';
|
|
146
|
+
for (const label of known) {
|
|
147
|
+
const n = norm(label);
|
|
148
|
+
if (n && norm(titleLine).includes(n) && n.length > listNorm.length) {
|
|
149
|
+
list = label;
|
|
150
|
+
listNorm = n;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (!list) {
|
|
154
|
+
const lm = /^#\s*[^·]*·\s*(.+?)\s*$/.exec(titleLine);
|
|
155
|
+
list = lm ? lm[1].trim() : '';
|
|
156
|
+
}
|
|
157
|
+
const books = new Map();
|
|
158
|
+
let cur = null;
|
|
159
|
+
for (const line of lines) {
|
|
160
|
+
const bm = /^#{2,3} #(\d+) (.+)$/.exec(line);
|
|
161
|
+
if (bm) {
|
|
162
|
+
cur = { rank: Number(bm[1]), value: null };
|
|
163
|
+
books.set(bm[2].trim(), cur);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (!cur) continue;
|
|
167
|
+
const vm = metricRe.exec(line);
|
|
168
|
+
if (vm) cur.value = parseMetricValue(vm[1]);
|
|
169
|
+
}
|
|
170
|
+
out.push({
|
|
171
|
+
path: p,
|
|
172
|
+
name: basename(p),
|
|
173
|
+
list,
|
|
174
|
+
time: tm ? tm[1] : '',
|
|
175
|
+
count: cm ? cm[1] : '',
|
|
176
|
+
books,
|
|
177
|
+
});
|
|
178
|
+
} catch {
|
|
179
|
+
// 读不了的文件由预检负责,不在正文校验里重复报错
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** 表头行 → 归一化的列名(去掉首尾竖线、每列去空白、用 `|` 连接) */
|
|
186
|
+
function normalizeHeader(line) {
|
|
187
|
+
return String(line).replace(/^\s*\|/, '').replace(/\|\s*$/, '')
|
|
188
|
+
.split('|')
|
|
189
|
+
.map(s => s.trim())
|
|
190
|
+
.join('|');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** 是不是表格分隔行 */
|
|
194
|
+
function isTableSep(line) {
|
|
195
|
+
return TABLE_SEP_RE.test(line);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* 校验一份报告正文。
|
|
200
|
+
*
|
|
201
|
+
* @param {string} body 报告正文(**不含** start/end 标记行)
|
|
202
|
+
* @param {Object} opts
|
|
203
|
+
* @param {string} opts.metricLabel 热度指标名(如"月票"、"在读")
|
|
204
|
+
* @param {boolean} opts.grouped 数据是否按题材分组(番茄/晋江)
|
|
205
|
+
* @returns {{ok: boolean, errors: string[], warnings: string[], stats: Object}}
|
|
206
|
+
*/
|
|
207
|
+
export function validateReportBody(body, opts = {}) {
|
|
208
|
+
const metricLabel = opts.metricLabel || '月票';
|
|
209
|
+
const grouped = !!opts.grouped;
|
|
210
|
+
const multiList = !!opts.multiList;
|
|
211
|
+
const R = REPORT_RULES;
|
|
212
|
+
|
|
213
|
+
const text = String(body || '');
|
|
214
|
+
const lines = text.split('\n');
|
|
215
|
+
const errors = [];
|
|
216
|
+
const warnings = [];
|
|
217
|
+
|
|
218
|
+
// 乱码替换字符 U+FFFD:模型输出/传输被截断或按错误编码解码时会出现。
|
|
219
|
+
// 它不影响章节/字数等结构校验,但读者会看到“��推荐”这种坏字,
|
|
220
|
+
// 所以必须当成正文不合格拦下来,而不是沉默写进报告。
|
|
221
|
+
const replacementChars = (text.match(/\uFFFD/g) || []).length;
|
|
222
|
+
if (replacementChars) {
|
|
223
|
+
errors.push(`正文里有 ${replacementChars} 个乱码替换字符(\uFFFD)—— 内容在生成/传输中损坏,请重新生成这一段`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// 数据文件可能在本轮开始前被重新采集覆盖。报告必须引用**当前文件**
|
|
227
|
+
// 的抓取时间;只靠提示词说"不要用对话历史"挡不住模型复用旧快照。
|
|
228
|
+
const dataHeaders = readDataFileHeaders(opts.dataFiles, metricLabel, opts.knownLists);
|
|
229
|
+
for (const dh of dataHeaders) {
|
|
230
|
+
if (dh.time && !text.includes(dh.time)) {
|
|
231
|
+
errors.push(`正文没有引用数据文件 ${dh.name} 的抓取时间 ${dh.time}`
|
|
232
|
+
+ ' —— 可能用了对话历史里的旧快照;请重新读文件开头,'
|
|
233
|
+
+ '把每个数据文件的抓取时间原样写进“一、数据与方法”');
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ── 单榜报告不能比较另一个榜单 ────────────────────────────────
|
|
238
|
+
// 单榜提示词明确说"没有第二个榜单可比"。模型仍可能引用目标文件里
|
|
239
|
+
// 已有的其它榜单块(实测 29cfc65f 的推荐榜块就这样),这里直接拒收。
|
|
240
|
+
if (!multiList && dataHeaders.length === 1 && dataHeaders[0].list) {
|
|
241
|
+
const own = dataHeaders[0].list;
|
|
242
|
+
const fallbackKnown = [
|
|
243
|
+
'畅销榜', '月票榜', '阅读榜', '书友榜', '推荐榜', '更新榜',
|
|
244
|
+
'签约榜', '新书榜', '新人作者榜', '新书月榜', '完本榜', '收藏榜',
|
|
245
|
+
'男频阅读榜', '男频新书榜', '女频阅读榜', '女频新书榜',
|
|
246
|
+
'月榜', '总分榜', '季度榜', '新晋作者榜',
|
|
247
|
+
'男生大热月榜', '男生新书月榜', '女生大热月榜', '女生新书月榜',
|
|
248
|
+
];
|
|
249
|
+
const known = Array.isArray(opts.knownLists) && opts.knownLists.length
|
|
250
|
+
? opts.knownLists
|
|
251
|
+
: fallbackKnown;
|
|
252
|
+
for (const label of known) {
|
|
253
|
+
if (label === own) continue;
|
|
254
|
+
let pos = 0;
|
|
255
|
+
while ((pos = text.indexOf(label, pos)) >= 0) {
|
|
256
|
+
const before = text.slice(Math.max(0, pos - 10), pos);
|
|
257
|
+
pos += label.length;
|
|
258
|
+
// “不是月票榜 / 不引用月票榜”这类否定说明不是跨榜比较
|
|
259
|
+
if (/(不是|而非|不引用|不比较|不参考|没有|不含)/.test(before)) continue;
|
|
260
|
+
errors.push(`单榜报告只允许分析「${own}」,但正文提到了另一个榜单「${label}」`
|
|
261
|
+
+ ' —— 单榜数据不能与其它榜单比较');
|
|
262
|
+
break;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ── 引用榜单值对账 ────────────────────────────────────────────
|
|
268
|
+
// 正文写 `《书名》(阅读榜第 15 名,86408 张)` 时,必须与当前
|
|
269
|
+
// 阅读榜数据文件里的名次/月票一致。只检查**明确写了榜单**的引用;
|
|
270
|
+
// `榜内第 N 名` 只在单榜时按唯一数据文件核对,多榜时无法归因,跳过。
|
|
271
|
+
const fileByList = new Map();
|
|
272
|
+
for (const dh of dataHeaders) {
|
|
273
|
+
if (dh.list && !fileByList.has(dh.list)) fileByList.set(dh.list, dh);
|
|
274
|
+
}
|
|
275
|
+
const citeCheckRe = /《([^》]+)》(([^)\n]{0,260}))/g;
|
|
276
|
+
let citeHit;
|
|
277
|
+
while ((citeHit = citeCheckRe.exec(text))) {
|
|
278
|
+
const title = citeHit[1].trim();
|
|
279
|
+
const inside = citeHit[2];
|
|
280
|
+
const lm = /([^\s,,。;;]{1,16}榜)第\s*(\d+)\s*名/.exec(inside);
|
|
281
|
+
let target = null;
|
|
282
|
+
let rank = null;
|
|
283
|
+
if (lm) {
|
|
284
|
+
const label = lm[1].split('·').pop();
|
|
285
|
+
target = fileByList.get(label) || null;
|
|
286
|
+
rank = Number(lm[2]);
|
|
287
|
+
} else if (!multiList && dataHeaders.length === 1 && inside.includes('榜内第')) {
|
|
288
|
+
target = dataHeaders[0];
|
|
289
|
+
const rm = /榜内第\s*(\d+)\s*名/.exec(inside);
|
|
290
|
+
rank = rm ? Number(rm[1]) : null;
|
|
291
|
+
}
|
|
292
|
+
if (!target || !rank) continue;
|
|
293
|
+
const value = extractMetricValue(inside, metricLabel);
|
|
294
|
+
if (value === null) continue;
|
|
295
|
+
const book = target.books.get(title);
|
|
296
|
+
if (!book) {
|
|
297
|
+
errors.push(`引用对账失败:《${title}》不在「${target.list}」数据文件里`);
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
if (book.rank !== rank) {
|
|
301
|
+
errors.push(`引用对账失败:《${title}》在「${target.list}」是第 ${book.rank} 名,`
|
|
302
|
+
+ `正文写第 ${rank} 名`);
|
|
303
|
+
}
|
|
304
|
+
if (book.value !== null && value !== null && book.value !== value) {
|
|
305
|
+
errors.push(`引用对账失败:《${title}》在「${target.list}」是 ${book.value}(${metricLabel}),`
|
|
306
|
+
+ `正文写 ${value}`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ── 1. 章节标题 ────────────────────────────────────────────────
|
|
311
|
+
let cursor = -1;
|
|
312
|
+
let foundSections = 0;
|
|
313
|
+
for (const title of SECTION_TITLES) {
|
|
314
|
+
const want = '## ' + title;
|
|
315
|
+
const idx = lines.findIndex((l, i) => i > cursor && l.trim() === want);
|
|
316
|
+
if (idx < 0) {
|
|
317
|
+
errors.push(`缺章节标题「${want}」(七个章节必须齐全且按序出现)`);
|
|
318
|
+
} else {
|
|
319
|
+
cursor = idx;
|
|
320
|
+
foundSections++;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// 多榜单时,市场结构里必须有一节“榜单间结构”;
|
|
325
|
+
// 单榜单没有第二个榜可比,不强制。
|
|
326
|
+
if (multiList) {
|
|
327
|
+
const secStart = lines.findIndex(l => l.trim() === '## 二、市场结构(单日快照)');
|
|
328
|
+
const secEnd = lines.findIndex((l, i) => i > secStart && /^##\s+三、/.test(l));
|
|
329
|
+
const scope = secStart >= 0 ? lines.slice(secStart, secEnd >= 0 ? secEnd : lines.length) : [];
|
|
330
|
+
if (!scope.some(l => /^#{3,4}\s*榜单间结构\s*$/.test(l.trim()))) {
|
|
331
|
+
errors.push('多榜单报告缺「### 榜单间结构」小节(二、市场结构里必须回答跨榜重合度/名次差/题材分布)');
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ── 2. 方向标题 ────────────────────────────────────────────────
|
|
336
|
+
const dirPos = [];
|
|
337
|
+
lines.forEach((l, i) => { if (DIRECTION_RE.test(l)) dirPos.push(i); });
|
|
338
|
+
if (dirPos.length < R.minDirections || dirPos.length > R.maxDirections) {
|
|
339
|
+
errors.push(`方向个数是 ${dirPos.length},要求 ${R.minDirections}-${R.maxDirections} 个(标题格式 \`### 方向 N:名称\`)`);
|
|
340
|
+
}
|
|
341
|
+
const dirNums = dirPos.map(i => Number(DIRECTION_RE.exec(lines[i])[1]));
|
|
342
|
+
dirNums.forEach((n, k) => {
|
|
343
|
+
if (n !== k + 1) {
|
|
344
|
+
errors.push(`方向序号不连续:第 ${k + 1} 个方向写的是「${n}」,序号要从 1 连续编号`);
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
// 形如 `### 方向 3X:…` 的变体 —— 它不会被算成方向(正则要求数字+冒号),
|
|
348
|
+
// 但说明标题写歪了,静默忽略会让方向数莫名其妙少一个。
|
|
349
|
+
const badDir = lines.find(l => /^###\s*方向/.test(l) && !DIRECTION_RE.test(l));
|
|
350
|
+
if (badDir) {
|
|
351
|
+
errors.push(`方向标题格式不对:「${badDir.trim().slice(0, 30)}」`
|
|
352
|
+
+ ' —— 必须写成 `### 方向 N:名称`(N 是连续数字)');
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// ── 3. 每个方向的 11 项标签 ───────────────────────────────────
|
|
356
|
+
const tagStats = [];
|
|
357
|
+
dirPos.forEach((start, k) => {
|
|
358
|
+
const end = k + 1 < dirPos.length ? dirPos[k + 1] : lines.length;
|
|
359
|
+
const section = lines.slice(start, end);
|
|
360
|
+
let okTags = 0;
|
|
361
|
+
for (const lab of DIRECTION_LABELS) {
|
|
362
|
+
const re = new RegExp(
|
|
363
|
+
'^\\s*[-*+]?\\s*\\*\\*' + lab.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\*\\*\\s*[::]\\s*(.+)$'
|
|
364
|
+
);
|
|
365
|
+
const hit = section.map(l => re.exec(l)).find(Boolean);
|
|
366
|
+
if (!hit) {
|
|
367
|
+
errors.push(`方向 ${k + 1} 缺标签「${lab}」(11 项必须齐全,格式 \`- **${lab}**:内容\`)`);
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
const chars = cjkCount(hit[1]);
|
|
371
|
+
if (chars < R.minTagChars) {
|
|
372
|
+
errors.push(`方向 ${k + 1} 的「${lab}」只有 ${chars} 个汉字(要求 ≥${R.minTagChars},不能只写个标签名充数)`);
|
|
373
|
+
} else {
|
|
374
|
+
okTags++;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
tagStats.push({ direction: k + 1, okTags, total: DIRECTION_LABELS.length });
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
// ── 4. 字数 ────────────────────────────────────────────────────
|
|
381
|
+
const totalCjk = cjkCount(text);
|
|
382
|
+
if (totalCjk < R.minCjkTotal || totalCjk > R.maxCjkTotal) {
|
|
383
|
+
errors.push(`正文汉字数 ${totalCjk},要求 ${R.minCjkTotal}-${R.maxCjkTotal}`);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// 纯正文:排除表格行、标题行、分隔线、标签行
|
|
387
|
+
const pureLines = lines.filter(l => {
|
|
388
|
+
const t = l.trim();
|
|
389
|
+
if (!t) return false;
|
|
390
|
+
if (t.startsWith('#')) return false;
|
|
391
|
+
if (t.startsWith('|')) return false;
|
|
392
|
+
if (isTableSep(t)) return false;
|
|
393
|
+
if (/^[-*_]{3,}$/.test(t)) return false;
|
|
394
|
+
// 标签行(`- **定位**:…`)不算"正文"
|
|
395
|
+
if (/^\s*[-*+]?\s*\*\*[^*]+\*\*\s*[::]/.test(l)) return false;
|
|
396
|
+
return true;
|
|
397
|
+
});
|
|
398
|
+
const pureCjk = cjkCount(pureLines.join('\n'));
|
|
399
|
+
if (pureCjk < R.minPureCjk) {
|
|
400
|
+
errors.push(`去掉表格/标题/标签行之后只剩 ${pureCjk} 个汉字(要求 ≥${R.minPureCjk},正文不能全靠表格撑着)`);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// ── 5. 表格 ────────────────────────────────────────────────────
|
|
404
|
+
const headers = tableHeaders(metricLabel, grouped);
|
|
405
|
+
// 表头里的空格不能参与比较:模型写 `题材|书目数|…` 也算同一张表。
|
|
406
|
+
// 这里必须先归一化再和 `head` 比 —— 之前拿归一化后的 head 去比
|
|
407
|
+
// 带空格的原始表头,导致下面的行级检查("全部"行、份额列)全是死代码。
|
|
408
|
+
const normalizedHeaders = Object.fromEntries(
|
|
409
|
+
Object.entries(headers).map(([k, v]) => [k, normalizeHeader(v)])
|
|
410
|
+
);
|
|
411
|
+
const foundTables = [];
|
|
412
|
+
const topicTables = [];
|
|
413
|
+
const competitionTables = [];
|
|
414
|
+
let choiceTableRows = [];
|
|
415
|
+
for (let i = 0; i < lines.length; i++) {
|
|
416
|
+
if (!isTableSep(lines[i])) continue;
|
|
417
|
+
const head = normalizeHeader(lines[i - 1] || '');
|
|
418
|
+
const rows = [];
|
|
419
|
+
let j = i + 1;
|
|
420
|
+
while (j < lines.length && lines[j].trim().startsWith('|')) {
|
|
421
|
+
rows.push(normalizeHeader(lines[j]));
|
|
422
|
+
j++;
|
|
423
|
+
}
|
|
424
|
+
foundTables.push({ head, rows });
|
|
425
|
+
if (head === normalizedHeaders.topic) topicTables.push(rows);
|
|
426
|
+
if (head === normalizedHeaders.competition) competitionTables.push(rows);
|
|
427
|
+
if (head === normalizedHeaders.choice) choiceTableRows = rows;
|
|
428
|
+
}
|
|
429
|
+
if (foundTables.length < R.minTables) {
|
|
430
|
+
errors.push(`只有 ${foundTables.length} 张表,要求 ≥${R.minTables} 张`);
|
|
431
|
+
}
|
|
432
|
+
for (const key of Object.keys(headers)) {
|
|
433
|
+
if (!foundTables.some(t => t.head === normalizedHeaders[key])) {
|
|
434
|
+
errors.push(`缺表头「${headers[key]}」这张表(表头必须逐字一致)`);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
// 题材表里不能有"全部"汇总行(它不是题材)。
|
|
438
|
+
// 多榜单报告可能有两张题材表(每榜一张),每张都要查。
|
|
439
|
+
for (const rows of topicTables) {
|
|
440
|
+
const hit = rows.find(r => r.split('|').some(c => c.trim() === '全部'));
|
|
441
|
+
if (hit) {
|
|
442
|
+
errors.push('题材表里有「全部」这一行 —— 它是汇总不是题材,必须去掉');
|
|
443
|
+
}
|
|
444
|
+
if (rows.length < 3) {
|
|
445
|
+
errors.push(`题材表只有 ${rows.length} 行数据,至少要有 3 个题材`);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
for (const rows of competitionTables) {
|
|
449
|
+
const hit = rows.find(r => r.split('|').some(c => c.trim() === '全部'));
|
|
450
|
+
if (hit) {
|
|
451
|
+
errors.push('竞争度表里有「全部」这一行 —— 它是汇总不是题材,必须去掉');
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// 份额列必须是百分比。样本量不足只影响头部集中度/同质化程度/差异化空间三项,
|
|
456
|
+
// 不影响"该题材总量 ÷ 样本内总量"这个可算的数;写成"3 本仅描述"
|
|
457
|
+
// 会让同一列里一半是比例、一半是说明,列名与内容对不上。
|
|
458
|
+
const shareRe = /^(?:<|≤)?\d+(?:\.\d+)?%(?:\s*[((](?:高|中|低)[))])?$/;
|
|
459
|
+
const shareName = grouped ? '需求份额' : '样本内份额';
|
|
460
|
+
function checkShareCells(rows, headerText, tableName) {
|
|
461
|
+
const cols = headerText.split('|').map(s => s.trim());
|
|
462
|
+
const idx = cols.indexOf(shareName);
|
|
463
|
+
if (idx < 0) return;
|
|
464
|
+
rows.forEach((row, ri) => {
|
|
465
|
+
const cells = row.split('|').map(s => s.trim());
|
|
466
|
+
const val = (cells[idx] || '').trim();
|
|
467
|
+
if (!shareRe.test(val)) {
|
|
468
|
+
errors.push(`${tableName}第 ${ri + 1} 行「${shareName}」不是百分比:「${val || '(空)'}」`
|
|
469
|
+
+ ' —— 样本量不足只影响其余指标,份额仍要按总量计算');
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
for (const rows of topicTables) checkShareCells(rows, headers.topic, '题材表');
|
|
474
|
+
for (const rows of competitionTables) checkShareCells(rows, headers.competition, '竞争度表');
|
|
475
|
+
|
|
476
|
+
// 份额的高/中/低必须按 n 的三分位切:前 ⌊n/3⌋ 高、后 ⌊n/3⌋ 低、
|
|
477
|
+
// 中间为中。只核对“所有行都带档位”的表,避免把没写档位的旧报告误判。
|
|
478
|
+
function checkShareTiers(rows, headerText, tableName) {
|
|
479
|
+
const cols = headerText.split('|').map(s => s.trim());
|
|
480
|
+
const idx = cols.indexOf(shareName);
|
|
481
|
+
if (idx < 0) return;
|
|
482
|
+
const tiers = [];
|
|
483
|
+
for (const row of rows) {
|
|
484
|
+
const cells = row.split('|').map(s => s.trim());
|
|
485
|
+
const m = /^(?:<|≤)?\d+(?:\.\d+)?%[((](高|中|低)[))]$/.exec(cells[idx] || '');
|
|
486
|
+
if (!m) return;
|
|
487
|
+
tiers.push(m[1]);
|
|
488
|
+
}
|
|
489
|
+
const n = tiers.length;
|
|
490
|
+
if (n < 3) return;
|
|
491
|
+
const edge = Math.floor(n / 3);
|
|
492
|
+
const want = { 高: edge, 低: edge, 中: n - edge * 2 };
|
|
493
|
+
const got = { 高: 0, 中: 0, 低: 0 };
|
|
494
|
+
for (const t of tiers) got[t]++;
|
|
495
|
+
if (got.高 !== want.高 || got.中 !== want.中 || got.低 !== want.低) {
|
|
496
|
+
errors.push(`${tableName}的高/中/低分档数量是 高${got.高}/中${got.中}/低${got.低},`
|
|
497
|
+
+ `按 ${n} 个题材应为 高${want.高}/中${want.中}/低${want.低}`
|
|
498
|
+
+ '(前 ⌊n/3⌋ 高、后 ⌊n/3⌋ 低、其余为中)');
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
for (const rows of topicTables) checkShareTiers(rows, headers.topic, '题材表');
|
|
502
|
+
for (const rows of competitionTables) checkShareTiers(rows, headers.competition, '竞争度表');
|
|
503
|
+
|
|
504
|
+
// 头部集中度 / 同质化程度的阈值是 0.8 / 0.7,所以表里必须写
|
|
505
|
+
// 0–1 的小数(0.56、0.4286),不能写 56.07% / 42.86%。
|
|
506
|
+
// 百分比写法会被读者当成 56.07 去比 0.8,和"(中)"的档位自相矛盾。
|
|
507
|
+
const fractionRe = /^(?:0|1)(?:\.\d+)?\s*(?:[((](?:高|中|低|只描述|仅描述)[))])?$/;
|
|
508
|
+
const smallSample = '样本不足';
|
|
509
|
+
for (const rows of competitionTables) {
|
|
510
|
+
rows.forEach((row, ri) => {
|
|
511
|
+
const cells = row.split('|').map(s => s.trim());
|
|
512
|
+
const checks = [
|
|
513
|
+
[2, '头部集中度'],
|
|
514
|
+
[3, '同质化程度'],
|
|
515
|
+
];
|
|
516
|
+
for (const [idx, label] of checks) {
|
|
517
|
+
const val = (cells[idx] || '').trim();
|
|
518
|
+
if (val === smallSample) continue;
|
|
519
|
+
// 3-4 本只描述时允许写“只描述/仅描述”,样本量检查会确保
|
|
520
|
+
// 只有 3-4 本才能用这两个词。
|
|
521
|
+
if (val === '只描述' || val === '仅描述') continue;
|
|
522
|
+
if (!fractionRe.test(val)) {
|
|
523
|
+
errors.push(`竞争度表第 ${ri + 1} 行「${label}」应写 0–1 的小数、`
|
|
524
|
+
+ `“只描述”或“样本不足”,不能写百分比,也不能在数值后加说明:「${val || '(空)'}」`);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// 样本量口径:≥5 正常打分;3-4 只描述(数值或“只描述”),
|
|
531
|
+
// <3 头部集中度/同质化程度/差异化空间三项“样本不足”;份额列仍按总量算。
|
|
532
|
+
// 题材数从题材表取,若有多张题材表,选与竞争度表题材重合最多的一张。
|
|
533
|
+
function parseTopicCounts(rows) {
|
|
534
|
+
const cols = headers.topic.split('|').map(s => s.trim());
|
|
535
|
+
const gi = cols.indexOf('题材');
|
|
536
|
+
const ci = cols.indexOf('书目数');
|
|
537
|
+
if (gi < 0 || ci < 0) return null;
|
|
538
|
+
const map = new Map();
|
|
539
|
+
for (const row of rows) {
|
|
540
|
+
const cells = row.split('|').map(s => s.trim());
|
|
541
|
+
const genre = cells[gi];
|
|
542
|
+
const n = Number(cells[ci]);
|
|
543
|
+
if (genre && Number.isFinite(n)) map.set(genre, n);
|
|
544
|
+
}
|
|
545
|
+
return map;
|
|
546
|
+
}
|
|
547
|
+
const countTables = topicTables.map(parseTopicCounts).filter(Boolean);
|
|
548
|
+
for (const rows of competitionTables) {
|
|
549
|
+
let counts = null;
|
|
550
|
+
let best = -1;
|
|
551
|
+
for (const map of countTables) {
|
|
552
|
+
let score = 0;
|
|
553
|
+
for (const row of rows) {
|
|
554
|
+
const genre = row.split('|')[0].trim();
|
|
555
|
+
if (map.has(genre)) score++;
|
|
556
|
+
}
|
|
557
|
+
if (score > best) {
|
|
558
|
+
best = score;
|
|
559
|
+
counts = map;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
if (!counts) continue;
|
|
563
|
+
rows.forEach((row, ri) => {
|
|
564
|
+
const cells = row.split('|').map(s => s.trim());
|
|
565
|
+
const genre = cells[0];
|
|
566
|
+
const n = counts.get(genre);
|
|
567
|
+
if (!n) return;
|
|
568
|
+
const head = cells[2] || '';
|
|
569
|
+
const hom = cells[3] || '';
|
|
570
|
+
const diff = cells[4] || '';
|
|
571
|
+
const isSampleInsufficient = v => v.startsWith('样本不足');
|
|
572
|
+
const isDescribe = v => v.includes('只描述') || v.includes('仅描述');
|
|
573
|
+
if (n < 3) {
|
|
574
|
+
for (const [idx, label] of [[2, '头部集中度'], [3, '同质化程度'], [4, '差异化空间']]) {
|
|
575
|
+
if (!isSampleInsufficient(cells[idx] || '')) {
|
|
576
|
+
errors.push(`竞争度表第 ${ri + 1} 行「${label}」:${genre} 只有 ${n} 本`
|
|
577
|
+
+ '(<3),必须写“样本不足”');
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
} else if (n <= 4) {
|
|
581
|
+
for (const [idx, label] of [[2, '头部集中度'], [3, '同质化程度']]) {
|
|
582
|
+
if (isSampleInsufficient(cells[idx] || '')) {
|
|
583
|
+
errors.push(`竞争度表第 ${ri + 1} 行「${label}」:${genre} 有 ${n} 本`
|
|
584
|
+
+ '(3-4 本档),只能描述,不能写“样本不足”');
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
if (isSampleInsufficient(diff) || /^(高|中|低)/.test(diff)) {
|
|
588
|
+
errors.push(`竞争度表第 ${ri + 1} 行「差异化空间」:${genre} 有 ${n} 本`
|
|
589
|
+
+ '(3-4 本档),只能写“只描述/仅描述”,不能打分或写“样本不足”');
|
|
590
|
+
}
|
|
591
|
+
} else {
|
|
592
|
+
for (const [idx, label] of [[2, '头部集中度'], [3, '同质化程度']]) {
|
|
593
|
+
if (isSampleInsufficient(cells[idx] || '') || isDescribe(cells[idx] || '')) {
|
|
594
|
+
errors.push(`竞争度表第 ${ri + 1} 行「${label}」:${genre} 有 ${n} 本`
|
|
595
|
+
+ '(≥5 本档),必须正常打分,不能只描述');
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
if (isSampleInsufficient(diff) || isDescribe(diff) || !/^(高|中|低)/.test(diff)) {
|
|
599
|
+
errors.push(`竞争度表第 ${ri + 1} 行「差异化空间」:${genre} 有 ${n} 本`
|
|
600
|
+
+ '(≥5 本档),必须给高/中/低,不能只描述');
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// 选题对比表的“优先级”列必须是名次 1..N(从上到下递增),
|
|
607
|
+
// 不是加权总分。之前模型在列里填 4.44 / 3.83,读者无法一眼看出排名。
|
|
608
|
+
if (choiceTableRows.length) {
|
|
609
|
+
const nums = choiceTableRows.map(row => {
|
|
610
|
+
const cells = row.split('|').map(s => s.trim());
|
|
611
|
+
return Number(cells[cells.length - 1]);
|
|
612
|
+
});
|
|
613
|
+
const valid = nums.every((p, i) => Number.isInteger(p) && p === i + 1);
|
|
614
|
+
if (!valid) {
|
|
615
|
+
errors.push(`选题对比表的“优先级”列必须是 1–${choiceTableRows.length} 的名次`
|
|
616
|
+
+ `(从上到下递增),当前是 ${nums.map(x => Number.isFinite(x) ? x : '空').join('/')}`);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// 优先级打分是自由文本,但常见写法是
|
|
621
|
+
// "方向 N ... a/b/c/待确认,加权后为 X"。
|
|
622
|
+
// 能解析就核对算术与排序;解析不到就跳过(不因为写法不同而误报)。
|
|
623
|
+
const scoreRows = [];
|
|
624
|
+
for (const chunk of text.split(/[;;\n]/)) {
|
|
625
|
+
const m = chunk.match(
|
|
626
|
+
/方向\s*(\d+)[^。;\n]{0,60}?(\d+)\s*\/\s*(\d+)\s*\/\s*(\d+)[^。;\n]{0,60}?加权后为\s*(\d+(?:\.\d+)?)/
|
|
627
|
+
);
|
|
628
|
+
if (!m) continue;
|
|
629
|
+
const scores = [Number(m[2]), Number(m[3]), Number(m[4])];
|
|
630
|
+
const expected = Math.round(
|
|
631
|
+
((scores[0] * 40 + scores[1] * 25 + scores[2] * 25) / 90) * 100
|
|
632
|
+
) / 100;
|
|
633
|
+
scoreRows.push({
|
|
634
|
+
direction: Number(m[1]),
|
|
635
|
+
scores,
|
|
636
|
+
stated: Number(m[5]),
|
|
637
|
+
expected,
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
for (const r of scoreRows) {
|
|
641
|
+
if (Math.abs(r.expected - r.stated) > 0.011) {
|
|
642
|
+
warnings.push(`优先级打分算术不一致:方向 ${r.direction} 的 ${r.scores.join('/')}`
|
|
643
|
+
+ ` 按 90% 归一化应为 ${r.expected.toFixed(2)},正文写 ${r.stated.toFixed(2)}`);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
if (scoreRows.length > 1 && choiceTableRows.length) {
|
|
647
|
+
const priorityByDir = new Map();
|
|
648
|
+
for (const row of choiceTableRows) {
|
|
649
|
+
const cells = row.split('|').map(s => s.trim());
|
|
650
|
+
if (cells.length < 2) continue;
|
|
651
|
+
const dm = /^方向\s*(\d+)/.exec(cells[0]);
|
|
652
|
+
const p = Number(cells[cells.length - 1]);
|
|
653
|
+
if (dm && Number.isFinite(p)) priorityByDir.set(Number(dm[1]), p);
|
|
654
|
+
}
|
|
655
|
+
for (let i = 0; i < scoreRows.length; i++) {
|
|
656
|
+
for (let j = i + 1; j < scoreRows.length; j++) {
|
|
657
|
+
const a = scoreRows[i];
|
|
658
|
+
const b = scoreRows[j];
|
|
659
|
+
const pa = priorityByDir.get(a.direction);
|
|
660
|
+
const pb = priorityByDir.get(b.direction);
|
|
661
|
+
if (!pa || !pb || Math.abs(a.expected - b.expected) < 0.005) continue;
|
|
662
|
+
const wrong = (a.expected > b.expected && pa > pb)
|
|
663
|
+
|| (b.expected > a.expected && pb > pa);
|
|
664
|
+
if (wrong) {
|
|
665
|
+
warnings.push(`选题对比表的优先级与加权总分不一致:方向 ${a.direction}`
|
|
666
|
+
+ `(${a.expected.toFixed(2)})与方向 ${b.direction}(${b.expected.toFixed(2)})`
|
|
667
|
+
+ ` 的顺序对不上(表里优先级 ${pa} vs ${pb})`);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// ── 6. 引用(书名 + 指标) ──────────────────────────────────────
|
|
674
|
+
// 数的是**出现次数**,不是行数 —— 一行里写了两本书的两条佐证也算两处。
|
|
675
|
+
const citeRe = new RegExp(
|
|
676
|
+
'《[^》]+》[^。\\n]{0,60}?' + metricLabel + '[^。\\n]{0,20}?\\d', 'g'
|
|
677
|
+
);
|
|
678
|
+
const citations = (text.match(citeRe) || []).length;
|
|
679
|
+
if (citations < R.minCitations) {
|
|
680
|
+
errors.push(`只有 ${citations} 处「《书名》…${metricLabel}…」式的引用,要求 ≥${R.minCitations} 处(结论要有书名 + 排名 + ${metricLabel} 佐证)`);
|
|
681
|
+
}
|
|
682
|
+
// 佐证必须落在“题材/市场分析”里,不能全堆在最后的选题建议里。
|
|
683
|
+
const beforeCompetition = text.split('## 五、竞争度评估')[0] || '';
|
|
684
|
+
const earlyCitations = (beforeCompetition.match(citeRe) || []).length;
|
|
685
|
+
if (earlyCitations < R.minCitations) {
|
|
686
|
+
errors.push(`「五、竞争度评估」之前只有 ${earlyCitations} 处「《书名》…${metricLabel}…」引用,要求 ≥${R.minCitations} 处(题材结论不能没有榜单数据佐证)`);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// ── 7. 推断标记 ────────────────────────────────────────────────
|
|
690
|
+
const inferCount = (text.match(/推断/g) || []).length;
|
|
691
|
+
if (inferCount < R.minInferMarks) {
|
|
692
|
+
errors.push(`全文只有 ${inferCount} 处「推断」标记,要求 ≥${R.minInferMarks} 处(数据里没有的东西必须标"推断")`);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// ── 8. 占位符 / 标记行 / 命令痕迹 ─────────────────────────────
|
|
696
|
+
if (text.includes('<本次正文>')) {
|
|
697
|
+
errors.push('正文里还留着 `<本次正文>` 占位符 —— 内容没真正替换');
|
|
698
|
+
}
|
|
699
|
+
const markerLine = lines.find(l => /^#\s+\S+-(start|end)\s*$/.test(l.trim()));
|
|
700
|
+
if (markerLine) {
|
|
701
|
+
errors.push(`正文里有一行标记「${markerLine.trim()}」—— start/end 标记由脚本写入,你不用自己写`);
|
|
702
|
+
}
|
|
703
|
+
const heredoc = lines.find(l => /^(?:FANCY_SCAN_EOF|FANCY_SCAN_BODY_EOF)$/i.test(l.trim()));
|
|
704
|
+
if (heredoc) {
|
|
705
|
+
errors.push(`正文里有一行 \`${heredoc.trim()}\` —— 那是命令的结束标记,不能进正文`);
|
|
706
|
+
}
|
|
707
|
+
const cmdTrace = lines.find(l => /\b(cat\s+>>|python3|grep\s+-c|node\s+\S+\.js|<<)/.test(l));
|
|
708
|
+
if (cmdTrace) {
|
|
709
|
+
errors.push(`正文里混进了命令:「${cmdTrace.trim().slice(0, 60)}」—— 写进去的应该是分析,不是命令`);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// ── 9. 软告警(不拦) ─────────────────────────────────────────
|
|
713
|
+
const trendWords = ['增长', '上升', '降温', '较上月', '同比', '环比'];
|
|
714
|
+
const trendHits = [];
|
|
715
|
+
// 第六节是选题方向设计,"每一步上升""逐级上升"说的是人物弧线,
|
|
716
|
+
// 不是榜单趋势。只跳过这一节,前五节与第七节仍然检查。
|
|
717
|
+
const secSix = lines.findIndex(l => l.trim() === '## 六、选题方向建议');
|
|
718
|
+
const secSeven = lines.findIndex(l => l.trim() === '## 七、风险与验证');
|
|
719
|
+
for (let li = 0; li < lines.length; li++) {
|
|
720
|
+
if (secSix >= 0 && li > secSix && (secSeven < 0 || li < secSeven)) continue;
|
|
721
|
+
const line = lines[li];
|
|
722
|
+
for (const w of trendWords) {
|
|
723
|
+
let idx = line.indexOf(w);
|
|
724
|
+
while (idx >= 0) {
|
|
725
|
+
// 否定句里的趋势词不是趋势结论:
|
|
726
|
+
// "全文不写'上升''降温'一类判断" / "没有历史数据,任何'上升'都缺乏依据"。
|
|
727
|
+
// 只看词前 40 字与词后 20 字,避免把真正的趋势结论也吞掉。
|
|
728
|
+
const before = line.slice(Math.max(0, idx - 40), idx);
|
|
729
|
+
const after = line.slice(idx + w.length, idx + w.length + 20);
|
|
730
|
+
const negated = /(不写|不要|不得|不能写|不能读作|不能视为|不能当|没有|缺乏|不代表|不构成|并非|不是|非)/.test(before)
|
|
731
|
+
|| /(一类趋势|趋势判断|缺乏|没有数据)/.test(after);
|
|
732
|
+
// 排名变动里的“上升/下降 N 本/位”是单次名次变化,不是趋势结论。
|
|
733
|
+
// 提示词明确允许写“名次较上次上升 N 位”,不能把它当趋势告警。
|
|
734
|
+
const rankChange = /(排名变动|名次较上次|名次变化|上升\s*\d+\s*本|下降\s*\d+\s*本)/.test(line);
|
|
735
|
+
if (!negated && !rankChange && !trendHits.includes(w)) trendHits.push(w);
|
|
736
|
+
idx = line.indexOf(w, idx + w.length);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
if (trendHits.length) {
|
|
741
|
+
warnings.push(`正文里出现了趋势词(${trendHits.join('、')})—— 本次是单日快照,除非有对比数据否则不要写趋势`);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
return {
|
|
745
|
+
ok: errors.length === 0,
|
|
746
|
+
errors,
|
|
747
|
+
warnings,
|
|
748
|
+
stats: {
|
|
749
|
+
totalCjk,
|
|
750
|
+
pureCjk,
|
|
751
|
+
directions: dirPos.length,
|
|
752
|
+
tags: tagStats,
|
|
753
|
+
tables: foundTables.length,
|
|
754
|
+
citations,
|
|
755
|
+
earlyCitations,
|
|
756
|
+
inferMarks: inferCount,
|
|
757
|
+
sections: foundSections,
|
|
758
|
+
multiList,
|
|
759
|
+
},
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* 复核"这一次到底写进去没有、写对没有"。
|
|
765
|
+
*
|
|
766
|
+
* 脚本已经校验过一遍了,但模型**可能绕过脚本**(自己 `cat >>`、用 write 覆盖…)。
|
|
767
|
+
* 所以插件在模型回合结束后**自己再查一遍**:
|
|
768
|
+
*
|
|
769
|
+
* - 本次块在不在、是不是只有一份、闭合了没有;
|
|
770
|
+
* - 块正文过不过机械校验;
|
|
771
|
+
* - 写之前就有的内容有没有被动过。
|
|
772
|
+
*
|
|
773
|
+
* 这一层是"校验不许跳过"的最后一道保险 ——
|
|
774
|
+
* 就算模型没走脚本,插件也会发现并告诉用户。
|
|
775
|
+
*
|
|
776
|
+
* @param {string} filePath 目标文件
|
|
777
|
+
* @param {string} blockId 块 ID(平台-榜单-日期)
|
|
778
|
+
* @param {Object} opts 传给 validateReportBody 的参数
|
|
779
|
+
* @param {Object|null} pre 写之前的状态(captureFileState 的结果)
|
|
780
|
+
* @returns {{ok: boolean, problems: string[]}}
|
|
781
|
+
*/
|
|
782
|
+
export function verifyBlockWritten(filePath, blockId, opts = {}, pre = null) {
|
|
783
|
+
const problems = [];
|
|
784
|
+
if (!existsSync(filePath)) {
|
|
785
|
+
return { ok: false, problems: ['目标文件不存在 —— 这次分析没有写进去'] };
|
|
786
|
+
}
|
|
787
|
+
const raw = readFileSync(filePath, 'utf-8');
|
|
788
|
+
const lines = raw.split('\n');
|
|
789
|
+
const startMarker = `# ${blockId}-start`;
|
|
790
|
+
const endMarker = `# ${blockId}-end`;
|
|
791
|
+
const starts = lines.filter(l => l.trim() === startMarker).length;
|
|
792
|
+
const ends = lines.filter(l => l.trim() === endMarker).length;
|
|
793
|
+
|
|
794
|
+
if (starts !== 1) {
|
|
795
|
+
problems.push(`本次块(${blockId})的 start 标记有 ${starts} 个(应为 1)`
|
|
796
|
+
+ (starts === 0 ? ' —— 这次分析没有写进去' : ' —— 写重了'));
|
|
797
|
+
}
|
|
798
|
+
if (ends !== 1) {
|
|
799
|
+
problems.push(`本次块(${blockId})的 end 标记有 ${ends} 个(应为 1)`);
|
|
800
|
+
}
|
|
801
|
+
if (starts === 1 && ends === 1) {
|
|
802
|
+
const i = lines.findIndex(l => l.trim() === startMarker);
|
|
803
|
+
const j = lines.findIndex(l => l.trim() === endMarker);
|
|
804
|
+
if (j <= i) {
|
|
805
|
+
problems.push('end 标记在 start 标记之前 —— 块是坏的');
|
|
806
|
+
} else {
|
|
807
|
+
const body = lines.slice(i + 1, j).join('\n');
|
|
808
|
+
const v = validateReportBody(body, opts);
|
|
809
|
+
if (!v.ok) {
|
|
810
|
+
problems.push(...v.errors.slice(0, 6).map(e => '块正文不合格:' + e));
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// 旧内容一个字节都不能少
|
|
816
|
+
if (pre && pre.size > 0) {
|
|
817
|
+
const after = hashPrefix(filePath, pre.size);
|
|
818
|
+
if (after !== pre.hash) {
|
|
819
|
+
problems.push('写之前就有的内容被改动了 —— 旧块被覆盖了');
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
return { ok: problems.length === 0, problems };
|
|
824
|
+
}
|