update_miaoguo_literacy_2026_fall.mjs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373
  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_miaoguo_literacy_2026_fall.json");
  10. const chineseSourcePath = path.join(taskDir, "source_chinese_2026_fall.json");
  11. const sources = JSON.parse(fs.readFileSync(sourcePath, "utf8"));
  12. const chineseSources = JSON.parse(fs.readFileSync(chineseSourcePath, "utf8"));
  13. const flags = new Set(process.argv.slice(2));
  14. const knownFlags = new Set(["--dry-run", "--apply", "--audit"]);
  15. for (const flag of flags) assert(knownFlags.has(flag), `不支持的参数:${flag}`);
  16. assert(
  17. ["--dry-run", "--apply", "--audit"].filter((flag) => flags.has(flag)).length <= 1,
  18. "--dry-run、--apply、--audit 只能选择一个",
  19. );
  20. const mode = flags.has("--apply") ? "apply" : flags.has("--audit") ? "audit" : "dry-run";
  21. function nowForFile() {
  22. return new Date().toISOString().replaceAll(":", "-").replaceAll(".", "-");
  23. }
  24. function writeJson(filePath, data) {
  25. fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`, "utf8");
  26. }
  27. function splitWords(example) {
  28. return example.split(",").filter(Boolean);
  29. }
  30. function finalVocabularyWords() {
  31. return [
  32. ...new Set(
  33. chineseSources
  34. .filter((source) => source.unitType === 2)
  35. .flatMap((source) => source.units.flatMap((unit) => splitWords(unit.example))),
  36. ),
  37. ];
  38. }
  39. function buildTianKong(word, pinyin) {
  40. const chars = Array.from(word);
  41. const syllables = pinyin.split(" ");
  42. assert.equal(chars.length, syllables.length, `${word} 的字数与拼音音节数不一致`);
  43. const seen = new Set();
  44. const result = [];
  45. for (let index = 0; index < chars.length; index += 1) {
  46. const char = chars[index];
  47. if (seen.has(char)) continue;
  48. seen.add(char);
  49. result.push(
  50. chars
  51. .map((current, currentIndex) => (current === char ? syllables[currentIndex] : current))
  52. .join(""),
  53. );
  54. }
  55. return result;
  56. }
  57. function validateSources() {
  58. assert.equal(sources.length, 20, "本批次应补录 20 个词语");
  59. assert.equal(new Set(sources.map((source) => source.word)).size, sources.length, "补录词语不能重复");
  60. const vocabulary = new Set(finalVocabularyWords());
  61. for (const source of sources) {
  62. assert(vocabulary.has(source.word), `${source.word} 不在新版词语表中`);
  63. assert(source.templateWord?.length > 0, `${source.word} 缺少模板词`);
  64. assert(source.pinyin?.length > 0, `${source.word} 缺少拼音`);
  65. assert(source.explain?.length > 0, `${source.word} 缺少释义`);
  66. assert(source.english?.length > 0, `${source.word} 缺少英文释义`);
  67. assert(Array.isArray(source.synonym), `${source.word} synonym 必须是数组`);
  68. assert(Array.isArray(source.antonym), `${source.word} antonym 必须是数组`);
  69. buildTianKong(source.word, source.pinyin);
  70. }
  71. }
  72. async function loadCharacterAssets(connection) {
  73. const chars = [...new Set(sources.flatMap((source) => Array.from(source.word)))];
  74. const [rows] = await connection.query(
  75. `SELECT ID,Name,KaitiUrl,BiShunUrl
  76. FROM HanziWord
  77. WHERE Name IN (?)
  78. AND KaitiUrl IS NOT NULL AND KaitiUrl<>''
  79. AND BiShunUrl IS NOT NULL AND BiShunUrl<>''
  80. ORDER BY Name,ID`,
  81. [chars],
  82. );
  83. const assets = new Map();
  84. for (const row of rows) {
  85. if (!assets.has(row.Name)) {
  86. assets.set(row.Name, { kaitiUrl: row.KaitiUrl, biShunUrl: row.BiShunUrl });
  87. }
  88. }
  89. const missing = chars.filter((char) => !assets.has(char));
  90. assert.equal(missing.length, 0, `HanziWord 缺少字形资源:${missing.join("")}`);
  91. return { assets, distinctCharacters: chars.length };
  92. }
  93. async function validateTemplates(connection) {
  94. const templateWords = [...new Set(sources.map((source) => source.templateWord))];
  95. const [rows] = await connection.query(
  96. `SELECT ID,Word,SearchType,JSONString
  97. FROM MiaoguoLiteracy
  98. WHERE Word IN (?) AND SearchType='zici'
  99. ORDER BY Word,ID`,
  100. [templateWords],
  101. );
  102. const templates = new Map();
  103. for (const row of rows) {
  104. if (templates.has(row.Word) || !row.JSONString) continue;
  105. try {
  106. const json = JSON.parse(row.JSONString);
  107. if (json.CHN?.PinYin?.[0]) templates.set(row.Word, row);
  108. } catch {
  109. // 继续寻找同词的下一条可用记录。
  110. }
  111. }
  112. const missing = templateWords.filter((word) => !templates.has(word));
  113. assert.equal(missing.length, 0, `找不到具有完整 CHN 的模板词:${missing.join("、")}`);
  114. return templates;
  115. }
  116. function buildPayload(source, assets) {
  117. const chars = Array.from(source.word);
  118. const payload = {
  119. ENG: {
  120. Word: source.word,
  121. Soundmark: "",
  122. Paraphrase: [{ PartOfSpeech: "", ParaphraseList: source.english }],
  123. },
  124. CHN: {
  125. HanZi: source.word,
  126. HanZiImageUrl: "",
  127. BiShunUrl: "",
  128. KaitiArr: chars.map((char) => assets.get(char).kaitiUrl),
  129. BiShunArr2: chars.map((char) => assets.get(char).biShunUrl),
  130. PinYin: [{ pinyin: source.pinyin, explain: source.explain }],
  131. CombineWords: {},
  132. Antonym: source.antonym,
  133. Synonym: source.synonym,
  134. TianKong: buildTianKong(source.word, source.pinyin),
  135. PinyinTone: source.pinyin,
  136. },
  137. };
  138. assert.equal(payload.CHN.KaitiArr.length, chars.length, `${source.word} 楷体图数量不正确`);
  139. assert.equal(payload.CHN.BiShunArr2.length, chars.length, `${source.word} 笔顺图数量不正确`);
  140. return payload;
  141. }
  142. function inspectLiteracyRow(row) {
  143. if (!row?.JSONString) return { ok: false, reason: row ? "EMPTY_JSON" : "NO_ROW" };
  144. try {
  145. const json = JSON.parse(row.JSONString);
  146. if (!json.CHN) return { ok: false, reason: "MISSING_CHN" };
  147. if (!json.CHN.PinYin?.[0]?.pinyin || !json.CHN.PinYin?.[0]?.explain) {
  148. return { ok: false, reason: "INVALID_CHN_STRUCTURE" };
  149. }
  150. return { ok: true, reason: "OK", json };
  151. } catch {
  152. return { ok: false, reason: "INVALID_JSON" };
  153. }
  154. }
  155. async function readTargetRows(connection, forUpdate = false) {
  156. const lock = forUpdate ? " FOR UPDATE" : "";
  157. const [rows] = await connection.query(
  158. `SELECT * FROM MiaoguoLiteracy WHERE Word IN (?) ORDER BY Word,SearchType DESC,ID${lock}`,
  159. [sources.map((source) => source.word)],
  160. );
  161. return rows;
  162. }
  163. async function auditAllVocabulary(connection) {
  164. const words = finalVocabularyWords();
  165. const [rows] = await connection.query(
  166. `SELECT ID,Word,SearchType,JSONString
  167. FROM MiaoguoLiteracy
  168. WHERE Word IN (?)
  169. AND SearchType<>'shici'
  170. AND SearchType<>'eng'
  171. ORDER BY Word,SearchType DESC,ID`,
  172. [words],
  173. );
  174. const selectedRows = new Map();
  175. for (const row of rows) {
  176. if (!selectedRows.has(row.Word)) selectedRows.set(row.Word, row);
  177. }
  178. const problems = [];
  179. for (const word of words) {
  180. const row = selectedRows.get(word);
  181. const check = inspectLiteracyRow(row);
  182. if (!check.ok) problems.push({ word, reason: check.reason, selectedRowId: row?.ID ?? null });
  183. }
  184. return {
  185. passed: problems.length === 0,
  186. finalVocabularyWords: words.length,
  187. selectedUsableRows: words.length - problems.length,
  188. problems,
  189. };
  190. }
  191. async function auditInsertedRows(connection, expectedRows) {
  192. const rows = await readTargetRows(connection);
  193. assert.equal(rows.length, sources.length, "补录词语行数不等于 20");
  194. const actualByWord = new Map(rows.map((row) => [row.Word, row]));
  195. const checks = [];
  196. for (const expected of expectedRows) {
  197. const actual = actualByWord.get(expected.word);
  198. assert(actual, `${expected.word} 写入后不存在`);
  199. assert.equal(actual.SearchType, "zici", `${expected.word} SearchType 不正确`);
  200. assert.equal(actual.Author, "", `${expected.word} Author 不正确`);
  201. assert.equal(actual.ShiciUrl, "", `${expected.word} ShiciUrl 不正确`);
  202. const actualJson = JSON.parse(actual.JSONString);
  203. assert.deepEqual(actualJson, expected.payload, `${expected.word} JSONString 与拟写数据不一致`);
  204. checks.push({
  205. id: actual.ID,
  206. word: actual.Word,
  207. searchType: actual.SearchType,
  208. pinyin: actualJson.CHN.PinYin[0].pinyin,
  209. explain: actualJson.CHN.PinYin[0].explain,
  210. kaitiImages: actualJson.CHN.KaitiArr.length,
  211. strokeAnimations: actualJson.CHN.BiShunArr2.length,
  212. });
  213. }
  214. return checks;
  215. }
  216. async function main() {
  217. validateSources();
  218. const connection = await mysql.createConnection(config.database);
  219. try {
  220. const { assets, distinctCharacters } = await loadCharacterAssets(connection);
  221. const templates = await validateTemplates(connection);
  222. const expectedRows = sources.map((source) => ({
  223. word: source.word,
  224. templateWord: source.templateWord,
  225. templateId: templates.get(source.templateWord).ID,
  226. pinyin: source.pinyin,
  227. explain: source.explain,
  228. sourceUrl: source.sourceUrl,
  229. payload: buildPayload(source, assets),
  230. }));
  231. if (mode === "audit") {
  232. const insertedRows = await auditInsertedRows(connection, expectedRows);
  233. const vocabularyAudit = await auditAllVocabulary(connection);
  234. assert(vocabularyAudit.passed, `全部词语复查仍有 ${vocabularyAudit.problems.length} 个问题`);
  235. const report = {
  236. generatedAt: new Date().toISOString(),
  237. mode,
  238. passed: true,
  239. insertedRows,
  240. vocabularyAudit,
  241. };
  242. const reportPath = path.join(taskDir, "miaoguo_literacy_post_apply_audit_2026_fall.json");
  243. writeJson(reportPath, report);
  244. console.log(`AUDIT PASS: ${reportPath}`);
  245. return;
  246. }
  247. const existingRows = await readTargetRows(connection);
  248. assert.equal(existingRows.length, 0, "目标词语已经存在记录,拒绝重复插入");
  249. const dryRun = {
  250. generatedAt: new Date().toISOString(),
  251. mode,
  252. targetRows: expectedRows.length,
  253. existingRows: existingRows.length,
  254. distinctCharacters,
  255. templateWords: [...new Set(sources.map((source) => source.templateWord))],
  256. proposedRows: expectedRows,
  257. };
  258. if (mode === "dry-run") {
  259. const reportPath = path.join(taskDir, "miaoguo_literacy_dry_run_2026_fall.json");
  260. writeJson(reportPath, dryRun);
  261. console.log(`DRY RUN PASS: ${reportPath}`);
  262. console.log(
  263. JSON.stringify(
  264. expectedRows.map((row) => ({
  265. word: row.word,
  266. templateWord: row.templateWord,
  267. pinyin: row.pinyin,
  268. explain: row.explain,
  269. kaitiImages: row.payload.CHN.KaitiArr.length,
  270. strokeAnimations: row.payload.CHN.BiShunArr2.length,
  271. })),
  272. null,
  273. 2,
  274. ),
  275. );
  276. return;
  277. }
  278. await connection.beginTransaction();
  279. try {
  280. const lockedTargetRows = await readTargetRows(connection, true);
  281. assert.equal(lockedTargetRows.length, 0, "事务开始后发现目标词语已有记录,拒绝重复插入");
  282. const templateRows = [...templates.values()];
  283. const characterRows = [...assets.entries()].map(([name, value]) => ({ name, ...value }));
  284. const backupDir = path.join(taskDir, "backups");
  285. fs.mkdirSync(backupDir, { recursive: true });
  286. const backupPath = path.join(
  287. backupDir,
  288. `before_miaoguo_literacy_2026_fall_${nowForFile()}.json`,
  289. );
  290. writeJson(backupPath, {
  291. createdAt: new Date().toISOString(),
  292. note: "20 个目标词写入前的目标记录、模板记录和汉字资源备份",
  293. targetWords: sources.map((source) => source.word),
  294. targetRows: lockedTargetRows,
  295. templateRows,
  296. characterRows,
  297. });
  298. const insertIds = [];
  299. for (const row of expectedRows) {
  300. const [result] = await connection.execute(
  301. `INSERT INTO MiaoguoLiteracy
  302. (Word,SearchType,Author,ShiciUrl,CreateTime,JSONString)
  303. VALUES (?,?,'','',CURRENT_TIMESTAMP,?)`,
  304. [row.word, "zici", JSON.stringify(row.payload)],
  305. );
  306. insertIds.push({ word: row.word, id: result.insertId });
  307. }
  308. const transactionAudit = await auditInsertedRows(connection, expectedRows);
  309. const transactionVocabularyAudit = await auditAllVocabulary(connection);
  310. assert(
  311. transactionVocabularyAudit.passed,
  312. `事务内全部词语复查仍有 ${transactionVocabularyAudit.problems.length} 个问题`,
  313. );
  314. await connection.commit();
  315. const committedAudit = await auditInsertedRows(connection, expectedRows);
  316. const committedVocabularyAudit = await auditAllVocabulary(connection);
  317. assert(
  318. committedVocabularyAudit.passed,
  319. `提交后全部词语复查仍有 ${committedVocabularyAudit.problems.length} 个问题`,
  320. );
  321. const report = {
  322. generatedAt: new Date().toISOString(),
  323. mode,
  324. backupPath,
  325. insertIds,
  326. proposedRows: expectedRows,
  327. transactionAudit,
  328. transactionVocabularyAudit,
  329. committedAudit,
  330. committedVocabularyAudit,
  331. };
  332. const reportPath = path.join(taskDir, "miaoguo_literacy_apply_report_2026_fall.json");
  333. writeJson(reportPath, report);
  334. console.log(`APPLY AND AUDIT PASS: ${reportPath}`);
  335. console.log(`BACKUP: ${backupPath}`);
  336. } catch (error) {
  337. await connection.rollback();
  338. throw error;
  339. }
  340. } finally {
  341. await connection.end();
  342. }
  343. }
  344. main().catch((error) => {
  345. console.error(error);
  346. process.exitCode = 1;
  347. });