@jurislm/judicial-mcp 1.3.0 → 1.6.2

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.
@@ -0,0 +1,487 @@
1
+ import axios, { type AxiosResponse } from 'axios'
2
+ import { load } from 'cheerio'
3
+ import { JudicialSourceError } from './source-error.js'
4
+
5
+ const BASE_URL = 'https://judgment.judicial.gov.tw/FJUD/'
6
+ const SEARCH_URL = new URL('Default_AD.aspx', BASE_URL)
7
+ const DETAIL_URL = new URL('data.aspx', BASE_URL)
8
+ const PAGE_SIZE = 10
9
+ const SOURCE_PAGE_SIZE = 20
10
+ const MAX_PAGE = 50
11
+ const MAX_TEXT_LENGTH = 40_000
12
+ const MAX_SUMMARY_LENGTH = 500
13
+ const REQUEST_LIMIT = 2_000_000
14
+ const NO_RESULTS = /查無.{0,12}(?:資料|裁判書)|沒有符合條件的資料/
15
+
16
+ interface SearchCriteria {
17
+ keyword?: string
18
+ court?: string
19
+ year?: number
20
+ caseNumber?: string
21
+ limit: number
22
+ page: number
23
+ offset: number
24
+ }
25
+
26
+ interface CaseNumberParts {
27
+ year?: number
28
+ caseWord?: string
29
+ number: string
30
+ }
31
+
32
+ interface JudgmentSearchResult {
33
+ case_id: string
34
+ case_number: string
35
+ date: string | null
36
+ court: string
37
+ cause: string
38
+ summary?: string
39
+ source_url: string
40
+ search_source_url: string
41
+ retrieved_at: string
42
+ cache: { status: 'not_cached' }
43
+ truncated: boolean
44
+ partial: false
45
+ source_version: null
46
+ }
47
+
48
+ function optionalString(args: Record<string, unknown>, name: string, maxLength: number): string | undefined {
49
+ const value = args[name]
50
+ if (value === undefined) return undefined
51
+ if (typeof value !== 'string' || value.trim().length === 0 || value.length > maxLength) {
52
+ throw new Error(`參數 ${name} 必須是 1 至 ${maxLength} 個字元的文字`)
53
+ }
54
+ return value.trim()
55
+ }
56
+
57
+ function optionalInteger(
58
+ args: Record<string, unknown>,
59
+ name: string,
60
+ minimum: number,
61
+ maximum: number,
62
+ fallback: number,
63
+ ): number {
64
+ const value = args[name]
65
+ if (value === undefined) return fallback
66
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < minimum || value > maximum) {
67
+ throw new Error(`參數 ${name} 必須是 ${minimum} 至 ${maximum} 的整數`)
68
+ }
69
+ return value
70
+ }
71
+
72
+ function parseCriteria(args: Record<string, unknown>): SearchCriteria {
73
+ const keyword = optionalString(args, 'keyword', 128)
74
+ const court = optionalString(args, 'court', 80)
75
+ const year = optionalInteger(args, 'year', 1, 9999, 0)
76
+ const caseNumber = optionalString(args, 'caseNumber', 80)
77
+ const limit = optionalInteger(args, 'limit', 1, PAGE_SIZE, 5)
78
+ const page = optionalInteger(args, 'page', 1, MAX_PAGE, 1)
79
+ const offset = optionalInteger(args, 'offset', 0, SOURCE_PAGE_SIZE - 1, 0)
80
+ if (!keyword && !court && !caseNumber && year === 0) {
81
+ throw new Error('至少需要提供 keyword、court、year 或 caseNumber 其中一項')
82
+ }
83
+ return {
84
+ ...(keyword ? { keyword } : {}),
85
+ ...(court ? { court } : {}),
86
+ ...(year > 0 ? { year } : {}),
87
+ ...(caseNumber ? { caseNumber } : {}),
88
+ limit,
89
+ page,
90
+ offset,
91
+ }
92
+ }
93
+
94
+ function parseCaseNumber(value: string): CaseNumberParts {
95
+ const withYear = value.match(/^(\d{1,4})年度(.+?)字第([A-Za-z0-9-]+)號$/)
96
+ if (withYear) {
97
+ const year = Number(withYear[1])
98
+ if (year < 1 || year > 9999) throw new Error('案號年度不在有效範圍')
99
+ return { year, caseWord: withYear[2], number: withYear[3] }
100
+ }
101
+ const withCaseWord = value.match(/^(.+?)字第([A-Za-z0-9-]+)號$/)
102
+ if (withCaseWord) return { caseWord: withCaseWord[1], number: withCaseWord[2] }
103
+ if (/^[A-Za-z0-9-]+$/.test(value)) return { number: value }
104
+ throw new Error('caseNumber 請使用案號數字或「年度案類字第號」格式')
105
+ }
106
+
107
+ function toTaiwanYear(year: number): number {
108
+ return year > 1911 ? year - 1911 : year
109
+ }
110
+
111
+ function normalizeCourt(value: string): string {
112
+ return value.replaceAll('台', '臺').replaceAll(/\s+/g, '').toLocaleLowerCase()
113
+ }
114
+
115
+ function readCourtCode(html: string, court: string): string {
116
+ const $ = load(html)
117
+ let code: string | undefined
118
+ const target = normalizeCourt(court)
119
+ $('select[name="jud_court"] option').each((_, option) => {
120
+ const candidate = $(option)
121
+ const value = candidate.attr('value')?.trim()
122
+ const label = candidate.text().trim()
123
+ if (value && (value === court || normalizeCourt(label) === target)) code = value
124
+ })
125
+ if (!code) throw new Error(`官方法院清單中找不到「${court}」,請使用司法院顯示的法院名稱`)
126
+ return code
127
+ }
128
+
129
+ function readCookieHeader(cookies: string[] | undefined): string | undefined {
130
+ if (!cookies) return undefined
131
+ const values = cookies
132
+ .map(cookie => cookie.split(';', 1)[0]?.trim())
133
+ .filter((cookie): cookie is string => Boolean(cookie && cookie.includes('=')))
134
+ return values.length > 0 ? values.join('; ') : undefined
135
+ }
136
+
137
+ function formFields(html: string, criteria: SearchCriteria): URLSearchParams {
138
+ const $ = load(html)
139
+ const form = $('form').first()
140
+ const state = new URLSearchParams()
141
+ if (!form.length || !form.find('input[name="__VIEWSTATE"]').length || !form.find('input[name="__EVENTVALIDATION"]').length) {
142
+ throw new JudicialSourceError('source_changed', SEARCH_URL.href)
143
+ }
144
+ form.find('input[type="hidden"][name]').each((_, input) => {
145
+ const name = $(input).attr('name')
146
+ const value = $(input).attr('value') ?? ''
147
+ if (name) state.set(name, value)
148
+ })
149
+ const submit = form.find('input[type="submit"][name], button[type="submit"][name]').first()
150
+ const submitName = submit.attr('name')
151
+ const submitValue = submit.attr('value') ?? submit.text().trim()
152
+ if (!submitName) throw new JudicialSourceError('source_changed', SEARCH_URL.href)
153
+ state.set('judtype', 'JUDBOOK')
154
+ state.set('whosub', '0')
155
+ state.set(submitName, submitValue)
156
+ if (criteria.keyword) state.set('jud_kw', criteria.keyword)
157
+ if (criteria.court) state.set('jud_court', readCourtCode(html, criteria.court))
158
+ if (criteria.year) state.set('jud_year', String(toTaiwanYear(criteria.year)))
159
+ if (criteria.caseNumber) {
160
+ const parsed = parseCaseNumber(criteria.caseNumber)
161
+ if (parsed.year && criteria.year && toTaiwanYear(criteria.year) !== toTaiwanYear(parsed.year)) {
162
+ throw new Error('year 與 caseNumber 的年度不一致')
163
+ }
164
+ const queryYear = criteria.year ?? parsed.year
165
+ if (queryYear) state.set('jud_year', String(toTaiwanYear(queryYear)))
166
+ if (parsed.caseWord) state.set('jud_case', parsed.caseWord)
167
+ state.set('jud_no', parsed.number)
168
+ }
169
+ return state
170
+ }
171
+
172
+ function allowedOfficialUrl(value: string, base: URL): URL {
173
+ const url = new URL(value, base)
174
+ if (
175
+ url.origin !== base.origin ||
176
+ url.username.length > 0 ||
177
+ url.password.length > 0 ||
178
+ url.hash.length > 0 ||
179
+ !url.pathname.startsWith('/FJUD/')
180
+ ) {
181
+ throw new JudicialSourceError('source_changed', base.href)
182
+ }
183
+ return url
184
+ }
185
+
186
+ function requestOptions(cookie?: string) {
187
+ return {
188
+ timeout: 15_000,
189
+ maxContentLength: REQUEST_LIMIT,
190
+ maxBodyLength: REQUEST_LIMIT,
191
+ maxRedirects: 0,
192
+ validateStatus: (status: number) => status >= 200 && status < 300,
193
+ responseType: 'text' as const,
194
+ headers: {
195
+ 'User-Agent': 'judicial-mcp/1.5 (https://github.com/jurislm/judicial-mcp)',
196
+ ...(cookie ? { Cookie: cookie } : {}),
197
+ },
198
+ }
199
+ }
200
+
201
+ function upstreamFailure(error: unknown, url: URL): never {
202
+ let status: number | undefined
203
+ if (error !== null && typeof error === 'object' && 'response' in error) {
204
+ const response = error.response
205
+ if (response !== null && typeof response === 'object' && 'status' in response && typeof response.status === 'number') {
206
+ status = response.status
207
+ }
208
+ }
209
+ const code = status === 404 && url.pathname.endsWith('/data.aspx') ? 'source_not_found' : 'source_unavailable'
210
+ throw new JudicialSourceError(code, url.href)
211
+ }
212
+
213
+ function sourceText(value: unknown, url: URL): string {
214
+ if (typeof value !== 'string') throw new JudicialSourceError('source_changed', url.href)
215
+ return value
216
+ }
217
+
218
+ function visibleBodyText(html: string): string {
219
+ const $ = load(html)
220
+ $('script, style, noscript').remove()
221
+ return $('body').text()
222
+ }
223
+
224
+ function resultDate(text: string, caseId: string): string | null {
225
+ const dateMatch = text.match(/(\d{2,4})[./年-](\d{1,2})[./月-](\d{1,2})/)
226
+ const idMatch = caseId.split(',')[4]?.match(/^(\d{4})(\d{2})(\d{2})$/)
227
+ const match = dateMatch ?? idMatch
228
+ if (!match) return null
229
+ const rawYear = Number(match[1])
230
+ const year = rawYear < 1912 ? rawYear + 1911 : rawYear
231
+ const month = Number(match[2])
232
+ const day = Number(match[3])
233
+ const parsed = new Date(Date.UTC(year, month - 1, day))
234
+ if (
235
+ month < 1 ||
236
+ month > 12 ||
237
+ day < 1 ||
238
+ parsed.getUTCFullYear() !== year ||
239
+ parsed.getUTCMonth() !== month - 1 ||
240
+ parsed.getUTCDate() !== day
241
+ ) return null
242
+ return `${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
243
+ }
244
+
245
+ function caseIdFromLink(href: string, base: URL): string | undefined {
246
+ const url = allowedOfficialUrl(href, base)
247
+ if (url.pathname !== '/FJUD/data.aspx' || url.searchParams.get('ty') !== 'JD') return undefined
248
+ const caseId = url.searchParams.get('id')
249
+ if (!caseId || !/^[A-Za-z0-9]{2,8},\d{1,4},[^,<>/?#]+,[A-Za-z0-9-]+,\d{8}(?:,\d+)?$/.test(caseId)) return undefined
250
+ return caseId
251
+ }
252
+
253
+ function parseResults(html: string, sourceUrl: URL, retrievedAt: string): JudgmentSearchResult[] {
254
+ const $ = load(html)
255
+ const noResults = NO_RESULTS.test(visibleBodyText(html))
256
+ const table = $('table#jud, table.jub-table').first()
257
+ if (!table.length) {
258
+ if (noResults) return []
259
+ throw new JudicialSourceError('source_changed', sourceUrl.href)
260
+ }
261
+ const results: JudgmentSearchResult[] = []
262
+ table.find('tr').each((_, row) => {
263
+ const currentRow = $(row)
264
+ if (currentRow.hasClass('summary')) return
265
+ const link = currentRow.find('a[href*="data.aspx"]').first()
266
+ const href = link.attr('href')
267
+ if (!href) return
268
+ const caseId = caseIdFromLink(href, sourceUrl)
269
+ if (!caseId) throw new JudicialSourceError('source_changed', sourceUrl.href)
270
+ const cells = currentRow.find('td')
271
+ if (cells.length < 4) throw new JudicialSourceError('source_changed', sourceUrl.href)
272
+ const summaryRow = currentRow.next('tr.summary')
273
+ const rawSummary = summaryRow.find('span.tdCut').text().trim()
274
+ const summary = rawSummary.slice(0, MAX_SUMMARY_LENGTH)
275
+ const date = resultDate(cells.eq(2).text(), caseId)
276
+ const candidate: JudgmentSearchResult = {
277
+ case_id: caseId,
278
+ case_number: link.text().trim(),
279
+ date,
280
+ court: caseId.split(',')[0] ?? '',
281
+ cause: cells.eq(3).text().trim(),
282
+ source_url: judgmentUrl(caseId).href,
283
+ search_source_url: sourceUrl.href,
284
+ retrieved_at: retrievedAt,
285
+ cache: { status: 'not_cached' },
286
+ truncated: rawSummary.length > MAX_SUMMARY_LENGTH,
287
+ partial: false,
288
+ source_version: null,
289
+ }
290
+ if (summary) candidate.summary = summary
291
+ results.push(candidate)
292
+ })
293
+ if (results.length === 0 && !noResults) throw new JudicialSourceError('source_changed', sourceUrl.href)
294
+ return results
295
+ }
296
+
297
+ function hasNextPage(html: string, page: number): boolean {
298
+ const $ = load(html)
299
+ let found = false
300
+ $('a[href]').each((_, anchor) => {
301
+ const link = $(anchor)
302
+ const label = link.text().trim()
303
+ const href = link.attr('href') ?? ''
304
+ if (/^(?:下一頁|下頁|next)$/i.test(label) || new URLSearchParams(href.split('?')[1] ?? '').get('page') === String(page + 1)) {
305
+ found = true
306
+ }
307
+ })
308
+ return found
309
+ }
310
+
311
+ function makeSearchResultUrl(iframeSource: string, page: number): URL {
312
+ const url = allowedOfficialUrl(iframeSource, SEARCH_URL)
313
+ if (url.pathname !== '/FJUD/qryresultlst.aspx') throw new JudicialSourceError('source_changed', SEARCH_URL.href)
314
+ if (page > 1) url.searchParams.set('page', String(page))
315
+ return url
316
+ }
317
+
318
+ function makeSearchErrorPageUrl(iframeSource: string): URL | undefined {
319
+ const url = new URL(iframeSource, SEARCH_URL)
320
+ if (
321
+ url.origin !== SEARCH_URL.origin ||
322
+ url.pathname !== '/ErrorPage.aspx' ||
323
+ url.username.length > 0 ||
324
+ url.password.length > 0 ||
325
+ url.hash ||
326
+ url.href.length > 2_048
327
+ ) return undefined
328
+ const parameters = [...url.searchParams.entries()]
329
+ if (
330
+ parameters.length !== 2 ||
331
+ url.searchParams.getAll('frm').length !== 1 ||
332
+ url.searchParams.getAll('err').length !== 1 ||
333
+ !['FJUD', 'iframe'].includes(url.searchParams.get('frm') ?? '') ||
334
+ !/^[A-Za-z0-9_-]{1,64}$/.test(url.searchParams.get('err') ?? '')
335
+ ) return undefined
336
+ return url
337
+ }
338
+
339
+ function emptySearchResult(sourceUrl: URL, retrievedAt: string) {
340
+ return {
341
+ count: 0,
342
+ ambiguous: false,
343
+ results: [],
344
+ source_url: sourceUrl.href,
345
+ retrieved_at: retrievedAt,
346
+ cache: { status: 'not_cached' as const },
347
+ truncated: false,
348
+ partial: false,
349
+ next_page: null,
350
+ next_offset: null,
351
+ source_version: null,
352
+ }
353
+ }
354
+
355
+ function validCaseId(value: string): boolean {
356
+ return /^[A-Za-z0-9]{2,8},\d{1,4},[^,<>/?#]+,[A-Za-z0-9-]+,\d{8}(?:,\d+)?$/.test(value)
357
+ }
358
+
359
+ function judgmentUrl(caseId: string): URL {
360
+ const url = new URL(DETAIL_URL.href)
361
+ url.searchParams.set('ty', 'JD')
362
+ url.searchParams.set('id', caseId)
363
+ return url
364
+ }
365
+
366
+ export async function searchJudgments(args: Record<string, unknown>): Promise<unknown> {
367
+ const criteria = parseCriteria(args)
368
+ const retrievedAt = new Date().toISOString()
369
+ let formResponse: AxiosResponse<string>
370
+ try {
371
+ formResponse = await axios.get<string>(SEARCH_URL.href, requestOptions())
372
+ } catch (error: unknown) {
373
+ upstreamFailure(error, SEARCH_URL)
374
+ }
375
+ const formHtml = sourceText(formResponse.data, SEARCH_URL)
376
+ const cookie = readCookieHeader(formResponse.headers['set-cookie'])
377
+ const fields = formFields(formHtml, criteria)
378
+ let queryResponse: AxiosResponse<string>
379
+ try {
380
+ queryResponse = await axios.post<string>(SEARCH_URL.href, fields, {
381
+ ...requestOptions(cookie),
382
+ headers: {
383
+ ...requestOptions(cookie).headers,
384
+ 'Content-Type': 'application/x-www-form-urlencoded',
385
+ Referer: SEARCH_URL.href,
386
+ },
387
+ })
388
+ } catch (error: unknown) {
389
+ upstreamFailure(error, SEARCH_URL)
390
+ }
391
+ const queryHtml = sourceText(queryResponse.data, SEARCH_URL)
392
+ const $ = load(queryHtml)
393
+ const iframeSource = $('iframe').first().attr('src')
394
+ if (!iframeSource) {
395
+ if (NO_RESULTS.test(visibleBodyText(queryHtml))) {
396
+ return emptySearchResult(SEARCH_URL, retrievedAt)
397
+ }
398
+ throw new JudicialSourceError('source_changed', SEARCH_URL.href)
399
+ }
400
+ const errorPageUrl = makeSearchErrorPageUrl(iframeSource)
401
+ if (errorPageUrl) {
402
+ let errorPageResponse: AxiosResponse<string>
403
+ try {
404
+ errorPageResponse = await axios.get<string>(errorPageUrl.href, requestOptions())
405
+ } catch (error: unknown) {
406
+ upstreamFailure(error, errorPageUrl)
407
+ }
408
+ const errorPageHtml = sourceText(errorPageResponse.data, errorPageUrl)
409
+ if (!NO_RESULTS.test(visibleBodyText(errorPageHtml))) {
410
+ throw new JudicialSourceError('source_changed', errorPageUrl.href)
411
+ }
412
+ return emptySearchResult(errorPageUrl, retrievedAt)
413
+ }
414
+ const resultUrl = makeSearchResultUrl(iframeSource, criteria.page)
415
+ let resultResponse: AxiosResponse<string>
416
+ try {
417
+ resultResponse = await axios.get<string>(resultUrl.href, requestOptions(cookie))
418
+ } catch (error: unknown) {
419
+ upstreamFailure(error, resultUrl)
420
+ }
421
+ const resultsHtml = sourceText(resultResponse.data, resultUrl)
422
+ const allResults = parseResults(resultsHtml, resultUrl, retrievedAt)
423
+ const sourceHasNextPage = hasNextPage(resultsHtml, criteria.page)
424
+ const pageHasNext = sourceHasNextPage && criteria.page < MAX_PAGE
425
+ const pageResults = allResults.slice(criteria.offset, criteria.offset + criteria.limit)
426
+ const hasMoreOnPage = allResults.length > criteria.offset + criteria.limit
427
+ const truncated = hasMoreOnPage || sourceHasNextPage || pageResults.some(result => result.truncated)
428
+ const ambiguous = Boolean(
429
+ criteria.caseNumber && (allResults.length > 1 || criteria.page > 1 || criteria.offset > 0 || sourceHasNextPage),
430
+ )
431
+ const hasMoreResults = hasMoreOnPage || sourceHasNextPage
432
+ const hasNextPageResult = hasMoreOnPage || pageHasNext
433
+ return {
434
+ count: pageResults.length,
435
+ ambiguous,
436
+ results: pageResults,
437
+ source_url: resultUrl.href,
438
+ retrieved_at: retrievedAt,
439
+ cache: { status: 'not_cached' },
440
+ truncated,
441
+ partial: hasMoreResults && !hasNextPageResult,
442
+ next_page: hasMoreOnPage ? criteria.page : pageHasNext ? criteria.page + 1 : null,
443
+ next_offset: hasMoreOnPage ? criteria.offset + criteria.limit : pageHasNext ? 0 : null,
444
+ source_version: null,
445
+ }
446
+ }
447
+
448
+ export async function fetchJudgmentText(args: Record<string, unknown>): Promise<unknown> {
449
+ const caseId = optionalString(args, 'caseId', 180)
450
+ if (!caseId || !validCaseId(caseId)) throw new Error('caseId 必須是司法院裁判書來源提供的裁判 ID')
451
+ const url = judgmentUrl(caseId)
452
+ let response: AxiosResponse<string>
453
+ try {
454
+ response = await axios.get<string>(url.href, requestOptions())
455
+ } catch (error: unknown) {
456
+ upstreamFailure(error, url)
457
+ }
458
+ const html = sourceText(response.data, url)
459
+ const $ = load(html)
460
+ const noResults = NO_RESULTS.test(visibleBodyText(html))
461
+ const judgment = $('#jud').first()
462
+ if (!judgment.length) {
463
+ throw new JudicialSourceError(noResults ? 'source_not_found' : 'source_changed', url.href)
464
+ }
465
+ judgment.find('br').replaceWith('\n')
466
+ judgment.find('div, p, tr').each((_, element) => {
467
+ $(element).append('\n')
468
+ })
469
+ const fullText = judgment.text()
470
+ .replace(/\r/g, '')
471
+ .replace(/[ \t]+\n/g, '\n')
472
+ .replace(/\n{3,}/g, '\n\n')
473
+ .trim()
474
+ if (!fullText) throw new JudicialSourceError(noResults ? 'source_not_found' : 'source_changed', url.href)
475
+ const truncated = fullText.length > MAX_TEXT_LENGTH
476
+ return {
477
+ case_id: caseId,
478
+ date: resultDate('', caseId),
479
+ full_text: fullText.slice(0, MAX_TEXT_LENGTH),
480
+ source_url: url.href,
481
+ retrieved_at: new Date().toISOString(),
482
+ cache: { status: 'not_cached' },
483
+ truncated,
484
+ partial: truncated,
485
+ source_version: null,
486
+ }
487
+ }
@@ -0,0 +1,38 @@
1
+ export interface RegulationCacheEntry {
2
+ value: unknown
3
+ expiresAt: number
4
+ }
5
+
6
+ const CACHE_TTL_MS = 15 * 60 * 1000
7
+ const MAX_CACHE_ENTRIES = 100
8
+ const entries = new Map<string, RegulationCacheEntry>()
9
+
10
+ export function getRegulationCacheEntry(key: string): RegulationCacheEntry | undefined {
11
+ const entry = entries.get(key)
12
+ if (!entry) return undefined
13
+ entries.delete(key)
14
+ entries.set(key, entry)
15
+ return entry
16
+ }
17
+
18
+ export function setRegulationCacheEntry(
19
+ key: string,
20
+ entry: Omit<RegulationCacheEntry, 'expiresAt'>,
21
+ now = Date.now(),
22
+ ): void {
23
+ entries.delete(key)
24
+ entries.set(key, { ...entry, expiresAt: now + CACHE_TTL_MS })
25
+ while (entries.size > MAX_CACHE_ENTRIES) {
26
+ const oldestKey = entries.keys().next().value
27
+ if (oldestKey === undefined) return
28
+ entries.delete(oldestKey)
29
+ }
30
+ }
31
+
32
+ export function deleteRegulationCacheEntry(key: string): void {
33
+ entries.delete(key)
34
+ }
35
+
36
+ export function clearRegulationCache(): void {
37
+ entries.clear()
38
+ }