fe-inspector-mcp 0.2.0 → 0.3.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/dist/index.js +5 -1
- package/dist/inspector/a11y.js +3 -10
- package/dist/inspector/aesthetic.js +3 -10
- package/dist/inspector/index.js +8 -0
- package/dist/inspector/interaction.js +3 -10
- package/dist/inspector/performance.d.ts +8 -0
- package/dist/inspector/performance.js +157 -0
- package/dist/inspector/responsive.js +3 -10
- package/dist/types.d.ts +4 -1
- package/dist/utils/dom.d.ts +7 -0
- package/dist/utils/dom.js +13 -0
- package/dist/utils/parser.d.ts +31 -0
- package/dist/utils/parser.js +56 -0
- package/package.json +6 -2
package/dist/index.js
CHANGED
|
@@ -2,10 +2,14 @@
|
|
|
2
2
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
3
3
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
4
|
import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
5
|
+
import { createRequire } from 'module';
|
|
5
6
|
import { runInspection, getInspectorMeta } from './inspector/index.js';
|
|
6
7
|
import { generateTextReport, generateJsonReport } from './reporter/index.js';
|
|
7
8
|
import { reviewDesign, formatReview } from './design-review.js';
|
|
8
|
-
|
|
9
|
+
// 从 package.json 动态读取版本号
|
|
10
|
+
const require = createRequire(import.meta.url);
|
|
11
|
+
const pkg = require('../package.json');
|
|
12
|
+
const { version, name } = { version: pkg.version, name: pkg.name };
|
|
9
13
|
const server = new Server({ name, version }, { capabilities: { tools: {} } });
|
|
10
14
|
// ── Tool definitions ──
|
|
11
15
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
package/dist/inspector/a11y.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { parseHtml } from '../utils/parser.js';
|
|
2
|
+
import { buildSelector } from '../utils/dom.js';
|
|
2
3
|
const category = 'a11y';
|
|
3
4
|
const rules = [
|
|
4
5
|
{
|
|
@@ -118,16 +119,8 @@ const rules = [
|
|
|
118
119
|
},
|
|
119
120
|
},
|
|
120
121
|
];
|
|
121
|
-
function buildSelector($el) {
|
|
122
|
-
const tag = typeof $el.prop === 'function' ? ($el.prop('tagName')?.toLowerCase() || 'div') : 'div';
|
|
123
|
-
const id = $el.attr?.('id');
|
|
124
|
-
if (id)
|
|
125
|
-
return `#${id}`;
|
|
126
|
-
const cls = ($el.attr?.('class') || '').split(/\s+/).filter(Boolean).slice(0, 2).join('.');
|
|
127
|
-
return cls ? `${tag}.${cls}` : tag;
|
|
128
|
-
}
|
|
129
122
|
export function inspectA11y(html) {
|
|
130
|
-
const $ =
|
|
123
|
+
const $ = parseHtml(html);
|
|
131
124
|
const results = [];
|
|
132
125
|
for (const rule of rules) {
|
|
133
126
|
results.push(...rule.check($));
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { parseHtml } from '../utils/parser.js';
|
|
2
|
+
import { buildSelector } from '../utils/dom.js';
|
|
2
3
|
const category = 'aesthetic';
|
|
3
4
|
const rules = [
|
|
4
5
|
{
|
|
@@ -122,16 +123,8 @@ const rules = [
|
|
|
122
123
|
},
|
|
123
124
|
},
|
|
124
125
|
];
|
|
125
|
-
function buildSelector($el) {
|
|
126
|
-
const tag = typeof $el.prop === 'function' ? ($el.prop('tagName')?.toLowerCase() || 'div') : 'div';
|
|
127
|
-
const id = $el.attr?.('id');
|
|
128
|
-
if (id)
|
|
129
|
-
return `#${id}`;
|
|
130
|
-
const cls = ($el.attr?.('class') || '').split(/\s+/).filter(Boolean).slice(0, 2).join('.');
|
|
131
|
-
return cls ? `${tag}.${cls}` : tag;
|
|
132
|
-
}
|
|
133
126
|
export function inspectAesthetic(html) {
|
|
134
|
-
const $ =
|
|
127
|
+
const $ = parseHtml(html);
|
|
135
128
|
const results = [];
|
|
136
129
|
for (const rule of rules) {
|
|
137
130
|
results.push(...rule.check($));
|
package/dist/inspector/index.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { parseHtml } from '../utils/parser.js';
|
|
1
2
|
import { inspectAesthetic, aestheticMeta } from './aesthetic.js';
|
|
2
3
|
import { inspectA11y, a11yMeta } from './a11y.js';
|
|
3
4
|
import { inspectResponsive, responsiveMeta } from './responsive.js';
|
|
4
5
|
import { inspectInteraction, interactionMeta } from './interaction.js';
|
|
6
|
+
import { inspectPerformance, performanceMeta } from './performance.js';
|
|
5
7
|
const inspectors = {
|
|
6
8
|
aesthetic: {
|
|
7
9
|
...aestheticMeta,
|
|
@@ -19,10 +21,16 @@ const inspectors = {
|
|
|
19
21
|
...interactionMeta,
|
|
20
22
|
inspect: inspectInteraction,
|
|
21
23
|
},
|
|
24
|
+
performance: {
|
|
25
|
+
...performanceMeta,
|
|
26
|
+
inspect: inspectPerformance,
|
|
27
|
+
},
|
|
22
28
|
};
|
|
23
29
|
export function runInspection(input) {
|
|
24
30
|
const { html, url } = input;
|
|
25
31
|
const categories = input.categories || Object.keys(inspectors);
|
|
32
|
+
// 使用缓存解析 HTML,避免重复解析
|
|
33
|
+
const $ = parseHtml(html);
|
|
26
34
|
const categoryResults = {};
|
|
27
35
|
let total = 0;
|
|
28
36
|
let passed = 0;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { parseHtml } from '../utils/parser.js';
|
|
2
|
+
import { buildSelector } from '../utils/dom.js';
|
|
2
3
|
const category = 'interaction';
|
|
3
4
|
const rules = [
|
|
4
5
|
{
|
|
@@ -117,16 +118,8 @@ const rules = [
|
|
|
117
118
|
},
|
|
118
119
|
},
|
|
119
120
|
];
|
|
120
|
-
function buildSelector($el) {
|
|
121
|
-
const tag = typeof $el.prop === 'function' ? ($el.prop('tagName')?.toLowerCase() || 'div') : 'div';
|
|
122
|
-
const id = $el.attr?.('id');
|
|
123
|
-
if (id)
|
|
124
|
-
return `#${id}`;
|
|
125
|
-
const cls = ($el.attr?.('class') || '').split(/\s+/).filter(Boolean).slice(0, 2).join('.');
|
|
126
|
-
return cls ? `${tag}.${cls}` : tag;
|
|
127
|
-
}
|
|
128
121
|
export function inspectInteraction(html) {
|
|
129
|
-
const $ =
|
|
122
|
+
const $ = parseHtml(html);
|
|
130
123
|
const results = [];
|
|
131
124
|
for (const rule of rules) {
|
|
132
125
|
results.push(...rule.check($));
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { parseHtml } from '../utils/parser.js';
|
|
2
|
+
import { buildSelector } from '../utils/dom.js';
|
|
3
|
+
const category = 'performance';
|
|
4
|
+
const rules = [
|
|
5
|
+
{
|
|
6
|
+
id: 'pf-001',
|
|
7
|
+
title: 'Large image without width/height',
|
|
8
|
+
check: ($) => {
|
|
9
|
+
const results = [];
|
|
10
|
+
$('img').each((_i, el) => {
|
|
11
|
+
const width = $(el).attr('width');
|
|
12
|
+
const height = $(el).attr('height');
|
|
13
|
+
if (!width && !height) {
|
|
14
|
+
results.push({
|
|
15
|
+
ruleId: 'pf-001',
|
|
16
|
+
severity: 'warning',
|
|
17
|
+
title: 'Image missing dimensions',
|
|
18
|
+
message: 'Image has no width/height attributes — causes layout shift (CLS).',
|
|
19
|
+
element: buildSelector($(el)),
|
|
20
|
+
recommendation: 'Add explicit width and height attributes to prevent Cumulative Layout Shift.',
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
return results;
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
id: 'pf-002',
|
|
29
|
+
title: 'Render-blocking external CSS/JS in <head>',
|
|
30
|
+
check: ($) => {
|
|
31
|
+
const results = [];
|
|
32
|
+
// Check for render-blocking stylesheets
|
|
33
|
+
$('head link[rel="stylesheet"]').each((_i, el) => {
|
|
34
|
+
const href = $(el).attr('href');
|
|
35
|
+
const media = $(el).attr('media');
|
|
36
|
+
if (href && !media) {
|
|
37
|
+
results.push({
|
|
38
|
+
ruleId: 'pf-002',
|
|
39
|
+
severity: 'warning',
|
|
40
|
+
title: 'Render-blocking stylesheet',
|
|
41
|
+
message: `Stylesheet ${href} blocks rendering.`,
|
|
42
|
+
element: buildSelector($(el)),
|
|
43
|
+
recommendation: 'Use media="print" for non-critical CSS, or load asynchronously.',
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
// Check for render-blocking scripts
|
|
48
|
+
$('head script[src]').each((_i, el) => {
|
|
49
|
+
const defer = $(el).attr('defer');
|
|
50
|
+
const async = $(el).attr('async');
|
|
51
|
+
if (!defer && !async) {
|
|
52
|
+
results.push({
|
|
53
|
+
ruleId: 'pf-002',
|
|
54
|
+
severity: 'warning',
|
|
55
|
+
title: 'Render-blocking script',
|
|
56
|
+
message: 'Script in <head> without defer/async blocks rendering.',
|
|
57
|
+
element: buildSelector($(el)),
|
|
58
|
+
recommendation: 'Add defer or async attribute to non-critical scripts.',
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
return results;
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
id: 'pf-003',
|
|
67
|
+
title: 'Image not optimized (no loading=lazy below fold)',
|
|
68
|
+
check: ($) => {
|
|
69
|
+
const results = [];
|
|
70
|
+
const images = $('img').toArray();
|
|
71
|
+
// Skip first 2 images (likely above fold)
|
|
72
|
+
images.slice(2).forEach((el, index) => {
|
|
73
|
+
const loading = $(el).attr('loading');
|
|
74
|
+
if (!loading || loading !== 'lazy') {
|
|
75
|
+
results.push({
|
|
76
|
+
ruleId: 'pf-003',
|
|
77
|
+
severity: 'info',
|
|
78
|
+
title: 'Below-fold image missing lazy loading',
|
|
79
|
+
message: `Image #${index + 3} should use loading="lazy".`,
|
|
80
|
+
element: buildSelector($(el)),
|
|
81
|
+
recommendation: 'Add loading="lazy" to images below the fold for faster initial load.',
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
return results;
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
id: 'pf-004',
|
|
90
|
+
title: 'Too many HTTP requests (css/js/img count)',
|
|
91
|
+
check: ($) => {
|
|
92
|
+
const results = [];
|
|
93
|
+
const cssCount = $('link[href][rel="stylesheet"]').length;
|
|
94
|
+
const jsCount = $('script[src]').length;
|
|
95
|
+
const imgCount = $('img[src]').length;
|
|
96
|
+
const total = cssCount + jsCount + imgCount;
|
|
97
|
+
if (total > 30) {
|
|
98
|
+
results.push({
|
|
99
|
+
ruleId: 'pf-004',
|
|
100
|
+
severity: 'info',
|
|
101
|
+
title: 'Too many HTTP requests',
|
|
102
|
+
message: `Found ${total} external resources (${cssCount} CSS, ${jsCount} JS, ${imgCount} images).`,
|
|
103
|
+
element: 'head',
|
|
104
|
+
recommendation: 'Bundle CSS/JS files and use image sprites or lazy loading to reduce requests.',
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
return results;
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
id: 'pf-005',
|
|
112
|
+
title: 'DOM depth too deep (> 15 levels)',
|
|
113
|
+
check: ($) => {
|
|
114
|
+
const results = [];
|
|
115
|
+
function getMaxDepth(el, currentDepth) {
|
|
116
|
+
let maxDepth = currentDepth;
|
|
117
|
+
const children = $(el).children().toArray();
|
|
118
|
+
for (const child of children) {
|
|
119
|
+
const childDepth = getMaxDepth(child, currentDepth + 1);
|
|
120
|
+
if (childDepth > maxDepth) {
|
|
121
|
+
maxDepth = childDepth;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return maxDepth;
|
|
125
|
+
}
|
|
126
|
+
const body = $('body').get(0);
|
|
127
|
+
if (body) {
|
|
128
|
+
const depth = getMaxDepth(body, 0);
|
|
129
|
+
if (depth > 15) {
|
|
130
|
+
results.push({
|
|
131
|
+
ruleId: 'pf-005',
|
|
132
|
+
severity: 'info',
|
|
133
|
+
title: 'DOM depth too deep',
|
|
134
|
+
message: `DOM nesting depth is ${depth} levels (max recommended: 15).`,
|
|
135
|
+
element: 'body',
|
|
136
|
+
recommendation: 'Flatten HTML structure — deep nesting slows rendering and hurts maintainability.',
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return results;
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
];
|
|
144
|
+
export function inspectPerformance(html) {
|
|
145
|
+
const $ = parseHtml(html);
|
|
146
|
+
const results = [];
|
|
147
|
+
for (const rule of rules) {
|
|
148
|
+
results.push(...rule.check($));
|
|
149
|
+
}
|
|
150
|
+
return results;
|
|
151
|
+
}
|
|
152
|
+
export const performanceMeta = {
|
|
153
|
+
category,
|
|
154
|
+
label: 'Performance',
|
|
155
|
+
description: 'Page speed — image optimization, render blocking, DOM depth, HTTP requests',
|
|
156
|
+
ruleCount: rules.length,
|
|
157
|
+
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { parseHtml } from '../utils/parser.js';
|
|
2
|
+
import { buildSelector } from '../utils/dom.js';
|
|
2
3
|
const category = 'responsive';
|
|
3
4
|
const rules = [
|
|
4
5
|
{
|
|
@@ -125,16 +126,8 @@ const rules = [
|
|
|
125
126
|
},
|
|
126
127
|
},
|
|
127
128
|
];
|
|
128
|
-
function buildSelector($el) {
|
|
129
|
-
const tag = typeof $el.prop === 'function' ? ($el.prop('tagName')?.toLowerCase() || 'div') : 'div';
|
|
130
|
-
const id = $el.attr?.('id');
|
|
131
|
-
if (id)
|
|
132
|
-
return `#${id}`;
|
|
133
|
-
const cls = ($el.attr?.('class') || '').split(/\s+/).filter(Boolean).slice(0, 2).join('.');
|
|
134
|
-
return cls ? `${tag}.${cls}` : tag;
|
|
135
|
-
}
|
|
136
129
|
export function inspectResponsive(html) {
|
|
137
|
-
const $ =
|
|
130
|
+
const $ = parseHtml(html);
|
|
138
131
|
const results = [];
|
|
139
132
|
for (const rule of rules) {
|
|
140
133
|
results.push(...rule.check($));
|
package/dist/types.d.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
export type Severity = 'error' | 'warning' | 'info';
|
|
2
|
+
export type CheerioElement = any;
|
|
3
|
+
export type CheerioSelection = any;
|
|
4
|
+
export type CheerioAPI = any;
|
|
2
5
|
export interface Detection {
|
|
3
6
|
ruleId: string;
|
|
4
7
|
severity: Severity;
|
|
@@ -7,7 +10,7 @@ export interface Detection {
|
|
|
7
10
|
element?: string;
|
|
8
11
|
recommendation: string;
|
|
9
12
|
}
|
|
10
|
-
export type InspectCategory = 'aesthetic' | 'a11y' | 'responsive' | 'interaction';
|
|
13
|
+
export type InspectCategory = 'aesthetic' | 'a11y' | 'responsive' | 'interaction' | 'performance';
|
|
11
14
|
export interface InspectReport {
|
|
12
15
|
url?: string;
|
|
13
16
|
totalElements: number;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 构建元素的 CSS 选择器
|
|
3
|
+
* @param $el Cheerio 元素对象
|
|
4
|
+
* @returns CSS 选择器字符串
|
|
5
|
+
*/
|
|
6
|
+
export function buildSelector($el) {
|
|
7
|
+
const tag = typeof $el.prop === 'function' ? ($el.prop('tagName')?.toLowerCase() || 'div') : 'div';
|
|
8
|
+
const id = $el.attr?.('id');
|
|
9
|
+
if (id)
|
|
10
|
+
return `#${id}`;
|
|
11
|
+
const cls = ($el.attr?.('class') || '').split(/\s+/).filter(Boolean).slice(0, 2).join('.');
|
|
12
|
+
return cls ? `${tag}.${cls}` : tag;
|
|
13
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML 解析缓存
|
|
3
|
+
* 避免同一个 HTML 字符串被多次解析
|
|
4
|
+
*/
|
|
5
|
+
declare class ParserCache {
|
|
6
|
+
private cache;
|
|
7
|
+
private maxSize;
|
|
8
|
+
constructor(maxSize?: number);
|
|
9
|
+
/**
|
|
10
|
+
* 获取解析后的 Cheerio 实例
|
|
11
|
+
* @param html HTML 字符串
|
|
12
|
+
* @returns CheerioAPI 实例
|
|
13
|
+
*/
|
|
14
|
+
parse(html: string): any;
|
|
15
|
+
/**
|
|
16
|
+
* 清空缓存
|
|
17
|
+
*/
|
|
18
|
+
clear(): void;
|
|
19
|
+
/**
|
|
20
|
+
* 获取缓存大小
|
|
21
|
+
*/
|
|
22
|
+
get size(): number;
|
|
23
|
+
}
|
|
24
|
+
export declare const parserCache: ParserCache;
|
|
25
|
+
/**
|
|
26
|
+
* 解析 HTML 并缓存结果
|
|
27
|
+
* @param html HTML 字符串
|
|
28
|
+
* @returns CheerioAPI 实例
|
|
29
|
+
*/
|
|
30
|
+
export declare function parseHtml(html: string): any;
|
|
31
|
+
export {};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// ── HTML 解析缓存模块 ──
|
|
2
|
+
import { load } from 'cheerio';
|
|
3
|
+
/**
|
|
4
|
+
* HTML 解析缓存
|
|
5
|
+
* 避免同一个 HTML 字符串被多次解析
|
|
6
|
+
*/
|
|
7
|
+
class ParserCache {
|
|
8
|
+
cache = new Map();
|
|
9
|
+
maxSize;
|
|
10
|
+
constructor(maxSize = 10) {
|
|
11
|
+
this.maxSize = maxSize;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* 获取解析后的 Cheerio 实例
|
|
15
|
+
* @param html HTML 字符串
|
|
16
|
+
* @returns CheerioAPI 实例
|
|
17
|
+
*/
|
|
18
|
+
parse(html) {
|
|
19
|
+
const cached = this.cache.get(html);
|
|
20
|
+
if (cached) {
|
|
21
|
+
return cached;
|
|
22
|
+
}
|
|
23
|
+
// 如果缓存已满,删除最旧的条目
|
|
24
|
+
if (this.cache.size >= this.maxSize) {
|
|
25
|
+
const firstKey = this.cache.keys().next().value;
|
|
26
|
+
if (firstKey !== undefined) {
|
|
27
|
+
this.cache.delete(firstKey);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const $ = load(html);
|
|
31
|
+
this.cache.set(html, $);
|
|
32
|
+
return $;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* 清空缓存
|
|
36
|
+
*/
|
|
37
|
+
clear() {
|
|
38
|
+
this.cache.clear();
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* 获取缓存大小
|
|
42
|
+
*/
|
|
43
|
+
get size() {
|
|
44
|
+
return this.cache.size;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// 导出全局缓存实例
|
|
48
|
+
export const parserCache = new ParserCache();
|
|
49
|
+
/**
|
|
50
|
+
* 解析 HTML 并缓存结果
|
|
51
|
+
* @param html HTML 字符串
|
|
52
|
+
* @returns CheerioAPI 实例
|
|
53
|
+
*/
|
|
54
|
+
export function parseHtml(html) {
|
|
55
|
+
return parserCache.parse(html);
|
|
56
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fe-inspector-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "MCP Server for frontend quality inspection — catches aesthetic flaws, a11y issues, responsive breaks, and interaction bugs in AI-generated HTML.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -15,6 +15,9 @@
|
|
|
15
15
|
"build": "tsc",
|
|
16
16
|
"start": "node dist/index.js",
|
|
17
17
|
"dev": "tsx src/index.ts",
|
|
18
|
+
"test": "vitest run",
|
|
19
|
+
"test:watch": "vitest",
|
|
20
|
+
"test:coverage": "vitest run --coverage",
|
|
18
21
|
"prepublishOnly": "npm run build"
|
|
19
22
|
},
|
|
20
23
|
"keywords": [
|
|
@@ -38,6 +41,7 @@
|
|
|
38
41
|
"@types/cheerio": "^0.22.35",
|
|
39
42
|
"@types/node": "^22.20.1",
|
|
40
43
|
"tsx": "^4.19.0",
|
|
41
|
-
"typescript": "^5.7.0"
|
|
44
|
+
"typescript": "^5.7.0",
|
|
45
|
+
"vitest": "^4.1.10"
|
|
42
46
|
}
|
|
43
47
|
}
|