dsh-scrape 0.0.1 → 0.1.0

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/errors.mjs ADDED
@@ -0,0 +1,137 @@
1
+ /**
2
+ * 错误模型:抓取失败必须能被调用方分类处理,也必须能直接给人看。
3
+ *
4
+ * `code` 是稳定契约(不随文案变),`message` 是中文人话,`hint` 是「该去查什么」。
5
+ * **绝不吞异常**:所有失败路径都抛 ScrapeError,分类见 classifyFetchError。
6
+ */
7
+
8
+ /** 网络 / HTTP / 输入类错误。code 稳定,文案可改。 */
9
+ export class ScrapeError extends Error {
10
+ /**
11
+ * @param {string} code 稳定错误码
12
+ * @param {string} message 中文人话
13
+ * @param {{url?:string,cause?:unknown,details?:object,hint?:string}} [init] 附加信息
14
+ */
15
+ constructor(code, message, init = {}) {
16
+ super(message)
17
+ this.name = 'ScrapeError'
18
+ this.code = code
19
+ this.url = init.url
20
+ this.hint = init.hint
21
+ this.details = init.details ?? {}
22
+ if (init.cause !== undefined) this.cause = init.cause
23
+ }
24
+
25
+ /** 便于 JSON 序列化的纯数据视图(CLI --json 与工具错误都用它)。 */
26
+ toJSON() {
27
+ return {
28
+ code: this.code,
29
+ message: this.message,
30
+ hint: this.hint,
31
+ url: this.url,
32
+ details: this.details,
33
+ }
34
+ }
35
+ }
36
+
37
+ /**
38
+ * 把底层 fetch / undici / socket 错误翻成我们的分类。
39
+ * @param {unknown} error 捕获到的异常
40
+ * @param {string} url 请求的 URL
41
+ * @returns {ScrapeError} 分类后的错误
42
+ */
43
+ export function classifyFetchError(error, url) {
44
+ const name = error && typeof error === 'object' ? String(error.name) : ''
45
+ const causeCode = error && typeof error === 'object' && error.cause && typeof error.cause === 'object'
46
+ ? String(error.cause.code ?? '')
47
+ : ''
48
+ const message = error instanceof Error ? error.message : String(error)
49
+ const lower = message.toLowerCase()
50
+
51
+ if (name === 'TimeoutError' || lower.includes('timed out') || lower.includes('timeout')) {
52
+ return new ScrapeError('timeout', `请求超时:${url} 在限定时间内没有响应`, {
53
+ url, cause: error, hint: '站点可能很慢或在墙外;放宽 --timeout、换网络/代理,或先用浏览器确认它到底通不通',
54
+ })
55
+ }
56
+ if (name === 'AbortError') {
57
+ return new ScrapeError('aborted', `请求被取消:${url}`, {
58
+ url, cause: error, hint: '调用方主动取消(如工具超时),不是站点问题',
59
+ })
60
+ }
61
+ if (causeCode === 'ENOTFOUND' || causeCode === 'EAI_AGAIN' || lower.includes('getaddrinfo')) {
62
+ return new ScrapeError('dns', `域名解析失败:${url} 的主机名查不到`, {
63
+ url, cause: error, details: { code: causeCode }, hint: '检查域名是否拼错或已过期;国内网络访问境外站可能需要代理',
64
+ })
65
+ }
66
+ if (causeCode === 'ECONNREFUSED') {
67
+ return new ScrapeError('connection_refused', `连接被拒绝:${url} 没有在监听`, {
68
+ url, cause: error, details: { code: causeCode }, hint: '端口没开,或本地服务没起来',
69
+ })
70
+ }
71
+ if (causeCode === 'ECONNRESET' || causeCode === 'EPIPE' || causeCode === 'UND_ERR_SOCKET') {
72
+ return new ScrapeError('connection_reset', `连接被对端断开:${url}`, {
73
+ url, cause: error, details: { code: causeCode }, hint: '常见于代理中断或站点反爬;重试或换线路',
74
+ })
75
+ }
76
+ if (
77
+ causeCode.startsWith('CERT_') || causeCode === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE' ||
78
+ causeCode === 'DEPTH_ZERO_SELF_SIGNED_CERT' || causeCode === 'ERR_TLS_CERT_ALTNAME_INVALID' ||
79
+ lower.includes('certificate')
80
+ ) {
81
+ return new ScrapeError('tls', `HTTPS 证书验证失败:${url}`, {
82
+ url, cause: error, details: { code: causeCode }, hint: '对方证书过期/自签/域名不匹配;不要为了抓取去关证书校验',
83
+ })
84
+ }
85
+ if (causeCode === 'UND_ERR_TOO_MANY_REDIRECTS' || lower.includes('redirect')) {
86
+ return new ScrapeError('too_many_redirects', `重定向过多:${url}`, {
87
+ url, cause: error, details: { code: causeCode }, hint: '大概是 http→https→http 死循环;先用浏览器确认最终地址',
88
+ })
89
+ }
90
+ if (causeCode === 'EHOSTUNREACH' || causeCode === 'ENETUNREACH') {
91
+ return new ScrapeError('network', `网络不可达:${url}`, {
92
+ url, cause: error, details: { code: causeCode }, hint: '本机没网或路由不通',
93
+ })
94
+ }
95
+ if (name === 'TypeError' && lower.includes('failed to parse url')) {
96
+ return new ScrapeError('invalid_url', `URL 不合法:${url}`, { url, cause: error, hint: '要带 http:// 或 https:// 前缀' })
97
+ }
98
+ return new ScrapeError('network', `请求失败:${url}(${message})`, {
99
+ url, cause: error, details: { code: causeCode, name }, hint: '未知网络错误,看 details 里的原始 code',
100
+ })
101
+ }
102
+
103
+ /**
104
+ * HTTP 状态码 → 分类错误(4xx/5xx 都算失败,除非调用方声明可接受)。
105
+ * @param {string} url 请求 URL
106
+ * @param {number} status 状态码
107
+ * @param {string} statusText 状态说明
108
+ * @returns {ScrapeError} 分类后的错误
109
+ */
110
+ export function httpStatusError(url, status, statusText) {
111
+ const suffix = statusText ? ` ${statusText}` : ''
112
+ const hint = status === 404
113
+ ? '地址不存在;确认路径,或这个站点确实没有这个文件'
114
+ : status === 403 || status === 401
115
+ ? '被站点或 WAF 拦了:可能需要登录、换 UA,或这个页面本来就不对匿名访问开放'
116
+ : status === 429
117
+ ? '被限速了:降低频率(--min-interval)后重试'
118
+ : status >= 500
119
+ ? '对方服务器出错,等一会儿再试'
120
+ : '按状态码查原因'
121
+ return new ScrapeError('http_status', `HTTP ${status}${suffix}:${url}`, {
122
+ url, details: { status, statusText }, hint,
123
+ })
124
+ }
125
+
126
+ /**
127
+ * 把任意异常收敛成 {code,message,hint},供 CLI / 工具返回使用。
128
+ * @param {unknown} error 任意异常
129
+ * @returns {{code:string,message:string,hint?:string,status?:number}} 纯数据
130
+ */
131
+ export function describeError(error) {
132
+ if (error instanceof ScrapeError) return error.toJSON()
133
+ if (error instanceof Error) {
134
+ return { code: 'internal', message: error.message }
135
+ }
136
+ return { code: 'internal', message: String(error) }
137
+ }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * 正文抽取:「密度法」的最小实现 + 可解释的 confidence。
3
+ *
4
+ * 不做 Readability 那种打分模型的完整版,只做三件能被验证的事:
5
+ * 1) 有 `<main>` / `<article>` 且里面有足够文字 → 直接信它(这是站点自己声明的正文);
6
+ * 2) 否则按「连续正文段」找最长的一段(允许中间夹 2 个短块,因为正文里常有图注、按钮行);
7
+ * 3) 纯链接块、超短块、菜单词块判为样板内容(boilerplate)并丢掉。
8
+ *
9
+ * 每条结论都能说出理由(`reasons`),confidence 是这些理由的加权和,不是玄学分数。
10
+ */
11
+
12
+ import { stripInline } from './html.mjs'
13
+
14
+ /** 抽取器版本(报告里带上,便于对比不同版本的置信度口径)。 */
15
+ export const EXTRACT_VERSION = 'density-v1'
16
+
17
+ /** 正文块最短字数:低于这个数就认为「没抓到正文」。 */
18
+ export const MIN_MAIN_CHARS = 200
19
+
20
+ /** 纯样板块判定用的菜单词(整块只有这些词就丢)。 */
21
+ const MENU_WORDS = /^(首页|主页|关于我们|关于|联系我们|产品|服务|登录|注册|订阅|搜索|菜单|更多|返回|上一篇|下一篇|home|menu|about|contact|products?|services?|login|sign in|sign up|register|subscribe|search|more|next|previous|back|share|follow)$/i
22
+
23
+ /**
24
+ * 块的可读文本长度(去掉行内 Markdown 标记后计字)。
25
+ * @param {object} block 块
26
+ * @returns {number} 字数
27
+ */
28
+ export function blockLength(block) {
29
+ if (!block) return 0
30
+ if (block.kind === 'table') {
31
+ const cells = [...(block.header ?? []), ...(block.rows ?? []).flat()]
32
+ return cells.reduce((sum, cell) => sum + stripInline(cell).length, 0)
33
+ }
34
+ return stripInline(block.text ?? '').length
35
+ }
36
+
37
+ /**
38
+ * 块里的链接文字占比(导航条的特征:整块几乎都是链接文字)。
39
+ * @param {object} block 块
40
+ * @returns {number} 0–1
41
+ */
42
+ export function linkRatio(block) {
43
+ const text = String(block?.text ?? '')
44
+ const len = stripInline(text).length
45
+ if (len === 0) return 0
46
+ let linkChars = 0
47
+ for (const match of text.matchAll(/\[([^\]]*)\]\([^)]*\)/g)) linkChars += stripInline(match[1]).length
48
+ return linkChars / len
49
+ }
50
+
51
+ /**
52
+ * 判断一个块是不是界面样板(导航、页脚菜单、按钮行、版权行)。
53
+ * @param {object} block 块
54
+ * @returns {boolean} 是否样板
55
+ */
56
+ export function isBoilerplate(block) {
57
+ const length = blockLength(block)
58
+ if (length === 0) return true
59
+ if (block.kind === 'rule') return true
60
+ const plain = stripInline(block.text ?? '')
61
+ if (MENU_WORDS.test(plain)) return true
62
+ // 短块不一定是样板:导航/页脚/分享条已经在 HTML 层按元素去掉了,
63
+ // 这里只丢「真的没有信息量」的碎片(图标残留、单个符号、空链接串)
64
+ if (block.kind === 'paragraph' && length < 8) return true
65
+ if (block.kind === 'listItem' && length < 6) return true
66
+ if (block.kind === 'listItem' && length < 60 && linkRatio(block) > 0.5) return true
67
+ if (block.kind === 'paragraph' && length < 120 && linkRatio(block) > 0.6) return true
68
+ if (/^(©|\(c\)|copyright|版权所有|all rights reserved)/i.test(plain)) return true
69
+ return false
70
+ }
71
+
72
+ /**
73
+ * 在一串块里找最长的一段正文(允许夹 2 个样板块)。
74
+ * @param {Array<object>} blocks 块
75
+ * @returns {{indexes:number[],chars:number}} 最佳连续段
76
+ */
77
+ export function bestRun(blocks) {
78
+ let best = { indexes: [], chars: 0 }
79
+ let current = { indexes: [], chars: 0 }
80
+ let gap = 0
81
+ for (let i = 0; i < blocks.length; i++) {
82
+ const block = blocks[i]
83
+ const length = blockLength(block)
84
+ if (isBoilerplate(block)) {
85
+ if (current.indexes.length === 0) continue
86
+ gap += 1
87
+ if (gap > 2) {
88
+ if (current.chars > best.chars) best = { ...current, indexes: [...current.indexes] }
89
+ current = { indexes: [], chars: 0 }
90
+ gap = 0
91
+ }
92
+ continue
93
+ }
94
+ gap = 0
95
+ current.indexes.push(i)
96
+ current.chars += length
97
+ }
98
+ if (current.chars > best.chars) best = { ...current, indexes: [...current.indexes] }
99
+ return best
100
+ }
101
+
102
+ /**
103
+ * 挑正文块并给出可解释的置信度。
104
+ * @param {Array<object>} blocks parseHtml 的块
105
+ * @param {{minChars?:number,spa?:boolean,maxChars?:number}} [options] 选项
106
+ * @returns {object} 抽取结果
107
+ */
108
+ export function selectMain(blocks, options = {}) {
109
+ const minChars = options.minChars ?? MIN_MAIN_CHARS
110
+ const reasons = []
111
+ const all = Array.isArray(blocks) ? blocks : []
112
+ const totalChars = all.reduce((sum, block) => sum + blockLength(block), 0)
113
+
114
+ if (all.length === 0 || totalChars === 0) {
115
+ return {
116
+ blocks: [], method: 'empty', confidence: 0, reasons: ['页面没有任何可见文字'],
117
+ chars: { total: 0, kept: 0, dropped: 0, droppedBoilerplate: 0, droppedOffMain: 0 },
118
+ version: EXTRACT_VERSION,
119
+ }
120
+ }
121
+
122
+ const insideMain = all.filter((block) => block.inMain)
123
+ const insideMainChars = insideMain.reduce((sum, block) => sum + blockLength(block), 0)
124
+
125
+ let kept
126
+ let method
127
+ let base
128
+
129
+ // 站点自己声明的 <main>/<article> 是强信号:门槛放到 minChars 的一半(最低 80 字),
130
+ // 免得「短但完整」的文章被判成 fallback
131
+ if (insideMainChars >= Math.max(80, Math.round(minChars / 2))) {
132
+ kept = insideMain.filter((block) => !isBoilerplate(block) || block.kind === 'heading')
133
+ method = 'main-element'
134
+ base = 0.85
135
+ reasons.push(`页面用了 <main>/<article>,其中 ${insideMainChars} 字`)
136
+ } else {
137
+ const run = bestRun(all)
138
+ if (run.chars >= minChars) {
139
+ kept = all.filter((block, index) => run.indexes.includes(index))
140
+ method = 'density'
141
+ base = 0.55
142
+ reasons.push(`密度法选中 ${run.indexes.length} 个连续块,共 ${run.chars} 字(全页可见 ${totalChars} 字)`)
143
+ } else {
144
+ kept = all.filter((block) => !isBoilerplate(block))
145
+ if (kept.length === 0) kept = all.slice()
146
+ method = 'fallback'
147
+ base = 0.25
148
+ reasons.push(`没有可靠的正文段(最长连续段只有 ${run.chars} 字),退回「去掉样板后的全部文字」`)
149
+ }
150
+ }
151
+
152
+ // 只保留有文字的块,按原顺序
153
+ kept = kept.filter((block) => blockLength(block) > 0).sort((a, b) => a.seq - b.seq)
154
+
155
+ let confidence = base
156
+ const keptChars = kept.reduce((sum, block) => sum + blockLength(block), 0)
157
+ if (kept.length >= 3) { confidence += 0.10; reasons.push('保留块 ≥ 3 段(像正文而不是碎片)') }
158
+ if (keptChars >= 800) { confidence += 0.08; reasons.push(`正文 ${keptChars} 字(≥ 800)`) }
159
+ if (kept.some((block) => block.kind === 'heading')) { confidence += 0.05; reasons.push('正文里有标题层级') }
160
+ if (kept.some((block) => block.kind === 'table' || block.kind === 'code')) {
161
+ confidence += 0.05
162
+ reasons.push('正文里有表格或代码块(这类结构只在正文出现)')
163
+ }
164
+ if (keptChars < minChars) { confidence -= 0.15; reasons.push(`正文只有 ${keptChars} 字(< ${minChars})`) }
165
+ if (keptChars < 50) { confidence -= 0.15; reasons.push('正文不足 50 字') }
166
+ if (options.spa) { confidence -= 0.10; reasons.push('页面疑似客户端渲染(不执行 JS 时内容天然不全)') }
167
+
168
+ confidence = Math.max(0.05, Math.min(0.95, Math.round(confidence * 100) / 100))
169
+
170
+ const droppedBoilerplate = all
171
+ .filter((block) => !kept.includes(block) && isBoilerplate(block))
172
+ .reduce((sum, block) => sum + blockLength(block), 0)
173
+ const keptSet = new Set(kept)
174
+ const droppedOffMain = all
175
+ .filter((block) => !keptSet.has(block) && !isBoilerplate(block))
176
+ .reduce((sum, block) => sum + blockLength(block), 0)
177
+
178
+ return {
179
+ blocks: kept,
180
+ method,
181
+ confidence,
182
+ reasons,
183
+ chars: {
184
+ total: totalChars,
185
+ kept: keptChars,
186
+ dropped: Math.max(0, totalChars - keptChars),
187
+ droppedBoilerplate,
188
+ droppedOffMain,
189
+ },
190
+ version: EXTRACT_VERSION,
191
+ }
192
+ }
package/lib/format.mjs ADDED
@@ -0,0 +1,137 @@
1
+ /**
2
+ * 渲染层:一份给人/给模型看的文本摘要,一份纯 JSON(Markdown 正文本身在 result.markdown)。
3
+ *
4
+ * 口径:**摘要不抢正文的位置**。CLI 文本模式先给 6 行事实(状态、字数、置信度、去噪统计、警告),
5
+ * 空一行,再接 Markdown 正文——这样 agent 读到的第一屏就是可引用的内容,而不是一堆元数据。
6
+ */
7
+
8
+ /**
9
+ * 按显示宽度补空格(中文算 2 格),让中英混排对齐。
10
+ * @param {string} text 文本
11
+ * @param {number} width 目标宽度
12
+ * @returns {string} 补齐后的文本
13
+ */
14
+ export function padDisplay(text, width) {
15
+ const displayWidth = (input) => [...String(input)].reduce((sum, ch) => sum + (ch.charCodeAt(0) > 0x2e7f ? 2 : 1), 0)
16
+ const current = displayWidth(text)
17
+ return current >= width ? String(text) : String(text) + ' '.repeat(width - current)
18
+ }
19
+
20
+ /**
21
+ * 字节数人话化。
22
+ * @param {number} bytes 字节
23
+ * @returns {string} 例如 42.1 KB
24
+ */
25
+ export function humanBytes(bytes) {
26
+ const value = Number(bytes) || 0
27
+ if (value < 1024) return `${value} B`
28
+ if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`
29
+ return `${(value / 1024 / 1024).toFixed(1)} MB`
30
+ }
31
+
32
+ /**
33
+ * 渲染去噪统计(只列非零项)。
34
+ * @param {object} removed removed 对象
35
+ * @returns {string} 一行说明
36
+ */
37
+ export function renderRemoved(removed = {}) {
38
+ const parts = []
39
+ for (const key of ['script', 'style', 'nav', 'footer', 'aside', 'form', 'consent', 'share', 'comment', 'ad', 'noscript', 'svg', 'iframe', 'button', 'boilerplate']) {
40
+ const value = removed[key] ?? 0
41
+ if (value > 0) parts.push(`${key} ${value} 字`)
42
+ }
43
+ return parts.length > 0 ? `${parts.join(' · ')}(合计 ${removed.total ?? 0} 字)` : '没有可去噪的界面元素'
44
+ }
45
+
46
+ /**
47
+ * 渲染单页结果。
48
+ * @param {object} result scrape 结果
49
+ * @param {{detail?:'full'|'brief',maxWarnings?:number}} [options] 选项
50
+ * @returns {string} 文本
51
+ */
52
+ export function renderText(result, options = {}) {
53
+ const detail = options.detail ?? 'full'
54
+ const maxWarnings = options.maxWarnings ?? (detail === 'brief' ? 2 : 6)
55
+ const lines = []
56
+
57
+ if (result.kind === 'multi') return renderMulti(result, options)
58
+
59
+ const provenance = result.provenance ?? {}
60
+ const content = result.content ?? {}
61
+ lines.push(`dsh-scrape ${result.version} · ${result.kind === 'html' ? '本地 HTML' : 'URL'} · ${provenance.finalUrl ?? provenance.requestedUrl ?? '(无地址)'}`)
62
+ const facts = (result.kind === 'html'
63
+ ? [humanBytes(provenance.bytes), provenance.charset ? `字符集 ${provenance.charset}` : null]
64
+ : [
65
+ provenance.httpStatus ? `HTTP ${provenance.httpStatus}` : '未取到响应',
66
+ humanBytes(provenance.bytes),
67
+ provenance.elapsedMs ? `${provenance.elapsedMs}ms` : null,
68
+ provenance.charset ? `字符集 ${provenance.charset}` : null,
69
+ provenance.redirects?.length ? `跟随 ${provenance.redirects.length} 次重定向` : null,
70
+ provenance.truncated ? '已截断' : null,
71
+ ]).filter(Boolean)
72
+ lines.push(` ${facts.join(' · ')}`)
73
+ if (result.title) lines.push(` 标题:${result.title}`)
74
+ lines.push(` 正文 ${content.chars ?? 0} 字 · 置信度 ${content.confidence ?? 0}(${content.method ?? '-'})· ${content.blocks ?? 0} 块 · ${content.headings ?? 0} 标题 · ${content.tables ?? 0} 表格`)
75
+ if (content.spaSuspected) lines.push(' ⚠️ 疑似 SPA:不执行 JS,正文可能不全')
76
+ lines.push(` 去噪:${renderRemoved(result.removed)}`)
77
+
78
+ if (result.blocked === 'robots') {
79
+ lines.push('')
80
+ lines.push(' ⛔ robots.txt 禁止抓取,已按规则跳过(没有发出页面请求)')
81
+ }
82
+
83
+ const warnings = result.warnings ?? []
84
+ if (warnings.length > 0) {
85
+ lines.push(' 警告:')
86
+ for (const item of warnings.slice(0, maxWarnings)) lines.push(` - [${item.code}] ${item.message}`)
87
+ if (warnings.length > maxWarnings) lines.push(` - 还有 ${warnings.length - maxWarnings} 条(--json 看全量)`)
88
+ }
89
+ if (detail === 'full' && result.notes?.length > 0) {
90
+ lines.push(' 说明:')
91
+ for (const note of result.notes.slice(0, 5)) lines.push(` · ${note}`)
92
+ }
93
+
94
+ if (options.withMarkdown !== false) {
95
+ lines.push('')
96
+ lines.push('--- Markdown ---')
97
+ lines.push(result.markdown ?? '')
98
+ }
99
+ return lines.join('\n')
100
+ }
101
+
102
+ /**
103
+ * 渲染多页结果(只给摘要,正文在 --json 里)。
104
+ * @param {object} result 多页结果
105
+ * @param {object} [options] 选项
106
+ * @returns {string} 文本
107
+ */
108
+ function renderMulti(result, options = {}) {
109
+ const detail = options.detail ?? 'full'
110
+ const lines = []
111
+ lines.push(`dsh-scrape ${result.version} · 批量抓取 ${result.count}/${result.requested} 页`)
112
+ lines.push('')
113
+ lines.push(` ${padDisplay('状态', 6)} ${padDisplay('字数', 8)} ${padDisplay('置信度', 7)} ${padDisplay('标题', 34)} URL`)
114
+ for (const page of result.pages ?? []) {
115
+ const status = page.blocked ? '⛔robots' : (page.provenance?.httpStatus ?? '-')
116
+ const title = (page.title ?? '').slice(0, 32)
117
+ lines.push(` ${padDisplay(status, 6)} ${padDisplay(page.content?.chars ?? 0, 8)} ${padDisplay(page.content?.confidence ?? 0, 7)} ${padDisplay(title, 34)} ${page.provenance?.finalUrl ?? page.provenance?.requestedUrl ?? ''}`)
118
+ }
119
+ for (const failure of result.failures ?? []) {
120
+ lines.push(` ✗ ${failure.url} —— [${failure.error.code}] ${failure.error.message}`)
121
+ }
122
+ if (detail === 'full') {
123
+ const warnCount = (result.pages ?? []).reduce((sum, page) => sum + (page.warnings?.length ?? 0), 0)
124
+ lines.push('')
125
+ lines.push(` 提示:${warnCount} 条页面警告;正文请用 --json 逐页取 markdown 字段`)
126
+ }
127
+ return lines.join('\n')
128
+ }
129
+
130
+ /**
131
+ * 渲染 JSON。
132
+ * @param {object} result 结果
133
+ * @returns {string} 格式化 JSON
134
+ */
135
+ export function renderJson(result) {
136
+ return JSON.stringify(result, null, 2)
137
+ }