audit_miaoguo_literacy_2026_fall.mjs 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
  1. #!/usr/bin/env node
  2. import assert from "node:assert/strict";
  3. import fs from "node:fs";
  4. import path from "node:path";
  5. import { fileURLToPath } from "node:url";
  6. import mysql from "mysql2/promise";
  7. import config from "../../src/config/dev.js";
  8. const taskDir = path.dirname(fileURLToPath(import.meta.url));
  9. const sourcePath = path.join(taskDir, "source_chinese_2026_fall.json");
  10. const applyReportPath = path.join(taskDir, "apply_report_2026_fall.json");
  11. const jsonReportPath = path.join(
  12. taskDir,
  13. "miaoguo_literacy_dependency_audit_current_2026_fall.json",
  14. );
  15. const markdownReportPath = path.join(taskDir, "MiaoguoLiteracy依赖检查_2026年秋季.md");
  16. const sources = JSON.parse(fs.readFileSync(sourcePath, "utf8")).filter(
  17. (source) => source.unitType === 2,
  18. );
  19. const applyReport = JSON.parse(fs.readFileSync(applyReportPath, "utf8"));
  20. const backup = JSON.parse(fs.readFileSync(applyReport.backupPath, "utf8"));
  21. function splitWords(example) {
  22. return example.split(",").filter(Boolean);
  23. }
  24. function inspectJson(row) {
  25. if (!row) return { status: "NO_ROW", detail: "MiaoguoLiteracy 中没有可用记录" };
  26. if (!row.JSONString) return { status: "EMPTY_JSON", detail: "JSONString 为空" };
  27. try {
  28. const json = JSON.parse(row.JSONString);
  29. if (!json.CHN) return { status: "MISSING_CHN", detail: "JSONString 中不存在 CHN" };
  30. if (
  31. !Array.isArray(json.CHN.PinYin) ||
  32. !json.CHN.PinYin[0]?.pinyin ||
  33. !json.CHN.PinYin[0]?.explain
  34. ) {
  35. return {
  36. status: "INVALID_CHN_STRUCTURE",
  37. detail: "CHN.PinYin[0].pinyin/explain 不完整,现有接口无法使用",
  38. };
  39. }
  40. return { status: "OK", detail: "存在可用 CHN" };
  41. } catch (error) {
  42. return { status: "INVALID_JSON", detail: error.message };
  43. }
  44. }
  45. function writeReports(report) {
  46. fs.writeFileSync(jsonReportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
  47. const lines = [
  48. "# MiaoguoLiteracy 缺失清单(2026 年秋季)",
  49. "",
  50. `检查时间:${report.generatedAt}`,
  51. "",
  52. "检查口径:检查三套新版词语表的全部最终词语;按线上接口过滤掉 `SearchType=shici/eng`,并按 `Word,SearchType DESC` 选择每个词的首条记录。除检查 `CHN` 外,还检查了接口实际使用的 `CHN.PinYin[0].pinyin/explain`。",
  53. "",
  54. `- 新版词语总数:${report.summary.finalOccurrences}`,
  55. `- 不重复词语数:${report.summary.uniqueFinalWords}`,
  56. `- 相比更新前新出现的不重复词语:${report.summary.uniqueNewlyIntroducedWords}`,
  57. `- 已有可用 CHN:${report.summary.usableWords}`,
  58. `- 缺少可用 CHN:${report.summary.problemWords}`,
  59. "",
  60. "## 需要新增的词语",
  61. "",
  62. "| 词语 | 数据集 | 单元 | 状态 | 相对旧版 |",
  63. "|---|---|---|---|---|",
  64. ];
  65. for (const problem of report.problems) {
  66. lines.push(
  67. `| ${problem.word} | ${problem.locations.map((item) => item.label).join("、")} | ${problem.locations.map((item) => item.unit).join("、")} | ${problem.status}: ${problem.detail} | ${problem.isNewlyIntroduced ? "新出现" : "旧版已有"} |`,
  68. );
  69. }
  70. lines.push(
  71. "",
  72. "## 补充说明",
  73. "",
  74. `另发现 ${report.nonBlockingLowerPriorityIssues.length} 条低优先级 \`SearchType=list\` 记录自身没有 CHN;这些词同时存在排序更靠前且具有完整 CHN 的 \`zici\` 记录,线上接口实际会使用 \`zici\`,所以没有列入待新增清单。`,
  75. "",
  76. "本报告只做查询和分析,没有向 `MiaoguoLiteracy` 写入任何数据。",
  77. "",
  78. );
  79. fs.writeFileSync(markdownReportPath, `${lines.join("\n")}\n`, "utf8");
  80. }
  81. async function main() {
  82. assert.equal(sources.length, 3, "本批次应有三套语文词语表");
  83. const oldWordsByBook = new Map(
  84. sources.map((source) => [
  85. source.bookIdOld,
  86. new Set(
  87. backup.hanziRows
  88. .filter((row) => row.HanziBookID === source.bookIdOld)
  89. .flatMap((row) => splitWords(row.Example ?? "")),
  90. ),
  91. ]),
  92. );
  93. const references = new Map();
  94. for (const source of sources) {
  95. const oldWords = oldWordsByBook.get(source.bookIdOld);
  96. for (const unit of source.units) {
  97. for (const word of splitWords(unit.example)) {
  98. const items = references.get(word) ?? [];
  99. items.push({
  100. label: source.label,
  101. bookIdOld: source.bookIdOld,
  102. unit: unit.name,
  103. isNewlyIntroduced: !oldWords.has(word),
  104. });
  105. references.set(word, items);
  106. }
  107. }
  108. }
  109. const words = [...references.keys()];
  110. const connection = await mysql.createConnection(config.database);
  111. try {
  112. const [rows] = await connection.query(
  113. `SELECT ID,Word,SearchType,JSONString
  114. FROM MiaoguoLiteracy
  115. WHERE Word IN (?)
  116. AND SearchType<>?
  117. AND SearchType<>?
  118. ORDER BY Word,SearchType DESC,ID`,
  119. [words, "shici", "eng"],
  120. );
  121. const rowsByWord = new Map();
  122. for (const row of rows) {
  123. const items = rowsByWord.get(row.Word) ?? [];
  124. items.push(row);
  125. rowsByWord.set(row.Word, items);
  126. }
  127. const problems = [];
  128. const nonBlockingLowerPriorityIssues = [];
  129. for (const word of words) {
  130. const wordRows = rowsByWord.get(word) ?? [];
  131. const selectedRow = wordRows[0];
  132. const selectedCheck = inspectJson(selectedRow);
  133. const locations = references.get(word);
  134. if (selectedCheck.status !== "OK") {
  135. problems.push({
  136. word,
  137. status: selectedCheck.status,
  138. detail: selectedCheck.detail,
  139. selectedRow: selectedRow
  140. ? { id: selectedRow.ID, searchType: selectedRow.SearchType }
  141. : null,
  142. rowCount: wordRows.length,
  143. isNewlyIntroduced: locations.some((item) => item.isNewlyIntroduced),
  144. locations,
  145. });
  146. }
  147. for (const row of wordRows.slice(1)) {
  148. const check = inspectJson(row);
  149. if (check.status !== "OK") {
  150. nonBlockingLowerPriorityIssues.push({
  151. word,
  152. id: row.ID,
  153. searchType: row.SearchType,
  154. status: check.status,
  155. detail: check.detail,
  156. selectedRowId: selectedRow?.ID ?? null,
  157. });
  158. }
  159. }
  160. }
  161. const uniqueNewlyIntroducedWords = words.filter((word) =>
  162. references.get(word).some((item) => item.isNewlyIntroduced),
  163. ).length;
  164. const report = {
  165. generatedAt: new Date().toISOString(),
  166. readOnly: true,
  167. querySemantics:
  168. "与 GetMiaoguoLiteracyWords 一致:排除 SearchType=shici/eng,按 Word,SearchType DESC 取词语首条记录",
  169. summary: {
  170. finalOccurrences: sources.reduce((total, source) => total + source.wordNum, 0),
  171. uniqueFinalWords: words.length,
  172. uniqueNewlyIntroducedWords,
  173. eligibleDatabaseRows: rows.length,
  174. usableWords: words.length - problems.length,
  175. problemWords: problems.length,
  176. },
  177. datasets: sources.map((source) => ({
  178. label: source.label,
  179. bookIdOld: source.bookIdOld,
  180. finalWords: source.wordNum,
  181. oldUniqueWords: oldWordsByBook.get(source.bookIdOld).size,
  182. newlyIntroducedOccurrences: source.units
  183. .flatMap((unit) => splitWords(unit.example))
  184. .filter((word) => !oldWordsByBook.get(source.bookIdOld).has(word)).length,
  185. })),
  186. problems,
  187. nonBlockingLowerPriorityIssues,
  188. };
  189. writeReports(report);
  190. console.log(
  191. JSON.stringify(
  192. {
  193. summary: report.summary,
  194. missingWords: problems.map((problem) => problem.word),
  195. jsonReportPath,
  196. markdownReportPath,
  197. },
  198. null,
  199. 2,
  200. ),
  201. );
  202. if (problems.length > 0) process.exitCode = 2;
  203. } finally {
  204. await connection.end();
  205. }
  206. }
  207. main().catch((error) => {
  208. console.error(error);
  209. process.exitCode = 1;
  210. });