daily-question 2.1.0 → 2.1.1

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/bank.js CHANGED
@@ -1,4 +1,4 @@
1
- import { CHOICE_TYPES, QUESTION_TYPE_LABEL } from './subjects.js'
1
+ import { CHOICE_TYPES, QUESTION_TYPE_LABEL, hasChoiceOptions, validChoiceKey } from './subjects.js'
2
2
  import { extractPdfTextAsync } from './pdf.js'
3
3
 
4
4
  /**
@@ -9,13 +9,15 @@ import { extractPdfTextAsync } from './pdf.js'
9
9
  */
10
10
 
11
11
  const OPTION_LINE = /^\s*(?:[((]\s*([A-H])\s*[))]|([A-H])\s*[.、.::))])\s*(.+)$/
12
- const QUESTION_START = /^\s*(?:#{1,6}\s*)?(?:第\s*)?(\d{1,4})\s*(?:题|[.、.))])\s*(.*)$/
12
+ const QUESTION_START = /^\s*((?:#{1,6}\s*)?(?:第\s*)?)(\d{1,4})\s*(题|[.、.))])\s*(.*)$/
13
13
  const LABEL_LINE = /^\s*(?:【?\s*(答案|参考答案|正确答案|解析|详解|答案详解|【解析】|分析|知识点|考点|难度|题型|答|解)\s*】?\s*[::]\s*)(.*)$/
14
14
  const HARD_SEPARATOR = /^\s*(?:-{3,}|\*{3,}|={3,})\s*$/
15
15
  const BRACKET_MARK = /^\s*【\s*(题目|题干|问题)\s*】\s*(.*)$/
16
16
  const TYPE_WORDS = [
17
- ['single', ['单项选择', '单选', '选择题']],
17
+ ['cloze', ['完形填空', '英语知识运用']],
18
+ ['reading', ['阅读理解']],
18
19
  ['multiple', ['多项选择', '多选']],
20
+ ['single', ['单项选择', '单选', '选择题']],
19
21
  ['fill', ['填空']],
20
22
  ['solution', ['解答', '计算', '证明', '综合应用', '算法设计', '综合题']],
21
23
  ['analysis', ['分析题', '材料分析']],
@@ -24,6 +26,12 @@ const TYPE_WORDS = [
24
26
  ['shortanswer', ['简答']],
25
27
  ]
26
28
 
29
+ function answerSection(name) {
30
+ if (/解析|详解|^(分析|解)$/.test(name)) return 'explanation'
31
+ if (/答案|^答$/.test(name)) return 'answer'
32
+ return null
33
+ }
34
+
27
35
  /** 判断一行是否处于 ``` 代码围栏内,用于避免在代码块里切分题目。 */
28
36
  function fenceTracker() {
29
37
  let inside = false
@@ -41,29 +49,42 @@ function splitBlocks(text) {
41
49
  const isFenced = fenceTracker()
42
50
  const blocks = []
43
51
  let current = []
52
+ let inAnswer = false
53
+ let questionNumber = null
54
+ let blankBefore = false
44
55
  const push = () => {
45
56
  const body = current.join('\n').trim()
46
57
  if (body) blocks.push(body)
47
58
  current = []
59
+ inAnswer = false
60
+ questionNumber = null
48
61
  }
49
62
  for (const line of lines) {
50
63
  const fenced = isFenced(line)
51
- if (!fenced && (HARD_SEPARATOR.test(line) || BRACKET_MARK.test(line) || (QUESTION_START.test(line) && current.join('\n').trim()))) {
52
- if (BRACKET_MARK.test(line)) {
53
- push()
54
- const rest = BRACKET_MARK.exec(line)[2]
55
- current = [rest].filter(Boolean)
56
- continue
57
- }
58
- if (HARD_SEPARATOR.test(line)) {
59
- push()
60
- continue
61
- }
64
+ const bracket = fenced ? null : BRACKET_MARK.exec(line)
65
+ if (bracket || (!fenced && HARD_SEPARATOR.test(line))) {
62
66
  push()
63
- current = [line]
67
+ if (bracket?.[2]) current.push(bracket[2])
68
+ blankBefore = false
64
69
  continue
65
70
  }
71
+ const numbered = fenced ? null : QUESTION_START.exec(line)
72
+ if (numbered) {
73
+ const [, prefix, digits, suffix, stem] = numbered
74
+ const number = Number(digits)
75
+ const explicit = prefix || suffix === '题'
76
+ // shortcut: 裸数字题号与解析步骤有歧义,低置信度时请用“第 N 题”或分隔线。
77
+ const nextQuestion = blankBefore && number === questionNumber + 1
78
+ && (/^(?:已知|设|下列|试问|证明[::]|计算[::])/.test(stem) || /[??]$/.test(stem))
79
+ if (explicit || !inAnswer || nextQuestion) {
80
+ push()
81
+ questionNumber = number
82
+ }
83
+ }
84
+ const label = fenced ? null : LABEL_LINE.exec(line)
85
+ if (label && answerSection(label[1].replace(/[【】]/g, ''))) inAnswer = true
66
86
  current.push(line)
87
+ blankBefore = !line.trim()
67
88
  }
68
89
  push()
69
90
  return blocks
@@ -121,14 +142,10 @@ export function parseBlock(block, index) {
121
142
  if (label) {
122
143
  const name = label[1].replace(/[【】]/g, '')
123
144
  const rest = label[2].trim()
124
- if (name.includes('答案') || name === '答') {
125
- section = 'answer'
126
- if (rest) sections.answer.push(entry)
127
- continue
128
- }
129
- if (name.includes('解析') || name.includes('详解') || name === '分析' || name === '解') {
130
- section = 'explanation'
131
- if (rest) sections.explanation.push(entry)
145
+ const contentSection = answerSection(name)
146
+ if (contentSection) {
147
+ section = contentSection
148
+ if (rest) sections[section].push({ ...entry, text: rest })
132
149
  continue
133
150
  }
134
151
  if (name === '知识点' || name === '考点') {
@@ -153,9 +170,9 @@ export function parseBlock(block, index) {
153
170
  sections[section].push(entry)
154
171
  }
155
172
 
156
- const stemEntries = sections.stem.map((entry) => ({
173
+ const stemEntries = sections.stem.map((entry, index) => ({
157
174
  ...entry,
158
- text: QUESTION_START.test(entry.text) && !entry.fenced ? entry.text.replace(QUESTION_START, '$2').trim() : entry.text,
175
+ text: index === 0 && !entry.fenced ? entry.text.replace(QUESTION_START, '$4').trim() : entry.text,
159
176
  }))
160
177
  const optionLines = stemEntries.filter((entry) => !entry.fenced && OPTION_LINE.test(entry.text))
161
178
  const stemBody = stemEntries.filter((entry) => entry.fenced || !OPTION_LINE.test(entry.text))
@@ -184,21 +201,16 @@ export function parseBlock(block, index) {
184
201
  const issues = []
185
202
  if (!stemBody) issues.push('未识别到题干')
186
203
  else if (stemBody.length < 6) issues.push('题干过短,可能识别不完整')
187
- if (CHOICE_TYPES.has(type) && options.length < 2) issues.push('选择题未识别到选项')
204
+ if (CHOICE_TYPES.has(type) && !hasChoiceOptions(options)) issues.push('选择题需要至少两个不同且有效的选项')
188
205
  if (!answerRaw) issues.push('没有参考答案(标记为缺少参考答案)')
189
206
  if (!explanation) issues.push('没有解析(可稍后补充)')
190
207
  if (optionLines.length && !options.length) issues.push('选项行格式无法识别')
191
208
 
192
209
  let answerKey = ''
193
210
  const answerLetters = CHOICE_TYPES.has(type) ? lettersOf(answerRaw) : ''
194
- if (CHOICE_TYPES.has(type)) {
195
- if (answerLetters && options.every((option) => option.key !== answerLetters[0])) {
196
- issues.push('参考答案的选项字母不在选项列表中')
197
- } else if (type === 'single' && answerLetters.length > 1) {
198
- issues.push('参考答案给出多个选项,题型可能应为多项选择题')
199
- } else if (answerLetters) {
200
- answerKey = answerLetters
201
- }
211
+ if (answerLetters) {
212
+ if (validChoiceKey(type, answerLetters, options)) answerKey = [...answerLetters].sort().join('')
213
+ else issues.push('参考答案与题型或选项不匹配,将由用户自行判定')
202
214
  }
203
215
 
204
216
  return {
@@ -219,6 +231,11 @@ export function parseBlock(block, index) {
219
231
  }
220
232
  }
221
233
 
234
+ export function isUsableBankItem(item) {
235
+ return typeof item.stem === 'string' && item.stem.trim().length >= 6
236
+ && (!CHOICE_TYPES.has(item.type) || hasChoiceOptions(item.options))
237
+ }
238
+
222
239
  /** 像标题、说明或分隔块的片段:不含选项/答案/解析,且不像一道题。 */
223
240
  function isHeaderBlock(item) {
224
241
  if (item.options.length || item.answer || item.explanation || item.knowledge.length) return false
@@ -259,8 +276,8 @@ export function parseBankText(text, { filename = '' } = {}) {
259
276
  if (items.length === 1) {
260
277
  warnings.push('只识别到 1 道题:如果这是多题题库,请用“题号 / --- / 【题目】”等分隔符分开每道题')
261
278
  }
262
- const usable = items.filter((item) => item.stem && item.options.length + item.answer.length > 0).length
263
- if (!usable) warnings.push('没有可用题目:题干、选项与答案都没有被正确识别')
279
+ const usable = items.filter(isUsableBankItem).length
280
+ if (!usable) warnings.push('没有可用题目:请检查题干及选择题选项是否完整')
264
281
  if (filename) warnings.push(`来源文件:${filename}`)
265
282
  return { items, warnings, usable }
266
283
  }
package/lib/exam-dates.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { abortable } from './async.js'
2
+
1
3
  /**
2
4
  * 考研日期的权威性处理。
3
5
  *
@@ -269,93 +271,125 @@ export function computeCountdown(year, examInfo, today = todayISO()) {
269
271
  }
270
272
 
271
273
  function stripHtml(html) {
272
- return String(html || '')
273
- .replace(/<script[\s\S]*?<\/script>/gi, ' ')
274
- .replace(/<style[\s\S]*?<\/style>/gi, ' ')
274
+ return html
275
+ .replace(/<!--[\s\S]*?-->/g, ' ')
276
+ .replace(/<(head|title|script|style|nav|aside|footer|a)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, ' ')
277
+ .replace(/\r?\n/g, ' ')
278
+ .replace(/<\/(?:p|div|li|tr|h[1-6])\s*>|<br\b[^>]*>/gi, '\n')
275
279
  .replace(/<[^>]+>/g, ' ')
276
- .replace(/&nbsp;/g, ' ')
280
+ .replace(/&(?:nbsp|#160|#xa0);/gi, ' ')
277
281
  .replace(/&amp;/g, '&')
278
282
  .replace(/&lt;/g, '<')
279
283
  .replace(/&gt;/g, '>')
280
- .replace(/\s+/g, ' ')
281
284
  }
282
285
 
283
- function isOfficialHost(url) {
286
+ function officialUrl(value) {
284
287
  try {
285
- const host = new URL(url).hostname.toLowerCase()
286
- return OFFICIAL_HOSTS.some((suffix) => host === suffix || host.endsWith(`.${suffix}`))
288
+ const url = new URL(value)
289
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) return null
290
+ if (!OFFICIAL_HOSTS.some((host) => url.hostname === host || url.hostname.endsWith('.' + host))) return null
291
+ url.hash = ''
292
+ return url.href
287
293
  } catch {
288
- return false
294
+ return null
289
295
  }
290
296
  }
291
297
 
292
- /**
293
- * 联网核验某年的考试日期。
294
- *
295
- * 只在教育部 / 教育考试院 / 研招网域名命中“初试时间为 YYYY 年 12 月 D 日”时
296
- * 才认定为官方公布;否则如实返回未核实,不做任何猜测。
297
- */
298
+ function examSessions(text) {
299
+ return Array.from(text.matchAll(/(\d{4})年?(?:(?:全国)?硕士研究生招生|考研|研考)/g), (match) => Number(match[1]))
300
+ }
301
+
302
+ function readExamDates(body, year) {
303
+ if (typeof body?.content !== 'string' || !['html', 'text'].includes(body.kind)) return null
304
+ const html = body.kind === 'html'
305
+ const title = html ? stripHtml(/<title\b[^>]*>([\s\S]*?)<\/title\s*>/i.exec(body.content)?.[1] || '').trim() : ''
306
+ const heading = html ? stripHtml(/<h([12])\b[^>]*>([\s\S]*?)<\/h\1\s*>/i.exec(body.content)?.[2] || '') : ''
307
+ const paragraphs = html ? stripHtml(body.content.replace(/<h([1-6])\b[^>]*>[\s\S]*?<\/h\1\s*>/gi, '<br>')) : body.content
308
+ const text = paragraphs.normalize('NFKC').replace(/[^\S\r\n]+/g, '')
309
+ const titleYears = examSessions(title.normalize('NFKC').replace(/\s+/g, ''))
310
+ const pageYears = titleYears.length ? titleYears : examSessions(heading.normalize('NFKC').replace(/\s+/g, '') + '\n' + text)
311
+ if (!pageYears.includes(year)) return null
312
+
313
+ const examYear = year - 1
314
+ const pattern = new RegExp(`初试时间(?:为|是|定于|安排在|调整为)?:?${examYear}年12月(\\d{1,2})日?(?:至|到|[-–—~])(?:(?:${examYear}年)?12月)?(\\d{1,2})日`, 'g')
315
+ let result = null
316
+ for (const sentence of text.split(/[\r\n。;;,,]+/)) {
317
+ const years = examSessions(sentence)
318
+ if (years.length && !years.includes(year)) continue
319
+ for (const match of sentence.matchAll(pattern)) {
320
+ const start = Number(match[1])
321
+ const end = Number(match[2])
322
+ if (start < 1 || end < start || end > 31) continue
323
+ const date = `${examYear}-12-${pad2(start)}`
324
+ const endDate = `${examYear}-12-${pad2(end)}`
325
+ if (result && (result.date !== date || result.endDate !== endDate)) return null
326
+ result = { date, endDate, title, snippet: match[0] }
327
+ }
328
+ }
329
+ return result
330
+ }
331
+
332
+ /** 仅采信目标届次的官方正文明确给出的完整初试区间。 */
298
333
  export async function verifyExamDateOnline({ year, web, signal, fetchLimit = 3 }) {
299
334
  const checked = []
300
- const now = new Date().toISOString()
301
- if (!web || typeof web.search !== 'function') {
335
+ if (typeof web?.search !== 'function' || typeof web?.fetch !== 'function') {
302
336
  return { ok: false, reason: 'web 服务不可用,无法联网核验官方日期', checked }
303
337
  }
338
+ if (!Number.isInteger(year) || year < 1001 || year > 9999) return { ok: false, reason: '考研届次无效', checked }
304
339
  const expectYear = year - 1
305
- let sources = []
306
- try {
307
- const result = await web.search({ query: `${year}年全国硕士研究生招生考试 初试时间 12月`, maxResults: 8 }, signal)
308
- sources = Array.isArray(result?.sources) ? result.sources : []
309
- } catch (error) {
310
- return { ok: false, reason: `联网检索失败:${error?.message || error}`, checked }
311
- }
312
-
313
- const official = sources.filter((item) => item && item.url && isOfficialHost(item.url)).slice(0, fetchLimit)
314
- if (!official.length) {
315
- return {
316
- ok: false,
317
- reason: `没有检索到 ${year} 考研在官方渠道(moe.gov.cn / chsi.com.cn / neea.edu.cn)发布的初试日期,保持“尚未核实/估算”状态`,
318
- checked: sources.map((item) => item.url).slice(0, 8),
319
- }
320
- }
321
-
322
- for (const source of official) {
323
- checked.push(source.url)
340
+ const visited = new Set()
341
+ let searched = false
342
+ let searchError
343
+ for (const host of OFFICIAL_HOSTS) {
344
+ if (signal?.aborted) break
345
+ const subject = host === 'moe.gov.cn' ? '招生工作管理规定' : '招生考试'
346
+ const query = `site:${host} ${year}年全国硕士研究生${subject} 初试时间 ${expectYear}年12月`
347
+ let sources
324
348
  try {
325
- const page = await web.fetch({ url: source.url }, signal)
326
- const text = page?.body?.kind === 'html' ? stripHtml(page.body.content) : String(page?.body?.content || '')
327
- const match = new RegExp(`初试时间[^。;]{0,40}?${expectYear}年12月(\\d{1,2})日`).exec(text)
328
- || new RegExp(`${expectYear}年12月(\\d{1,2})日至`).exec(text)
329
- if (!match) continue
330
- const day = Number(match[1])
331
- if (!(day >= 1 && day <= 31)) continue
332
- const date = `${expectYear}-12-${pad2(day)}`
333
- return {
334
- ok: true,
335
- record: {
336
- date,
337
- endDate: addDays(date, 1),
338
- status: 'official',
339
- source: source.title || source.url,
340
- sourceUrl: source.url,
341
- publishedAt: source.publishedAt || '',
342
- verifiedAt: now,
343
- method: 'online-search',
344
- note: `由官方渠道页面核验:${source.url}`,
345
- },
346
- checked,
347
- snippet: match[0],
348
- }
349
+ const result = await abortable(() => web.search({ query, maxResults: 8 }, signal), signal)
350
+ searched = true
351
+ sources = Array.isArray(result?.sources) ? result.sources : []
349
352
  } catch (error) {
350
- checked.push(`${source.url} (${error?.message || error})`)
353
+ searchError = error
354
+ continue
355
+ }
356
+ let fetched = 0
357
+ for (const source of sources) {
358
+ if (signal?.aborted || fetched >= fetchLimit) break
359
+ const url = officialUrl(source?.url)
360
+ if (!url || visited.has(url)) continue
361
+ visited.add(url)
362
+ fetched++
363
+ try {
364
+ const page = await abortable(() => web.fetch({ url }, signal), signal)
365
+ const finalUrl = officialUrl(page?.url || url)
366
+ const found = finalUrl && readExamDates(page?.body, year)
367
+ checked.push(url)
368
+ if (!found) continue
369
+ return {
370
+ ok: true,
371
+ record: {
372
+ date: found.date,
373
+ endDate: found.endDate,
374
+ status: 'official',
375
+ source: found.title || source.title || finalUrl,
376
+ sourceUrl: finalUrl,
377
+ publishedAt: source.publishedAt || '',
378
+ verifiedAt: new Date().toISOString(),
379
+ method: 'online-search',
380
+ note: `由官方渠道页面核验:${finalUrl}`,
381
+ },
382
+ checked,
383
+ snippet: found.snippet,
384
+ }
385
+ } catch (error) {
386
+ checked.push(`${url} (${error?.message || error})`)
387
+ }
351
388
  }
352
389
  }
353
-
354
- return {
355
- ok: false,
356
- reason: `检索到官方渠道页面,但未能在其中确认 ${expectYear}年12月的初试日期`,
357
- checked,
358
- }
390
+ if (signal?.aborted) return { ok: false, reason: '联网核验已超时或取消', checked }
391
+ if (!searched) return { ok: false, reason: `联网检索失败:${searchError?.message || searchError}`, checked }
392
+ return { ok: false, reason: `未能从官方正文确认 ${year} 考研(${expectYear}年12月)的初试起止日期`, checked }
359
393
  }
360
394
 
361
395
  export function addDays(iso, days) {
package/lib/generate.js CHANGED
@@ -1,4 +1,4 @@
1
- import { describeSubjectForPrompt, QUESTION_TYPE_LABEL, CHOICE_TYPES } from './subjects.js'
1
+ import { describeSubjectForPrompt, QUESTION_TYPE_LABEL, CHOICE_TYPES, hasChoiceOptions, validChoiceKey } from './subjects.js'
2
2
  import { asString, asStringArray, generateText, parseJsonLoose, resolveModel } from './llm-json.js'
3
3
  import { loadExamReferences, reviewExamFit } from './references.js'
4
4
  import { generationPlan,findQuestionRepeat } from './generation-history.js'
@@ -34,7 +34,7 @@ const WRITER_SYSTEM = `你是一位中国大陆硕士研究生招生考试的命
34
34
 
35
35
  const SOLVER_SYSTEM = `你是一位独立解题专家。请求中只有题干和选项,不提供命题答案或解析。
36
36
  请独立求解,检查题干条件、可解性和唯一性;不要猜测命题人的答案。
37
- 选择题在 answerKey 中写正确选项字母,多选去重并升序;其他题型留空。
37
+ 单选、完形和阅读在 answerKey 中只写一个正确选项字母;多选去重并升序;非选择题留空。
38
38
  只输出 JSON:{"solvable":true,"answer":"最终答案","answerKey":"","explanation":"完整推导、依据及易错点,不少于20字","issues":[]}。
39
39
  无法确定时 solvable 为 false,并在 issues 中说明原因。`
40
40
 
@@ -170,7 +170,7 @@ async function writeQuestion({ ctx, subject, examInfo, allowed, allowedLabels, t
170
170
  ' "stem": "题干(Markdown + LaTeX)",',
171
171
  ' "options": [{"key": "A", "text": "选项内容(Markdown + LaTeX)"}], // 选择题必填,其他题型为空数组',
172
172
  ' "answer": "标准答案(Markdown)。选择题为正确选项字母,例如 B;填空题为最终结果;解答题给出最终结论",',
173
- ' "answerKey": "单选题的正确选项字母;多选题为按字母升序拼接的字符串,例如 \\"ABD\\";其他题型为空字符串",',
173
+ ' "answerKey": "单选、完形、阅读只填一个正确选项字母;多选按字母升序拼接,例如 ABD;非选择题留空",',
174
174
  ' "explanation": "完整解析(Markdown + LaTeX),必须含关键思路、推导步骤、核心知识点与易错点",',
175
175
  ' "knowledge": ["知识点标签", "..."],',
176
176
  ' "syllabusCheck": "一句话说明本题对应大纲的哪一部分"',
@@ -337,17 +337,14 @@ export function structuralProblems(candidate, allowedTypes) {
337
337
  if (!candidate.explanation || candidate.explanation.length < 20) problems.push('解析缺失或过短(缺少必要推导)')
338
338
  if (!allowedTypes.includes(candidate.type)) problems.push(`题型 ${candidate.type} 不在本科目允许范围内`)
339
339
  if (CHOICE_TYPES.has(candidate.type)) {
340
- if (candidate.options.length < 2) problems.push('选择题至少需要 2 个选项')
341
- const keys = candidate.options.map((option) => option.key)
342
- if (new Set(keys).size !== keys.length) problems.push('选项字母重复')
343
- if (candidate.type === 'single') {
344
- if (candidate.answerKey.length !== 1) problems.push('单项选择题必须有且仅有一个正确选项')
345
- else if (!keys.includes(candidate.answerKey)) problems.push('正确选项不在选项列表中')
346
- } else if (!candidate.answerKey) {
347
- problems.push('多项选择题必须写明正确选项集合')
340
+ if (!hasChoiceOptions(candidate.options)) problems.push('选择题至少需要两个有效选项,选项字母须为 A–H 且不可重复')
341
+ if (!validChoiceKey(candidate.type, candidate.answerKey, candidate.options)) {
342
+ problems.push(candidate.type === 'multiple'
343
+ ? '多项选择题必须写明正确选项集合,字母不可重复且必须属于本题选项'
344
+ : '单选、完形和阅读必须有且仅有一个有效的正确选项')
345
+ } else if ([...candidate.answerKey].sort().join('') !== candidate.answerKey) {
346
+ problems.push('多选题正确选项必须按字母升序排列')
348
347
  }
349
- const sorted = candidate.answerKey.split('').sort()
350
- if (sorted.join('') !== candidate.answerKey) problems.push('多选题正确选项必须按字母升序排列')
351
348
  }
352
349
  return problems
353
350
  }
@@ -401,7 +398,7 @@ export async function reviseAnswer({ ctx, subject, examInfo, question, currentAn
401
398
  '输出字段(严格 JSON):',
402
399
  '{',
403
400
  ' "answer": "标准答案(Markdown)",',
404
- ' "answerKey": "选择题的正确选项字母(多选按字母升序),非选择题为空字符串",',
401
+ ' "answerKey": "单选、完形、阅读只填一个正确选项字母;多选按字母升序;非选择题留空",',
405
402
  ' "explanation": "完整解析(Markdown + LaTeX)"',
406
403
  '}',
407
404
  ].filter(Boolean).join('\n')
package/lib/routes.js CHANGED
@@ -12,51 +12,36 @@ const MAX_BODY_BYTES = 60 * 1024 * 1024
12
12
  const MATHJAX_FILE = fileURLToPath(new URL('../vendor/mathjax/tex-svg.js', import.meta.url))
13
13
  let mathjaxBytes = null
14
14
 
15
- /** 最小 CSRF 防护:带 Origin 的请求必须与 Host 同源。 */
16
- function trusted(req) {
17
- const origin = req.headers.origin
18
- if (origin === undefined) return true
19
- try {
20
- return new URL(origin).host === req.headers.host
21
- } catch {
22
- return false
23
- }
24
- }
25
-
26
- function sendJson(res, status, body) {
27
- const payload = JSON.stringify(body)
28
- res.writeHead(status, {
29
- 'content-type': 'application/json; charset=utf-8',
30
- 'cache-control': 'no-store',
31
- })
32
- res.end(payload)
15
+ function json(status, body) {
16
+ return Response.json(body, { status, headers: { 'cache-control': 'no-store' } })
33
17
  }
34
18
 
35
- function readBody(req) {
36
- return new Promise((resolve, reject) => {
37
- const chunks = []
38
- let size = 0
39
- req.on('data', (chunk) => {
40
- size += chunk.length
41
- if (size > MAX_BODY_BYTES) {
42
- reject(new Error('请求体过大'))
43
- req.destroy()
44
- return
45
- }
46
- chunks.push(chunk)
47
- })
48
- req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
49
- req.on('error', reject)
50
- })
19
+ async function readBody(request) {
20
+ if (Number(request.headers.get('content-length')) > MAX_BODY_BYTES) return null
21
+ if (!request.body) return ''
22
+ const reader = request.body.getReader()
23
+ const chunks = []
24
+ let size = 0
25
+ try {
26
+ while (true) {
27
+ const { done, value } = await reader.read()
28
+ if (done) return Buffer.concat(chunks, size).toString('utf8')
29
+ size += value.byteLength
30
+ if (size > MAX_BODY_BYTES) return null
31
+ chunks.push(value)
32
+ }
33
+ } finally {
34
+ reader.releaseLock()
35
+ }
51
36
  }
52
37
 
53
38
  /**
54
39
  * 注册路由。
55
- * @param {{register: Function}} webServer ctx.webServer
40
+ * @param {{fetch: {register: Function}}} connection ctx.connection
56
41
  * @param {import('./service.js').DailyQuestionService} service
57
- * @returns {() => void} 注销函数
42
+ * @returns {() => Promise<void>} 注销函数
58
43
  */
59
- export function registerRoutes(webServer, service, logger = console) {
44
+ export function registerRoutes(connection, service, logger = console) {
60
45
  const ops = {
61
46
  bootstrap: () => service.bootstrap(),
62
47
  checkUpdates: (args) => service.checkUpdates(args),
@@ -87,39 +72,31 @@ export function registerRoutes(webServer, service, logger = console) {
87
72
  },
88
73
  }
89
74
 
90
- const disposeRpc = webServer.register({
91
- kind: 'exact',
75
+ const disposeRpc = connection.fetch.register({
92
76
  path: RPC_PATH,
93
- handler: async (req, res) => {
94
- if (!trusted(req)) {
95
- sendJson(res, 403, { ok: false, error: { message: 'forbidden', code: 'FORBIDDEN' } })
96
- return
97
- }
98
- if (req.method !== 'POST') {
99
- sendJson(res, 405, { ok: false, error: { message: '只接受 POST', code: 'METHOD' } })
100
- return
101
- }
77
+ methods: ['POST'],
78
+ requestBody: 'streaming',
79
+ fetch: async (request) => {
102
80
  let payload
103
81
  try {
104
- payload = JSON.parse(await readBody(req))
82
+ const body = await readBody(request)
83
+ if (body === null) return json(413, { ok: false, error: { message: '请求体过大', code: 'BODY_TOO_LARGE' } })
84
+ payload = JSON.parse(body)
105
85
  } catch (error) {
106
- sendJson(res, 400, { ok: false, error: { message: `请求体解析失败:${error.message}`, code: 'BAD_BODY' } })
107
- return
86
+ return json(400, { ok: false, error: { message: `请求体解析失败:${error.message}`, code: 'BAD_BODY' } })
108
87
  }
109
88
  const op = typeof payload?.op === 'string' ? payload.op : ''
110
- const handler = ops[op]
111
- if (!handler) {
112
- sendJson(res, 400, { ok: false, error: { message: `未知操作:${op}`, code: 'UNKNOWN_OP' } })
113
- return
89
+ if (!Object.hasOwn(ops, op)) {
90
+ return json(400, { ok: false, error: { message: `未知操作:${op}`, code: 'UNKNOWN_OP' } })
114
91
  }
115
92
  const started = Date.now()
116
93
  try {
117
- const result = await handler(payload.args || {})
118
- sendJson(res, 200, { ok: true, result })
94
+ const result = await ops[op](payload.args || {})
95
+ return json(200, { ok: true, result })
119
96
  } catch (error) {
120
97
  const message = error?.message || String(error)
121
98
  logger.warn?.(`daily-question: ${op} 失败(${Date.now() - started}ms):${message}`)
122
- sendJson(res, 500, {
99
+ return json(error?.code === 'ATTEMPT_CONFLICT' ? 409 : 500, {
123
100
  ok: false,
124
101
  error: {
125
102
  message,
@@ -131,49 +108,35 @@ export function registerRoutes(webServer, service, logger = console) {
131
108
  },
132
109
  })
133
110
 
134
- const disposePing = webServer.register({
135
- kind: 'exact',
111
+ const disposePing = connection.fetch.register({
136
112
  path: PING_PATH,
137
- handler: (req, res) => {
138
- if (!trusted(req)) {
139
- sendJson(res, 403, { ok: false })
140
- return
141
- }
142
- sendJson(res, 200, { ok: true, plugin: 'daily-question', version: 1 })
143
- },
113
+ methods: ['GET'],
114
+ requestBody: 'buffered',
115
+ fetch: async () => json(200, { ok: true, plugin: 'daily-question', version: 1 }),
144
116
  })
145
117
 
146
118
  // 公式引擎:MathJax 3 的单文件 bundle(SVG 输出,自带字形路径,不需要字体文件)。
147
- const disposeVendor = webServer.register({
148
- kind: 'exact',
119
+ const disposeVendor = connection.fetch.register({
149
120
  path: MATHJAX_PATH,
150
- handler: async (req, res) => {
151
- if (req.method !== 'GET' && req.method !== 'HEAD') {
152
- res.writeHead(405, { allow: 'GET, HEAD' })
153
- res.end()
154
- return
155
- }
121
+ methods: ['GET', 'HEAD'],
122
+ requestBody: 'buffered',
123
+ fetch: async (request) => {
156
124
  try {
157
125
  if (!mathjaxBytes) mathjaxBytes = await readFile(MATHJAX_FILE)
158
- res.writeHead(200, {
126
+ return new Response(request.method === 'HEAD' ? null : mathjaxBytes, { headers: {
159
127
  'content-type': 'text/javascript; charset=utf-8',
160
128
  'content-length': String(mathjaxBytes.length),
161
- 'cache-control': 'public, max-age=31536000, immutable',
162
- })
163
- if (req.method === 'HEAD') res.end()
164
- else res.end(mathjaxBytes)
129
+ 'cache-control': 'private, max-age=31536000, immutable',
130
+ } })
165
131
  } catch (error) {
166
132
  logger.warn?.(`daily-question: 公式引擎不可用(${error.message})`)
167
- res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' })
168
- res.end('MathJax not bundled')
133
+ return new Response(request.method === 'HEAD' ? null : 'MathJax not bundled', { status: 404 })
169
134
  }
170
135
  },
171
136
  })
172
137
 
173
138
  logger.info?.(`daily-question: 接口已就绪 ${RPC_PATH}`)
174
- return () => {
175
- disposeRpc()
176
- disposePing()
177
- disposeVendor()
139
+ return async () => {
140
+ await Promise.all([disposeRpc(), disposePing(), disposeVendor()])
178
141
  }
179
142
  }