@jurislm/judicial-mcp 1.3.0 → 1.6.3
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/README.md +68 -9
- package/bin/judicial-mcp.ts +2 -0
- package/package.json +30 -17
- package/src/index.ts +20 -0
- package/src/public-judgment-source.ts +487 -0
- package/src/regulation-cache.ts +38 -0
- package/src/regulation-source.ts +536 -0
- package/src/response.ts +74 -0
- package/src/server.ts +70 -0
- package/src/source-error.ts +50 -0
- package/src/tools.ts +409 -0
- package/types/index.d.ts +1 -113
- package/types/public-judgment-source.d.ts +2 -0
- package/types/regulation-cache.d.ts +8 -0
- package/types/regulation-source.d.ts +29 -0
- package/types/response.d.ts +20 -0
- package/types/server.d.ts +3 -0
- package/types/source-error.d.ts +14 -0
- package/types/tools.d.ts +236 -0
- package/bin/judicial-mcp.js +0 -18
- package/src/index.js +0 -76
- package/src/response.js +0 -44
- package/src/tools.js +0 -360
|
@@ -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
|
+
}
|