@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.
- 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,536 @@
|
|
|
1
|
+
import axios, { type AxiosResponse } from 'axios'
|
|
2
|
+
import { load } from 'cheerio'
|
|
3
|
+
import {
|
|
4
|
+
deleteRegulationCacheEntry,
|
|
5
|
+
getRegulationCacheEntry,
|
|
6
|
+
setRegulationCacheEntry,
|
|
7
|
+
type RegulationCacheEntry,
|
|
8
|
+
} from './regulation-cache.js'
|
|
9
|
+
import { RegulationSourceError } from './source-error.js'
|
|
10
|
+
|
|
11
|
+
const BASE_URL = 'https://law.moj.gov.tw'
|
|
12
|
+
const TERM_URL = new URL('/controls/GetTermList.ashx', BASE_URL)
|
|
13
|
+
const RESPONSE_LIMIT = 2_000_000
|
|
14
|
+
const FIRECRAWL_TIMEOUT_MS = 30_000
|
|
15
|
+
const FIRECRAWL_REQUEST_TIMEOUT_MS = 40_000
|
|
16
|
+
const MAX_ARTICLE_LENGTH = 30_000
|
|
17
|
+
const MAX_CANDIDATES = 10
|
|
18
|
+
const RESPONSE_TYPE: 'text' = 'text'
|
|
19
|
+
|
|
20
|
+
interface RegulationCandidate {
|
|
21
|
+
name: string
|
|
22
|
+
identifier: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface RegulationVersion {
|
|
26
|
+
amendment_date: string | null
|
|
27
|
+
promulgation_date: string | null
|
|
28
|
+
effective_date: string | null
|
|
29
|
+
effective_status: 'as_stated' | 'pending_or_mixed' | 'unknown'
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface RegulationResult {
|
|
33
|
+
status: 'current' | 'ambiguous'
|
|
34
|
+
regulation_name?: string
|
|
35
|
+
regulation_id?: string
|
|
36
|
+
article_number?: string
|
|
37
|
+
article_text?: string
|
|
38
|
+
version?: RegulationVersion
|
|
39
|
+
candidates?: Array<{ regulation_name: string; regulation_id: string }>
|
|
40
|
+
source_url: string
|
|
41
|
+
search_source_url: string
|
|
42
|
+
retrieved_at: string
|
|
43
|
+
cache: { status: 'miss' | 'hit' | 'refreshed' | 'revalidated' | 'not_cached' }
|
|
44
|
+
truncated: boolean
|
|
45
|
+
partial: boolean
|
|
46
|
+
source_version: string | null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface ParsedArticle {
|
|
50
|
+
text: string
|
|
51
|
+
version: RegulationVersion
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type CandidateResolution = { candidate: RegulationCandidate } | { ambiguous: RegulationResult }
|
|
55
|
+
|
|
56
|
+
type ValidatedRegulationCacheEntry = Omit<RegulationCacheEntry, 'value'> & { value: RegulationResult }
|
|
57
|
+
|
|
58
|
+
interface ArticleBlock {
|
|
59
|
+
find(selector: string): { first(): { text(): string; html(): string | null; length: number }; length: number }
|
|
60
|
+
text(): string
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
64
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isNullableDate(value: unknown): boolean {
|
|
68
|
+
return value === null || (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isRegulationResult(value: unknown): value is RegulationResult {
|
|
72
|
+
if (!isRecord(value) || value.status !== 'current') return false
|
|
73
|
+
if (!isRecord(value.cache) || !isRecord(value.version)) return false
|
|
74
|
+
const cacheStatus = value.cache.status
|
|
75
|
+
const effectiveStatus = value.version.effective_status
|
|
76
|
+
return typeof value.regulation_name === 'string' &&
|
|
77
|
+
typeof value.regulation_id === 'string' &&
|
|
78
|
+
typeof value.article_number === 'string' &&
|
|
79
|
+
typeof value.article_text === 'string' &&
|
|
80
|
+
isNullableDate(value.version.amendment_date) &&
|
|
81
|
+
isNullableDate(value.version.promulgation_date) &&
|
|
82
|
+
isNullableDate(value.version.effective_date) &&
|
|
83
|
+
(effectiveStatus === 'as_stated' || effectiveStatus === 'pending_or_mixed' || effectiveStatus === 'unknown') &&
|
|
84
|
+
typeof value.source_url === 'string' &&
|
|
85
|
+
typeof value.search_source_url === 'string' &&
|
|
86
|
+
typeof value.retrieved_at === 'string' &&
|
|
87
|
+
(cacheStatus === 'miss' || cacheStatus === 'hit' || cacheStatus === 'refreshed' || cacheStatus === 'revalidated') &&
|
|
88
|
+
typeof value.truncated === 'boolean' &&
|
|
89
|
+
typeof value.partial === 'boolean' &&
|
|
90
|
+
(value.source_version === null || typeof value.source_version === 'string')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function validateName(value: unknown): string {
|
|
94
|
+
if (typeof value !== 'string' || value.trim().length === 0 || value.trim().length > 120) {
|
|
95
|
+
throw new Error('lawName 必須是 1 至 120 個字元的法規名稱')
|
|
96
|
+
}
|
|
97
|
+
return value.trim()
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function validateArticleNumber(value: unknown): string {
|
|
101
|
+
if (typeof value !== 'string' || !/^\d{1,5}(?:-\d{1,5})?$/.test(value.trim())) {
|
|
102
|
+
throw new Error('articleNumber 必須使用阿拉伯數字,可含連字號,例如 1 或 1-1')
|
|
103
|
+
}
|
|
104
|
+
return value.trim().split('-').map(part => part.replace(/^0+(?=\d)/, '')).join('-')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function validateRefresh(value: unknown): boolean {
|
|
108
|
+
if (value === undefined) return false
|
|
109
|
+
if (typeof value !== 'boolean') throw new Error('refresh 必須是布林值')
|
|
110
|
+
return value
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function normalizeName(value: string): string {
|
|
114
|
+
return value.replaceAll('台', '臺').replaceAll(/\s+/g, '').toLocaleLowerCase()
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function termUrl(name: string): URL {
|
|
118
|
+
const url = new URL(TERM_URL.href)
|
|
119
|
+
url.searchParams.set('term', name)
|
|
120
|
+
url.searchParams.set('type', 'ONEBAR')
|
|
121
|
+
return url
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function detailUrl(identifier: string): URL {
|
|
125
|
+
const url = new URL('/LawClass/LawAll.aspx', BASE_URL)
|
|
126
|
+
url.searchParams.set('pcode', identifier)
|
|
127
|
+
return url
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function termRequestOptions() {
|
|
131
|
+
return {
|
|
132
|
+
timeout: 15_000,
|
|
133
|
+
maxContentLength: RESPONSE_LIMIT,
|
|
134
|
+
maxBodyLength: RESPONSE_LIMIT,
|
|
135
|
+
maxRedirects: 0,
|
|
136
|
+
responseType: RESPONSE_TYPE,
|
|
137
|
+
validateStatus: (status: number) => status >= 200 && status < 300,
|
|
138
|
+
headers: {
|
|
139
|
+
Accept: 'application/json,text/plain',
|
|
140
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
141
|
+
'User-Agent': 'judicial-mcp/1.5 (https://github.com/jurislm/judicial-mcp)',
|
|
142
|
+
},
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function firecrawlEndpoint(sourceUrl: URL): { endpoint: URL; apiKey?: string } {
|
|
147
|
+
const configuredUrl = process.env.FIRECRAWL_API_URL?.trim()
|
|
148
|
+
if (!configuredUrl) throw new RegulationSourceError('source_unavailable', sourceUrl.href)
|
|
149
|
+
let endpoint: URL
|
|
150
|
+
try {
|
|
151
|
+
endpoint = new URL(configuredUrl)
|
|
152
|
+
} catch {
|
|
153
|
+
throw new RegulationSourceError('source_unavailable', sourceUrl.href)
|
|
154
|
+
}
|
|
155
|
+
if (
|
|
156
|
+
(endpoint.protocol !== 'http:' && endpoint.protocol !== 'https:') ||
|
|
157
|
+
endpoint.username.length > 0 || endpoint.password.length > 0 ||
|
|
158
|
+
endpoint.search.length > 0 || endpoint.hash.length > 0
|
|
159
|
+
) throw new RegulationSourceError('source_unavailable', sourceUrl.href)
|
|
160
|
+
const apiKey = process.env.FIRECRAWL_API_KEY?.trim()
|
|
161
|
+
const loopbackHosts = new Set(['localhost', '127.0.0.1', '::1', '[::1]'])
|
|
162
|
+
if (apiKey && endpoint.protocol === 'http:' && !loopbackHosts.has(endpoint.hostname.toLocaleLowerCase())) {
|
|
163
|
+
throw new RegulationSourceError('source_unavailable', sourceUrl.href)
|
|
164
|
+
}
|
|
165
|
+
endpoint.pathname = `${endpoint.pathname.replace(/\/+$/, '')}/v1/scrape`
|
|
166
|
+
return apiKey ? { endpoint, apiKey } : { endpoint }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function firecrawlRequestOptions(apiKey?: string) {
|
|
170
|
+
const headers: Record<string, string> = {
|
|
171
|
+
Accept: 'application/json',
|
|
172
|
+
'Content-Type': 'application/json',
|
|
173
|
+
}
|
|
174
|
+
if (apiKey) headers.Authorization = `Bearer ${apiKey}`
|
|
175
|
+
return {
|
|
176
|
+
timeout: FIRECRAWL_REQUEST_TIMEOUT_MS,
|
|
177
|
+
maxContentLength: RESPONSE_LIMIT,
|
|
178
|
+
maxBodyLength: RESPONSE_LIMIT,
|
|
179
|
+
maxRedirects: 0,
|
|
180
|
+
validateStatus: (status: number) => status >= 200 && status < 300,
|
|
181
|
+
headers,
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function sourceFailure(error: unknown, url: URL, notFoundOn404 = true): never {
|
|
186
|
+
const status = error !== null && typeof error === 'object' && 'response' in error
|
|
187
|
+
? (error.response !== null && typeof error.response === 'object' && 'status' in error.response
|
|
188
|
+
? error.response.status
|
|
189
|
+
: undefined)
|
|
190
|
+
: undefined
|
|
191
|
+
throw new RegulationSourceError(status === 404 && notFoundOn404 ? 'regulation_not_found' : 'source_unavailable', url.href)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function responseText(response: AxiosResponse<string>, url: URL): string {
|
|
195
|
+
if (typeof response.data !== 'string' || response.data.length > RESPONSE_LIMIT) {
|
|
196
|
+
throw new RegulationSourceError('source_changed', url.href)
|
|
197
|
+
}
|
|
198
|
+
return response.data
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function responseHtml(payload: unknown, url: URL): string {
|
|
202
|
+
if (!isRecord(payload)) throw new RegulationSourceError('source_changed', url.href)
|
|
203
|
+
if (payload.success !== true) throw new RegulationSourceError('source_unavailable', url.href)
|
|
204
|
+
if (!isRecord(payload.data) || typeof payload.data.rawHtml !== 'string' || payload.data.rawHtml.length === 0 || payload.data.rawHtml.length > RESPONSE_LIMIT) {
|
|
205
|
+
throw new RegulationSourceError('source_changed', url.href)
|
|
206
|
+
}
|
|
207
|
+
const metadata = payload.data.metadata
|
|
208
|
+
if (!isRecord(metadata) || typeof metadata.sourceURL !== 'string') {
|
|
209
|
+
throw new RegulationSourceError('source_changed', url.href)
|
|
210
|
+
}
|
|
211
|
+
let resolvedUrl: URL
|
|
212
|
+
try {
|
|
213
|
+
resolvedUrl = new URL(metadata.sourceURL)
|
|
214
|
+
} catch {
|
|
215
|
+
throw new RegulationSourceError('source_changed', url.href)
|
|
216
|
+
}
|
|
217
|
+
const requestedPcode = url.searchParams.get('pcode')
|
|
218
|
+
const sourceParameters = [...resolvedUrl.searchParams.entries()]
|
|
219
|
+
if (
|
|
220
|
+
resolvedUrl.origin !== url.origin ||
|
|
221
|
+
resolvedUrl.username.length > 0 ||
|
|
222
|
+
resolvedUrl.password.length > 0 ||
|
|
223
|
+
resolvedUrl.pathname !== url.pathname ||
|
|
224
|
+
resolvedUrl.hash.length > 0 ||
|
|
225
|
+
requestedPcode === null ||
|
|
226
|
+
sourceParameters.length !== 1 ||
|
|
227
|
+
resolvedUrl.searchParams.getAll('pcode').length !== 1 ||
|
|
228
|
+
resolvedUrl.searchParams.get('pcode') !== requestedPcode
|
|
229
|
+
) {
|
|
230
|
+
throw new RegulationSourceError('source_changed', url.href)
|
|
231
|
+
}
|
|
232
|
+
return payload.data.rawHtml
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function validPcode(value: string | undefined): value is string {
|
|
236
|
+
return value !== undefined && /^[A-Z]\d{7}$/.test(value.toLocaleUpperCase())
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function parseTermCandidates(payload: string, url: URL): RegulationCandidate[] {
|
|
240
|
+
let parsed: unknown
|
|
241
|
+
try {
|
|
242
|
+
parsed = JSON.parse(payload)
|
|
243
|
+
} catch {
|
|
244
|
+
throw new RegulationSourceError('source_changed', url.href)
|
|
245
|
+
}
|
|
246
|
+
if (!Array.isArray(parsed)) throw new RegulationSourceError('source_changed', url.href)
|
|
247
|
+
const candidates: RegulationCandidate[] = []
|
|
248
|
+
for (const item of parsed) {
|
|
249
|
+
if (!isRecord(item) || typeof item.PCODE !== 'string' || typeof item.TERM !== 'string') {
|
|
250
|
+
throw new RegulationSourceError('source_changed', url.href)
|
|
251
|
+
}
|
|
252
|
+
const identifier = item.PCODE.trim()
|
|
253
|
+
const name = item.TERM.replaceAll(/\s+/g, ' ').trim()
|
|
254
|
+
if (identifier.length === 0 || name.length === 0) continue
|
|
255
|
+
if (!validPcode(identifier)) throw new RegulationSourceError('source_changed', url.href)
|
|
256
|
+
const normalizedIdentifier = identifier.toLocaleUpperCase()
|
|
257
|
+
const duplicate = candidates.find(candidate => candidate.identifier === normalizedIdentifier && normalizeName(candidate.name) === normalizeName(name))
|
|
258
|
+
if (!duplicate) candidates.push({ name, identifier: normalizedIdentifier })
|
|
259
|
+
}
|
|
260
|
+
return candidates
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function parseRocDate(value: string): string | null {
|
|
264
|
+
const match = value.match(/民國\s*(\d{1,3})\s*年\s*(\d{1,2})\s*月\s*(\d{1,2})\s*日/)
|
|
265
|
+
if (!match) return null
|
|
266
|
+
const year = Number(match[1]) + 1911
|
|
267
|
+
const month = Number(match[2])
|
|
268
|
+
const day = Number(match[3])
|
|
269
|
+
const parsed = new Date(Date.UTC(year, month - 1, day))
|
|
270
|
+
if (
|
|
271
|
+
month < 1 || month > 12 || day < 1 || day > 31 ||
|
|
272
|
+
parsed.getUTCFullYear() !== year || parsed.getUTCMonth() !== month - 1 || parsed.getUTCDate() !== day
|
|
273
|
+
) return null
|
|
274
|
+
return `${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function dateForLabel(text: string, labels: string[]): string | null {
|
|
278
|
+
for (const label of labels) {
|
|
279
|
+
const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
280
|
+
const match = text.match(new RegExp(`${escaped}\\s*[::]?\\s*(?:\\|\\s*)?(民國\\s*\\d{1,3}\\s*年\\s*\\d{1,2}\\s*月\\s*\\d{1,2}\\s*日)`))
|
|
281
|
+
if (match?.[1]) return parseRocDate(match[1])
|
|
282
|
+
}
|
|
283
|
+
return null
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function normalizeArticleText(value: string): string {
|
|
287
|
+
return value
|
|
288
|
+
.replaceAll('\u00a0', ' ')
|
|
289
|
+
.replaceAll(/[ \t]+/g, ' ')
|
|
290
|
+
.replaceAll(/\s*\n\s*/g, '\n')
|
|
291
|
+
.replaceAll(/\n{3,}/g, '\n\n')
|
|
292
|
+
.trim()
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function normalizeArticleHtml(value: string): string {
|
|
296
|
+
const $ = load(`<div>${value}</div>`)
|
|
297
|
+
$('script, style').remove()
|
|
298
|
+
$('br').replaceWith('\n')
|
|
299
|
+
$('p, li').each((_, element) => {
|
|
300
|
+
$(element).prepend('\n').append('\n')
|
|
301
|
+
})
|
|
302
|
+
return normalizeArticleText($('div').first().text())
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function regulationMetadataText(pageText: string): string {
|
|
306
|
+
const firstArticle = pageText.search(/第\s*\d+(?:-\d+)?\s*條/)
|
|
307
|
+
return firstArticle < 0 ? pageText : pageText.slice(0, firstArticle)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function articleHeading(block: ArticleBlock): string {
|
|
311
|
+
const heading = block.find('.col-no, .law-reg-no, .article-number').first()
|
|
312
|
+
const html = heading.html()
|
|
313
|
+
if (html !== null) {
|
|
314
|
+
const $heading = load(`<div>${html}</div>`)
|
|
315
|
+
let linkedHeading: string | undefined
|
|
316
|
+
$heading('a').each((_, anchor) => {
|
|
317
|
+
const text = $heading(anchor).text().trim()
|
|
318
|
+
if (!linkedHeading && /^第\s*\d+(?:-\d+)?\s*條$/.test(text)) linkedHeading = text
|
|
319
|
+
})
|
|
320
|
+
if (linkedHeading) return linkedHeading
|
|
321
|
+
}
|
|
322
|
+
return heading.text().trim()
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function articleBlockText(block: ArticleBlock, articleNumber: string): string | undefined {
|
|
326
|
+
const heading = articleHeading(block)
|
|
327
|
+
if (!heading || !new RegExp(`^第\\s*${articleNumber.replace('-', '\\-')}\\s*條`).test(heading)) return undefined
|
|
328
|
+
const content = block.find('.col-data, .law-reg-content-text, .article-content').first()
|
|
329
|
+
if (!content.length) return undefined
|
|
330
|
+
const html = content.html()
|
|
331
|
+
if (html === null) return undefined
|
|
332
|
+
return normalizeArticleHtml(html)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function isArticleBlock(block: ArticleBlock): boolean {
|
|
336
|
+
const heading = articleHeading(block)
|
|
337
|
+
const content = block.find('.col-data, .law-reg-content-text, .article-content').first()
|
|
338
|
+
return /^第\s*\d+(?:-\d+)?\s*條/.test(heading) && content.length > 0 && content.text().trim().length > 0
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function parseArticle(html: string, url: URL, lawName: string, articleNumber: string): ParsedArticle {
|
|
342
|
+
const $ = load(html)
|
|
343
|
+
const pageText = normalizeArticleText($('main, #content, #law_content').first().text() || $('body').text())
|
|
344
|
+
const metadataText = regulationMetadataText(pageText)
|
|
345
|
+
if (/法規名稱\s*[::]\s*廢\s/.test(metadataText) || /廢止日期\s*[::]/.test(metadataText)) {
|
|
346
|
+
throw new RegulationSourceError('regulation_abolished', url.href)
|
|
347
|
+
}
|
|
348
|
+
if (!pageText || !normalizeName(pageText).includes(normalizeName(lawName))) {
|
|
349
|
+
throw new RegulationSourceError('source_changed', url.href)
|
|
350
|
+
}
|
|
351
|
+
let articleText: string | undefined
|
|
352
|
+
const numberedRows = $('.law-reg-content > .row').toArray()
|
|
353
|
+
const blocks = numberedRows.length > 0
|
|
354
|
+
? numberedRows
|
|
355
|
+
: $('.law-reg-content, .law-article, .article-content-row, [id^="law-reg-content"]').toArray()
|
|
356
|
+
if (blocks.length === 0 || blocks.some(block => !isArticleBlock($(block)))) {
|
|
357
|
+
throw new RegulationSourceError('source_changed', url.href)
|
|
358
|
+
}
|
|
359
|
+
for (const block of blocks) {
|
|
360
|
+
articleText = articleBlockText($(block), articleNumber)
|
|
361
|
+
if (articleText) break
|
|
362
|
+
}
|
|
363
|
+
if (!articleText) {
|
|
364
|
+
throw new RegulationSourceError('regulation_article_not_found', url.href)
|
|
365
|
+
}
|
|
366
|
+
const version: RegulationVersion = {
|
|
367
|
+
amendment_date: dateForLabel(metadataText, ['修正日期', '修正日']),
|
|
368
|
+
promulgation_date: dateForLabel(metadataText, ['公布日期', '發布日期']),
|
|
369
|
+
effective_date: dateForLabel(metadataText, ['施行日期', '生效日期']),
|
|
370
|
+
effective_status: /尚未生效|尚未施行|最後生效日期:未定/.test(metadataText) ? 'pending_or_mixed' : 'as_stated',
|
|
371
|
+
}
|
|
372
|
+
if (/最後生效日期:未定/.test(metadataText) && version.effective_date === null) {
|
|
373
|
+
version.effective_status = 'unknown'
|
|
374
|
+
}
|
|
375
|
+
return { text: articleText, version }
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function makeResult(
|
|
379
|
+
candidate: RegulationCandidate,
|
|
380
|
+
articleNumber: string,
|
|
381
|
+
article: ParsedArticle,
|
|
382
|
+
sourceUrl: URL,
|
|
383
|
+
searchSourceUrl: URL,
|
|
384
|
+
retrievedAt: string,
|
|
385
|
+
cacheStatus: RegulationResult['cache']['status'],
|
|
386
|
+
): RegulationResult {
|
|
387
|
+
const truncated = article.text.length > MAX_ARTICLE_LENGTH
|
|
388
|
+
const missingDates = article.version.amendment_date === null ||
|
|
389
|
+
article.version.promulgation_date === null || article.version.effective_date === null
|
|
390
|
+
const text = article.text.slice(0, MAX_ARTICLE_LENGTH)
|
|
391
|
+
return {
|
|
392
|
+
status: 'current',
|
|
393
|
+
regulation_name: candidate.name,
|
|
394
|
+
regulation_id: candidate.identifier,
|
|
395
|
+
article_number: articleNumber,
|
|
396
|
+
article_text: text,
|
|
397
|
+
version: article.version,
|
|
398
|
+
source_url: sourceUrl.href,
|
|
399
|
+
search_source_url: searchSourceUrl.href,
|
|
400
|
+
retrieved_at: retrievedAt,
|
|
401
|
+
cache: { status: cacheStatus },
|
|
402
|
+
truncated,
|
|
403
|
+
partial: truncated || missingDates || article.version.effective_status !== 'as_stated',
|
|
404
|
+
source_version: article.version.amendment_date ?? article.version.promulgation_date,
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function resultWithCacheStatus(result: RegulationResult, status: RegulationResult['cache']['status'], retrievedAt = result.retrieved_at): RegulationResult {
|
|
409
|
+
return { ...result, retrieved_at: retrievedAt, cache: { status } }
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function cacheKey(lawName: string, articleNumber: string): string {
|
|
413
|
+
return `${normalizeName(lawName)}\u0000${articleNumber}`
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function ambiguousResult(candidates: RegulationCandidate[], lookupURL: URL): RegulationResult {
|
|
417
|
+
return {
|
|
418
|
+
status: 'ambiguous',
|
|
419
|
+
candidates: candidates.slice(0, MAX_CANDIDATES).map(candidate => ({ regulation_name: candidate.name, regulation_id: candidate.identifier })),
|
|
420
|
+
source_url: lookupURL.href,
|
|
421
|
+
search_source_url: lookupURL.href,
|
|
422
|
+
retrieved_at: new Date().toISOString(),
|
|
423
|
+
cache: { status: 'not_cached' },
|
|
424
|
+
truncated: candidates.length > MAX_CANDIDATES,
|
|
425
|
+
partial: true,
|
|
426
|
+
source_version: null,
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function resolveCandidate(candidates: RegulationCandidate[], lawName: string, lookupURL: URL): CandidateResolution {
|
|
431
|
+
const requested = normalizeName(lawName)
|
|
432
|
+
const exact = candidates.filter(candidate => normalizeName(candidate.name) === requested)
|
|
433
|
+
if (exact.length === 1) {
|
|
434
|
+
const candidate = exact[0]
|
|
435
|
+
if (candidate) return { candidate }
|
|
436
|
+
throw new RegulationSourceError('source_changed', lookupURL.href)
|
|
437
|
+
}
|
|
438
|
+
if (candidates.length === 0) throw new RegulationSourceError('regulation_not_found', lookupURL.href)
|
|
439
|
+
return { ambiguous: ambiguousResult(exact.length > 1 ? exact : candidates, lookupURL) }
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
async function fetchHtml(url: URL): Promise<string> {
|
|
443
|
+
const { endpoint, apiKey } = firecrawlEndpoint(url)
|
|
444
|
+
let response: AxiosResponse<unknown>
|
|
445
|
+
try {
|
|
446
|
+
response = await axios.post<unknown>(
|
|
447
|
+
endpoint.href,
|
|
448
|
+
{
|
|
449
|
+
url: url.href,
|
|
450
|
+
formats: ['rawHtml'],
|
|
451
|
+
onlyMainContent: false,
|
|
452
|
+
timeout: FIRECRAWL_TIMEOUT_MS,
|
|
453
|
+
},
|
|
454
|
+
firecrawlRequestOptions(apiKey),
|
|
455
|
+
)
|
|
456
|
+
} catch (error: unknown) {
|
|
457
|
+
sourceFailure(error, url, false)
|
|
458
|
+
}
|
|
459
|
+
return responseHtml(response.data, url)
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async function fetchTermCandidates(name: string, url: URL): Promise<RegulationCandidate[]> {
|
|
463
|
+
let response: AxiosResponse<string>
|
|
464
|
+
try {
|
|
465
|
+
const options = termRequestOptions()
|
|
466
|
+
response = await axios.post<string>(
|
|
467
|
+
url.href,
|
|
468
|
+
new URLSearchParams({ term: name, type: 'ONEBAR' }).toString(),
|
|
469
|
+
options,
|
|
470
|
+
)
|
|
471
|
+
} catch (error: unknown) {
|
|
472
|
+
sourceFailure(error, url, false)
|
|
473
|
+
}
|
|
474
|
+
if (response.status < 200 || response.status >= 300) throw new RegulationSourceError('source_changed', url.href)
|
|
475
|
+
return parseTermCandidates(responseText(response, url), url)
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
async function lookupRegulation(lawName: string): Promise<{ resolution: CandidateResolution; lookupURL: URL }> {
|
|
479
|
+
const directURL = termUrl(lawName)
|
|
480
|
+
const directCandidates = await fetchTermCandidates(lawName, directURL)
|
|
481
|
+
return { resolution: resolveCandidate(directCandidates, lawName, directURL), lookupURL: directURL }
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
export async function queryRegulation(args: Record<string, unknown>): Promise<RegulationResult> {
|
|
485
|
+
const lawName = validateName(args.lawName)
|
|
486
|
+
const articleNumber = validateArticleNumber(args.articleNumber)
|
|
487
|
+
const refresh = validateRefresh(args.refresh)
|
|
488
|
+
const key = cacheKey(lawName, articleNumber)
|
|
489
|
+
const now = Date.now()
|
|
490
|
+
const rawCached = getRegulationCacheEntry(key)
|
|
491
|
+
let cached: ValidatedRegulationCacheEntry | undefined
|
|
492
|
+
if (rawCached) {
|
|
493
|
+
if (isRegulationResult(rawCached.value)) cached = { ...rawCached, value: rawCached.value }
|
|
494
|
+
else deleteRegulationCacheEntry(key)
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
if (cached && cached.expiresAt > now && !refresh) {
|
|
498
|
+
return resultWithCacheStatus(cached.value, 'hit')
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (refresh) deleteRegulationCacheEntry(key)
|
|
502
|
+
if (cached && cached.expiresAt <= now && !refresh) {
|
|
503
|
+
const checkedAt = new Date().toISOString()
|
|
504
|
+
try {
|
|
505
|
+
const lookup = await lookupRegulation(lawName)
|
|
506
|
+
const { lookupURL, resolution } = lookup
|
|
507
|
+
if ('ambiguous' in resolution) {
|
|
508
|
+
deleteRegulationCacheEntry(key)
|
|
509
|
+
return resolution.ambiguous
|
|
510
|
+
}
|
|
511
|
+
const candidate = resolution.candidate
|
|
512
|
+
const detailURL = detailUrl(candidate.identifier)
|
|
513
|
+
const html = await fetchHtml(detailURL)
|
|
514
|
+
const article = parseArticle(html, detailURL, candidate.name, articleNumber)
|
|
515
|
+
const result = makeResult(candidate, articleNumber, article, detailURL, lookupURL, checkedAt, 'revalidated')
|
|
516
|
+
setRegulationCacheEntry(key, { value: result }, now)
|
|
517
|
+
return result
|
|
518
|
+
} catch (error: unknown) {
|
|
519
|
+
deleteRegulationCacheEntry(key)
|
|
520
|
+
throw error
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const lookup = await lookupRegulation(lawName)
|
|
525
|
+
const { lookupURL, resolution } = lookup
|
|
526
|
+
if ('ambiguous' in resolution) return resolution.ambiguous
|
|
527
|
+
const candidate = resolution.candidate
|
|
528
|
+
|
|
529
|
+
const articleURL = detailUrl(candidate.identifier)
|
|
530
|
+
const html = await fetchHtml(articleURL)
|
|
531
|
+
const article = parseArticle(html, articleURL, candidate.name, articleNumber)
|
|
532
|
+
const retrievedAt = new Date().toISOString()
|
|
533
|
+
const result = makeResult(candidate, articleNumber, article, articleURL, lookupURL, retrievedAt, refresh ? 'refreshed' : 'miss')
|
|
534
|
+
setRegulationCacheEntry(key, { value: result }, now)
|
|
535
|
+
return result
|
|
536
|
+
}
|
package/src/response.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { JudicialSourceError, RegulationSourceError } from './source-error.js'
|
|
2
|
+
|
|
3
|
+
type ContentBlock =
|
|
4
|
+
| { type: 'text'; text: string }
|
|
5
|
+
| { type: 'resource'; resource: { uri: string; mimeType: string; blob: string } }
|
|
6
|
+
|
|
7
|
+
export interface McpToolResult {
|
|
8
|
+
content: ContentBlock[]
|
|
9
|
+
isError?: boolean
|
|
10
|
+
[key: string]: unknown
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const createSuccessResponse = (data: unknown): McpToolResult => ({
|
|
14
|
+
content: [
|
|
15
|
+
{
|
|
16
|
+
type: 'text',
|
|
17
|
+
text: JSON.stringify(data, null, 2),
|
|
18
|
+
},
|
|
19
|
+
],
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
export const createErrorResponse = (error: unknown, message: string): McpToolResult => {
|
|
23
|
+
if (error instanceof JudicialSourceError || error instanceof RegulationSourceError) {
|
|
24
|
+
return {
|
|
25
|
+
content: [
|
|
26
|
+
{
|
|
27
|
+
type: 'text',
|
|
28
|
+
text: JSON.stringify({
|
|
29
|
+
error: {
|
|
30
|
+
code: error.code,
|
|
31
|
+
message: error.message,
|
|
32
|
+
source_url: error.sourceUrl,
|
|
33
|
+
retrieved_at: error.retrievedAt,
|
|
34
|
+
},
|
|
35
|
+
}, null, 2),
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
isError: true,
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const detail =
|
|
42
|
+
error !== null && typeof error === 'object' && 'response' in error
|
|
43
|
+
? (error as { response?: { data?: unknown } }).response?.data
|
|
44
|
+
: error instanceof Error
|
|
45
|
+
? error.message
|
|
46
|
+
: String(error)
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
content: [
|
|
50
|
+
{
|
|
51
|
+
type: 'text',
|
|
52
|
+
text: JSON.stringify({ error: message, detail }, null, 2),
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
isError: true,
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const createBlobResponse = (
|
|
60
|
+
data: Buffer | string,
|
|
61
|
+
mimeType: string,
|
|
62
|
+
uri: string,
|
|
63
|
+
): McpToolResult => ({
|
|
64
|
+
content: [
|
|
65
|
+
{
|
|
66
|
+
type: 'resource',
|
|
67
|
+
resource: {
|
|
68
|
+
uri,
|
|
69
|
+
mimeType,
|
|
70
|
+
blob: Buffer.isBuffer(data) ? data.toString('base64') : data,
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
})
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import 'dotenv/config'
|
|
2
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
|
3
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
4
|
+
import {
|
|
5
|
+
CallToolRequestSchema,
|
|
6
|
+
ListToolsRequestSchema,
|
|
7
|
+
} from '@modelcontextprotocol/sdk/types.js'
|
|
8
|
+
import { TOOLS_CONFIG, TOOL_HANDLERS } from './tools.js'
|
|
9
|
+
import { createSuccessResponse, createErrorResponse, type McpToolResult } from './response.js'
|
|
10
|
+
import { createRequire } from 'module'
|
|
11
|
+
|
|
12
|
+
const { version } = createRequire(import.meta.url)('../package.json') as { version: string }
|
|
13
|
+
|
|
14
|
+
const server = new Server(
|
|
15
|
+
{ name: 'judicial-mcp', version },
|
|
16
|
+
{ capabilities: { tools: {} } },
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
20
|
+
tools: Object.values(TOOLS_CONFIG),
|
|
21
|
+
}))
|
|
22
|
+
|
|
23
|
+
server.onerror = (error: unknown) => {
|
|
24
|
+
console.error('MCP 協議錯誤:', error instanceof Error ? error.message : error)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function dispatchTool(
|
|
28
|
+
name: string,
|
|
29
|
+
args: Record<string, unknown>,
|
|
30
|
+
): Promise<McpToolResult> {
|
|
31
|
+
const handler = Object.prototype.hasOwnProperty.call(TOOL_HANDLERS, name)
|
|
32
|
+
? TOOL_HANDLERS[name]
|
|
33
|
+
: undefined
|
|
34
|
+
if (!handler) {
|
|
35
|
+
throw new Error(`未知的工具: ${name}`)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const result = await handler(args)
|
|
39
|
+
|
|
40
|
+
if (
|
|
41
|
+
result !== null &&
|
|
42
|
+
typeof result === 'object' &&
|
|
43
|
+
'content' in result &&
|
|
44
|
+
Array.isArray((result as { content: unknown }).content) &&
|
|
45
|
+
(result as { content: Array<{ type?: string }> }).content[0]?.type === 'resource'
|
|
46
|
+
) {
|
|
47
|
+
return result as McpToolResult
|
|
48
|
+
}
|
|
49
|
+
return createSuccessResponse(result)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
53
|
+
const { name, arguments: args } = request.params
|
|
54
|
+
try {
|
|
55
|
+
return await dispatchTool(name, (args ?? {}) as Record<string, unknown>)
|
|
56
|
+
} catch (error: unknown) {
|
|
57
|
+
console.error(`執行工具 ${name} 時發生錯誤:`, error instanceof Error ? error.message : error)
|
|
58
|
+
return createErrorResponse(error, `執行工具 ${name} 時發生錯誤`)
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
async function main() {
|
|
63
|
+
const transport = new StdioServerTransport()
|
|
64
|
+
await server.connect(transport)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
main().catch((error: unknown) => {
|
|
68
|
+
console.error('伺服器啟動失敗:', error)
|
|
69
|
+
process.exit(1)
|
|
70
|
+
})
|