Compare commits
6 Commits
6eaef94a78
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| dc5ca95008 | |||
| 3227091c58 | |||
| c8f3d3a6ea | |||
| e939f0d2a1 | |||
| 39cf58df01 | |||
| 39ffe879f0 |
@ -18,12 +18,15 @@ export const en: Record<string, string> = {
|
|||||||
"get_inspection.param.id":
|
"get_inspection.param.id":
|
||||||
"The inspection_id or site_inspection_id to retrieve",
|
"The inspection_id or site_inspection_id to retrieve",
|
||||||
"get_issues.description":
|
"get_issues.description":
|
||||||
"Get a filtered list of issues found during an inspection. Filter by category and/or severity.",
|
"Get a filtered list of issues found during an inspection. Works with both single-page inspection_id and site_inspection_id. For site inspections, aggregates issues from all pages.",
|
||||||
"get_issues.param.id": "The inspection_id to get issues for",
|
"get_issues.param.id":
|
||||||
|
"The inspection_id or site_inspection_id to get issues for",
|
||||||
"get_issues.param.category":
|
"get_issues.param.category":
|
||||||
"Filter by category: html_css, accessibility, seo, or performance_security",
|
"Filter by category: html_css, accessibility, seo, or performance_security",
|
||||||
"get_issues.param.severity":
|
"get_issues.param.severity":
|
||||||
"Filter by severity: critical, major, minor, or info",
|
"Filter by severity: critical, major, minor, or info",
|
||||||
|
"get_issues.param.page_url":
|
||||||
|
"For site inspections: filter issues by page URL substring",
|
||||||
"get_history.description":
|
"get_history.description":
|
||||||
"List recent inspection history. Optionally filter by URL substring.",
|
"List recent inspection history. Optionally filter by URL substring.",
|
||||||
"get_history.param.url": "Optional URL substring to filter results",
|
"get_history.param.url": "Optional URL substring to filter results",
|
||||||
|
|||||||
@ -18,12 +18,15 @@ export const ko: Record<string, string> = {
|
|||||||
"get_inspection.param.id":
|
"get_inspection.param.id":
|
||||||
"조회할 inspection_id 또는 site_inspection_id",
|
"조회할 inspection_id 또는 site_inspection_id",
|
||||||
"get_issues.description":
|
"get_issues.description":
|
||||||
"검사에서 발견된 이슈 목록을 필터링하여 조회합니다. 카테고리 및 심각도로 필터링 가능.",
|
"검사에서 발견된 이슈 목록을 필터링하여 조회합니다. 단일 페이지 inspection_id와 site_inspection_id 모두 지원. 사이트 검사 시 모든 페이지의 이슈를 집계합니다.",
|
||||||
"get_issues.param.id": "이슈를 조회할 inspection_id",
|
"get_issues.param.id":
|
||||||
|
"이슈를 조회할 inspection_id 또는 site_inspection_id",
|
||||||
"get_issues.param.category":
|
"get_issues.param.category":
|
||||||
"카테고리 필터: html_css, accessibility, seo, performance_security",
|
"카테고리 필터: html_css, accessibility, seo, performance_security",
|
||||||
"get_issues.param.severity":
|
"get_issues.param.severity":
|
||||||
"심각도 필터: critical, major, minor, info",
|
"심각도 필터: critical, major, minor, info",
|
||||||
|
"get_issues.param.page_url":
|
||||||
|
"사이트 검사 시: 페이지 URL 부분 문자열로 이슈 필터링",
|
||||||
"get_history.description":
|
"get_history.description":
|
||||||
"최근 검사 이력을 조회합니다. URL로 필터링 가능.",
|
"최근 검사 이력을 조회합니다. URL로 필터링 가능.",
|
||||||
"get_history.param.url": "결과를 필터링할 URL (부분 문자열)",
|
"get_history.param.url": "결과를 필터링할 URL (부분 문자열)",
|
||||||
|
|||||||
112
mcp/src/index.ts
112
mcp/src/index.ts
@ -51,6 +51,118 @@ async function startHttp(server: ReturnType<typeof createServer>, port: number)
|
|||||||
res.writeHead(405).end(JSON.stringify({ error: "Session management not supported" }));
|
res.writeHead(405).end(JSON.stringify({ error: "Session management not supported" }));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// MCP Server Card (Smithery metadata)
|
||||||
|
app.get("/.well-known/mcp/server-card.json", (_req, res) => {
|
||||||
|
res.json({
|
||||||
|
serverInfo: { name: "web-inspector-mcp", version: "1.0.0" },
|
||||||
|
authentication: { required: false },
|
||||||
|
capabilities: {
|
||||||
|
tools: true,
|
||||||
|
resources: false,
|
||||||
|
prompts: false,
|
||||||
|
},
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
name: "inspect_page",
|
||||||
|
description:
|
||||||
|
"Inspect a single web page for HTML/CSS quality, accessibility (WCAG/KWCAG), SEO, and performance/security. Returns scores, grades, and top issues. Takes 10-30 seconds.",
|
||||||
|
annotations: { title: "Inspect Page", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
url: { type: "string", description: "The URL of the web page to inspect (must start with http:// or https://)" },
|
||||||
|
accessibility_standard: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["wcag_2.0_a", "wcag_2.0_aa", "wcag_2.1_aa", "wcag_2.2_aa", "kwcag_2.1", "kwcag_2.2"],
|
||||||
|
description: "Accessibility standard to check against (default: wcag_2.1_aa)",
|
||||||
|
},
|
||||||
|
language: { type: "string", enum: ["en", "ko"], description: "Response language: 'en' for English, 'ko' for Korean" },
|
||||||
|
},
|
||||||
|
required: ["url"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "inspect_site",
|
||||||
|
description:
|
||||||
|
"Start a site-wide crawl and inspection. Crawls links from the root URL and inspects each page. Returns a site_inspection_id for tracking. Use get_inspection to check results later.",
|
||||||
|
annotations: { title: "Inspect Site", readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
url: { type: "string", description: "Root URL of the site to crawl and inspect" },
|
||||||
|
max_pages: { type: "number", description: "Maximum number of pages to crawl (default: 20, max: 500, 0: unlimited)" },
|
||||||
|
max_depth: { type: "number", description: "Maximum link depth to crawl (default: 2, max: 3)" },
|
||||||
|
accessibility_standard: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["wcag_2.0_a", "wcag_2.0_aa", "wcag_2.1_aa", "wcag_2.2_aa", "kwcag_2.1", "kwcag_2.2"],
|
||||||
|
description: "Accessibility standard to check against (default: wcag_2.1_aa)",
|
||||||
|
},
|
||||||
|
language: { type: "string", enum: ["en", "ko"], description: "Response language: 'en' for English, 'ko' for Korean" },
|
||||||
|
},
|
||||||
|
required: ["url"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "get_inspection",
|
||||||
|
description: "Get detailed inspection results by inspection ID. Works for both single-page and site inspections.",
|
||||||
|
annotations: { title: "Get Inspection", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
id: { type: "string", description: "The inspection_id or site_inspection_id to retrieve" },
|
||||||
|
language: { type: "string", enum: ["en", "ko"], description: "Response language: 'en' for English, 'ko' for Korean" },
|
||||||
|
},
|
||||||
|
required: ["id"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "get_issues",
|
||||||
|
description:
|
||||||
|
"Get a filtered list of issues found during an inspection. Works with both single-page inspection_id and site_inspection_id. For site inspections, aggregates issues from all pages.",
|
||||||
|
annotations: { title: "Get Issues", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
id: { type: "string", description: "The inspection_id or site_inspection_id to get issues for" },
|
||||||
|
category: { type: "string", enum: ["html_css", "accessibility", "seo", "performance_security"], description: "Filter by category" },
|
||||||
|
severity: { type: "string", enum: ["critical", "major", "minor", "info"], description: "Filter by severity" },
|
||||||
|
page_url: { type: "string", description: "For site inspections: filter issues by page URL substring" },
|
||||||
|
language: { type: "string", enum: ["en", "ko"], description: "Response language: 'en' for English, 'ko' for Korean" },
|
||||||
|
},
|
||||||
|
required: ["id"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "get_history",
|
||||||
|
description: "List recent inspection history. Optionally filter by URL substring.",
|
||||||
|
annotations: { title: "Get History", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
url: { type: "string", description: "Optional URL substring to filter results" },
|
||||||
|
limit: { type: "number", description: "Number of results to return (default: 10)" },
|
||||||
|
language: { type: "string", enum: ["en", "ko"], description: "Response language: 'en' for English, 'ko' for Korean" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
resources: [],
|
||||||
|
prompts: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Server icon
|
||||||
|
app.get("/icon.svg", (_req, res) => {
|
||||||
|
res.type("image/svg+xml").send(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||||
|
<rect width="64" height="64" rx="14" fill="#1a1a2e"/>
|
||||||
|
<circle cx="32" cy="28" r="14" stroke="#00d2ff" stroke-width="2.5" fill="none"/>
|
||||||
|
<path d="M26 28l4 4 8-8" stroke="#00d2ff" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<rect x="14" y="48" width="36" height="3" rx="1.5" fill="#00d2ff" opacity="0.3"/>
|
||||||
|
<rect x="14" y="44" width="28" height="3" rx="1.5" fill="#00d2ff" opacity="0.5"/>
|
||||||
|
<rect x="14" y="52" width="20" height="3" rx="1.5" fill="#00d2ff" opacity="0.2"/>
|
||||||
|
</svg>`);
|
||||||
|
});
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
app.get("/health", (_req, res) => {
|
app.get("/health", (_req, res) => {
|
||||||
res.json({ status: "ok", transport: "http", api_url: API_URL });
|
res.json({ status: "ok", transport: "http", api_url: API_URL });
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||||||
import {
|
import {
|
||||||
CallToolRequestSchema,
|
CallToolRequestSchema,
|
||||||
|
ErrorCode,
|
||||||
ListToolsRequestSchema,
|
ListToolsRequestSchema,
|
||||||
|
McpError,
|
||||||
} from "@modelcontextprotocol/sdk/types.js";
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
import { ApiClient } from "./api-client.js";
|
import { ApiClient } from "./api-client.js";
|
||||||
import { t } from "./i18n/index.js";
|
import { t } from "./i18n/index.js";
|
||||||
@ -13,6 +15,25 @@ import { getHistory } from "./tools/get-history.js";
|
|||||||
|
|
||||||
type Lang = "en" | "ko";
|
type Lang = "en" | "ko";
|
||||||
|
|
||||||
|
// Common output schema for all tools (markdown text content)
|
||||||
|
const TEXT_OUTPUT = {
|
||||||
|
type: "object" as const,
|
||||||
|
properties: {
|
||||||
|
content: {
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
type: { type: "string", const: "text" },
|
||||||
|
text: { type: "string", description: "Markdown-formatted inspection result" },
|
||||||
|
},
|
||||||
|
required: ["type", "text"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["content"],
|
||||||
|
};
|
||||||
|
|
||||||
const STANDARDS = [
|
const STANDARDS = [
|
||||||
"wcag_2.0_a",
|
"wcag_2.0_a",
|
||||||
"wcag_2.0_aa",
|
"wcag_2.0_aa",
|
||||||
@ -27,7 +48,18 @@ export function createServer(apiUrl: string, defaultLang: Lang): Server {
|
|||||||
|
|
||||||
const server = new Server(
|
const server = new Server(
|
||||||
{ name: "web-inspector-mcp", version: "1.0.0" },
|
{ name: "web-inspector-mcp", version: "1.0.0" },
|
||||||
{ capabilities: { tools: {} } },
|
{
|
||||||
|
capabilities: { tools: {} },
|
||||||
|
instructions:
|
||||||
|
"Web Inspector MCP server analyzes web pages and websites for HTML/CSS quality, accessibility (WCAG 2.x / KWCAG 2.x), SEO, and performance/security.\n\n" +
|
||||||
|
"Typical workflow:\n" +
|
||||||
|
"1. Use `inspect_page` to audit a single URL (takes 10-30s, returns scores and top issues).\n" +
|
||||||
|
"2. Use `inspect_site` to start a site-wide crawl (returns an ID immediately; the crawl runs in background).\n" +
|
||||||
|
"3. Use `get_inspection` to retrieve detailed results by ID.\n" +
|
||||||
|
"4. Use `get_issues` to filter and drill into specific issues by category or severity.\n" +
|
||||||
|
"5. Use `get_history` to review past inspections.\n\n" +
|
||||||
|
"All tools accept a `language` parameter ('en' or 'ko') to control the response language.",
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── List tools ──────────────────────────────────────────
|
// ── List tools ──────────────────────────────────────────
|
||||||
@ -37,6 +69,14 @@ export function createServer(apiUrl: string, defaultLang: Lang): Server {
|
|||||||
{
|
{
|
||||||
name: "inspect_page",
|
name: "inspect_page",
|
||||||
description: t("inspect_page.description", defaultLang),
|
description: t("inspect_page.description", defaultLang),
|
||||||
|
annotations: {
|
||||||
|
title: "Inspect Page",
|
||||||
|
readOnlyHint: true,
|
||||||
|
destructiveHint: false,
|
||||||
|
idempotentHint: true,
|
||||||
|
openWorldHint: true,
|
||||||
|
},
|
||||||
|
outputSchema: TEXT_OUTPUT,
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object" as const,
|
type: "object" as const,
|
||||||
properties: {
|
properties: {
|
||||||
@ -61,6 +101,14 @@ export function createServer(apiUrl: string, defaultLang: Lang): Server {
|
|||||||
{
|
{
|
||||||
name: "inspect_site",
|
name: "inspect_site",
|
||||||
description: t("inspect_site.description", defaultLang),
|
description: t("inspect_site.description", defaultLang),
|
||||||
|
annotations: {
|
||||||
|
title: "Inspect Site",
|
||||||
|
readOnlyHint: true,
|
||||||
|
destructiveHint: false,
|
||||||
|
idempotentHint: false,
|
||||||
|
openWorldHint: true,
|
||||||
|
},
|
||||||
|
outputSchema: TEXT_OUTPUT,
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object" as const,
|
type: "object" as const,
|
||||||
properties: {
|
properties: {
|
||||||
@ -93,6 +141,14 @@ export function createServer(apiUrl: string, defaultLang: Lang): Server {
|
|||||||
{
|
{
|
||||||
name: "get_inspection",
|
name: "get_inspection",
|
||||||
description: t("get_inspection.description", defaultLang),
|
description: t("get_inspection.description", defaultLang),
|
||||||
|
annotations: {
|
||||||
|
title: "Get Inspection",
|
||||||
|
readOnlyHint: true,
|
||||||
|
destructiveHint: false,
|
||||||
|
idempotentHint: true,
|
||||||
|
openWorldHint: false,
|
||||||
|
},
|
||||||
|
outputSchema: TEXT_OUTPUT,
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object" as const,
|
type: "object" as const,
|
||||||
properties: {
|
properties: {
|
||||||
@ -112,6 +168,14 @@ export function createServer(apiUrl: string, defaultLang: Lang): Server {
|
|||||||
{
|
{
|
||||||
name: "get_issues",
|
name: "get_issues",
|
||||||
description: t("get_issues.description", defaultLang),
|
description: t("get_issues.description", defaultLang),
|
||||||
|
annotations: {
|
||||||
|
title: "Get Issues",
|
||||||
|
readOnlyHint: true,
|
||||||
|
destructiveHint: false,
|
||||||
|
idempotentHint: true,
|
||||||
|
openWorldHint: false,
|
||||||
|
},
|
||||||
|
outputSchema: TEXT_OUTPUT,
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object" as const,
|
type: "object" as const,
|
||||||
properties: {
|
properties: {
|
||||||
@ -134,6 +198,10 @@ export function createServer(apiUrl: string, defaultLang: Lang): Server {
|
|||||||
enum: ["critical", "major", "minor", "info"],
|
enum: ["critical", "major", "minor", "info"],
|
||||||
description: t("get_issues.param.severity", defaultLang),
|
description: t("get_issues.param.severity", defaultLang),
|
||||||
},
|
},
|
||||||
|
page_url: {
|
||||||
|
type: "string",
|
||||||
|
description: t("get_issues.param.page_url", defaultLang),
|
||||||
|
},
|
||||||
language: {
|
language: {
|
||||||
type: "string",
|
type: "string",
|
||||||
enum: ["en", "ko"],
|
enum: ["en", "ko"],
|
||||||
@ -146,6 +214,14 @@ export function createServer(apiUrl: string, defaultLang: Lang): Server {
|
|||||||
{
|
{
|
||||||
name: "get_history",
|
name: "get_history",
|
||||||
description: t("get_history.description", defaultLang),
|
description: t("get_history.description", defaultLang),
|
||||||
|
annotations: {
|
||||||
|
title: "Get History",
|
||||||
|
readOnlyHint: true,
|
||||||
|
destructiveHint: false,
|
||||||
|
idempotentHint: true,
|
||||||
|
openWorldHint: false,
|
||||||
|
},
|
||||||
|
outputSchema: TEXT_OUTPUT,
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object" as const,
|
type: "object" as const,
|
||||||
properties: {
|
properties: {
|
||||||
@ -174,6 +250,14 @@ export function createServer(apiUrl: string, defaultLang: Lang): Server {
|
|||||||
const { name, arguments: args = {} } = request.params;
|
const { name, arguments: args = {} } = request.params;
|
||||||
const lang: Lang = (args.language as Lang) || defaultLang;
|
const lang: Lang = (args.language as Lang) || defaultLang;
|
||||||
|
|
||||||
|
// Validate required params
|
||||||
|
if ((name === "inspect_page" || name === "inspect_site") && !args.url) {
|
||||||
|
throw new McpError(ErrorCode.InvalidParams, "Missing required parameter: url");
|
||||||
|
}
|
||||||
|
if ((name === "get_inspection" || name === "get_issues") && !args.id) {
|
||||||
|
throw new McpError(ErrorCode.InvalidParams, "Missing required parameter: id");
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let text: string;
|
let text: string;
|
||||||
|
|
||||||
@ -209,6 +293,7 @@ export function createServer(apiUrl: string, defaultLang: Lang): Server {
|
|||||||
lang,
|
lang,
|
||||||
args.category as string | undefined,
|
args.category as string | undefined,
|
||||||
args.severity as string | undefined,
|
args.severity as string | undefined,
|
||||||
|
args.page_url as string | undefined,
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@ -222,14 +307,12 @@ export function createServer(apiUrl: string, defaultLang: Lang): Server {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return {
|
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
|
||||||
content: [{ type: "text", text: `Unknown tool: ${name}` }],
|
|
||||||
isError: true,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { content: [{ type: "text", text }] };
|
return { content: [{ type: "text", text }] };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof McpError) throw error;
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: `Error: ${message}` }],
|
content: [{ type: "text", text: `Error: ${message}` }],
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { ApiClient } from "../api-client.js";
|
import { ApiClient, type Issue } from "../api-client.js";
|
||||||
import { t } from "../i18n/index.js";
|
import { t } from "../i18n/index.js";
|
||||||
|
|
||||||
export async function getIssues(
|
export async function getIssues(
|
||||||
@ -7,16 +7,155 @@ export async function getIssues(
|
|||||||
lang: "en" | "ko",
|
lang: "en" | "ko",
|
||||||
category?: string,
|
category?: string,
|
||||||
severity?: string,
|
severity?: string,
|
||||||
|
pageUrl?: string,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
|
// Try single-page first
|
||||||
|
try {
|
||||||
const result = await client.getIssues(id, category, severity);
|
const result = await client.getIssues(id, category, severity);
|
||||||
|
return formatSingleIssues(result.inspection_id, result.issues, result.total, result.filters, lang);
|
||||||
|
} catch {
|
||||||
|
// Might be a site inspection ID — aggregate issues from all pages
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await formatSiteIssues(client, id, lang, category, severity, pageUrl);
|
||||||
|
} catch {
|
||||||
|
return t("get_inspection.not_found", lang, { id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function formatSiteIssues(
|
||||||
|
client: ApiClient,
|
||||||
|
siteId: string,
|
||||||
|
lang: "en" | "ko",
|
||||||
|
category?: string,
|
||||||
|
severity?: string,
|
||||||
|
pageUrl?: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const site = await client.getSiteInspection(siteId);
|
||||||
|
|
||||||
|
// Collect pages with inspection results
|
||||||
|
let pages = site.discovered_pages.filter(
|
||||||
|
(p) => p.inspection_id && p.status === "completed",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Filter by page URL if specified
|
||||||
|
if (pageUrl) {
|
||||||
|
pages = pages.filter((p) => p.url.includes(pageUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pages.length === 0) {
|
||||||
|
const lines = [
|
||||||
|
`# ${t("get_issues.title", lang)} (Site)`,
|
||||||
|
`**Site**: ${site.root_url}`,
|
||||||
|
"",
|
||||||
|
"No completed page inspections found.",
|
||||||
|
];
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch issues from each page (parallel, max 10 concurrent)
|
||||||
|
type PageIssues = { url: string; issues: Issue[] };
|
||||||
|
const pageIssues: PageIssues[] = [];
|
||||||
|
|
||||||
|
const chunks = chunkArray(pages, 10);
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
chunk.map(async (p) => {
|
||||||
|
const r = await client.getIssues(p.inspection_id!, category, severity);
|
||||||
|
return { url: p.url, issues: r.issues } as PageIssues;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
for (const r of results) {
|
||||||
|
if (r.status === "fulfilled" && r.value.issues.length > 0) {
|
||||||
|
pageIssues.push(r.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggregate all issues
|
||||||
|
const allIssues = pageIssues.flatMap((p) =>
|
||||||
|
p.issues.map((i) => ({ ...i, _pageUrl: p.url })),
|
||||||
|
);
|
||||||
|
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
lines.push(`# ${t("get_issues.title", lang)}`);
|
lines.push(`# ${t("get_issues.title", lang)} (Site)`);
|
||||||
lines.push(`**Inspection ID**: ${result.inspection_id}`);
|
lines.push(`**Site**: ${site.root_url} (${site.domain})`);
|
||||||
lines.push(`**${t("get_issues.total", lang)}**: ${result.total}`);
|
lines.push(`**Pages**: ${pages.length}`);
|
||||||
|
lines.push(`**${t("get_issues.total", lang)}**: ${allIssues.length}`);
|
||||||
|
|
||||||
// Show applied filters
|
// Show applied filters
|
||||||
const activeFilters = Object.entries(result.filters)
|
const filters: string[] = [];
|
||||||
|
if (category) filters.push(`category: ${category}`);
|
||||||
|
if (severity) filters.push(`severity: ${severity}`);
|
||||||
|
if (pageUrl) filters.push(`page: ${pageUrl}`);
|
||||||
|
if (filters.length > 0) {
|
||||||
|
lines.push(`**${t("get_issues.filters", lang)}**: ${filters.join(", ")}`);
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
if (allIssues.length === 0) {
|
||||||
|
lines.push("No issues found.");
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary by severity
|
||||||
|
const sevCounts = { critical: 0, major: 0, minor: 0, info: 0 };
|
||||||
|
for (const i of allIssues) sevCounts[i.severity]++;
|
||||||
|
lines.push(
|
||||||
|
`**Summary**: Critical: ${sevCounts.critical}, Major: ${sevCounts.major}, Minor: ${sevCounts.minor}, Info: ${sevCounts.info}`,
|
||||||
|
);
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
// Per-page breakdown table
|
||||||
|
lines.push("## Pages");
|
||||||
|
lines.push("| Page | Issues | Critical | Major |");
|
||||||
|
lines.push("|---|---|---|---|");
|
||||||
|
for (const pi of pageIssues) {
|
||||||
|
const crit = pi.issues.filter((i) => i.severity === "critical").length;
|
||||||
|
const maj = pi.issues.filter((i) => i.severity === "major").length;
|
||||||
|
const shortUrl = pi.url.length > 60 ? pi.url.slice(0, 57) + "..." : pi.url;
|
||||||
|
lines.push(`| ${shortUrl} | ${pi.issues.length} | ${crit} | ${maj} |`);
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
// Top issues across all pages (critical + major first, max 15)
|
||||||
|
const sorted = allIssues
|
||||||
|
.sort((a, b) => sevOrder(a.severity) - sevOrder(b.severity))
|
||||||
|
.slice(0, 15);
|
||||||
|
|
||||||
|
lines.push("## Top Issues");
|
||||||
|
for (let i = 0; i < sorted.length; i++) {
|
||||||
|
const issue = sorted[i];
|
||||||
|
const shortUrl = issue._pageUrl.replace(site.root_url, "") || "/";
|
||||||
|
lines.push(
|
||||||
|
`### ${i + 1}. [${issue.severity.toUpperCase()}] ${issue.code} — ${shortUrl}`,
|
||||||
|
);
|
||||||
|
lines.push(`- **${t("result.message", lang)}**: ${issue.message}`);
|
||||||
|
if (issue.element) {
|
||||||
|
const el = issue.element.length > 120 ? issue.element.slice(0, 117) + "..." : issue.element;
|
||||||
|
lines.push(`- **${t("result.element", lang)}**: \`${el}\``);
|
||||||
|
}
|
||||||
|
lines.push(`- **${t("result.suggestion", lang)}**: ${issue.suggestion}`);
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSingleIssues(
|
||||||
|
inspectionId: string,
|
||||||
|
issues: Issue[],
|
||||||
|
total: number,
|
||||||
|
filters: Record<string, string | null>,
|
||||||
|
lang: "en" | "ko",
|
||||||
|
): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push(`# ${t("get_issues.title", lang)}`);
|
||||||
|
lines.push(`**Inspection ID**: ${inspectionId}`);
|
||||||
|
lines.push(`**${t("get_issues.total", lang)}**: ${total}`);
|
||||||
|
|
||||||
|
const activeFilters = Object.entries(filters)
|
||||||
.filter(([, v]) => v)
|
.filter(([, v]) => v)
|
||||||
.map(([k, v]) => `${k}: ${v}`)
|
.map(([k, v]) => `${k}: ${v}`)
|
||||||
.join(", ");
|
.join(", ");
|
||||||
@ -25,7 +164,7 @@ export async function getIssues(
|
|||||||
}
|
}
|
||||||
lines.push("");
|
lines.push("");
|
||||||
|
|
||||||
if (result.issues.length === 0) {
|
if (issues.length === 0) {
|
||||||
lines.push("No issues found.");
|
lines.push("No issues found.");
|
||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
@ -36,8 +175,8 @@ export async function getIssues(
|
|||||||
);
|
);
|
||||||
lines.push("|---|---|---|---|---|");
|
lines.push("|---|---|---|---|---|");
|
||||||
|
|
||||||
for (let i = 0; i < result.issues.length; i++) {
|
for (let i = 0; i < issues.length; i++) {
|
||||||
const issue = result.issues[i];
|
const issue = issues[i];
|
||||||
const msg =
|
const msg =
|
||||||
issue.message.length > 80
|
issue.message.length > 80
|
||||||
? issue.message.slice(0, 77) + "..."
|
? issue.message.slice(0, 77) + "..."
|
||||||
@ -49,10 +188,10 @@ export async function getIssues(
|
|||||||
lines.push("");
|
lines.push("");
|
||||||
|
|
||||||
// Detailed view for first 10
|
// Detailed view for first 10
|
||||||
const detailCount = Math.min(result.issues.length, 10);
|
const detailCount = Math.min(issues.length, 10);
|
||||||
lines.push("## Details");
|
lines.push("## Details");
|
||||||
for (let i = 0; i < detailCount; i++) {
|
for (let i = 0; i < detailCount; i++) {
|
||||||
const issue = result.issues[i];
|
const issue = issues[i];
|
||||||
lines.push(`### ${i + 1}. [${issue.severity.toUpperCase()}] ${issue.code}`);
|
lines.push(`### ${i + 1}. [${issue.severity.toUpperCase()}] ${issue.code}`);
|
||||||
lines.push(`- **${t("result.message", lang)}**: ${issue.message}`);
|
lines.push(`- **${t("result.message", lang)}**: ${issue.message}`);
|
||||||
if (issue.element) {
|
if (issue.element) {
|
||||||
@ -69,3 +208,15 @@ export async function getIssues(
|
|||||||
|
|
||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sevOrder(s: string): number {
|
||||||
|
return { critical: 0, major: 1, minor: 2, info: 3 }[s] ?? 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
function chunkArray<T>(arr: T[], size: number): T[][] {
|
||||||
|
const chunks: T[][] = [];
|
||||||
|
for (let i = 0; i < arr.length; i += size) {
|
||||||
|
chunks.push(arr.slice(i, i + size));
|
||||||
|
}
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|||||||
@ -13,10 +13,11 @@ export async function inspectPage(
|
|||||||
// 1. Start inspection
|
// 1. Start inspection
|
||||||
const { inspection_id } = await client.startInspection(url, standard);
|
const { inspection_id } = await client.startInspection(url, standard);
|
||||||
|
|
||||||
// 2. Poll until completion
|
// 2. Poll until completion (404 = not yet saved, keep polling)
|
||||||
let result: InspectionResult | null = null;
|
let result: InspectionResult | null = null;
|
||||||
for (let i = 0; i < MAX_POLLS; i++) {
|
for (let i = 0; i < MAX_POLLS; i++) {
|
||||||
await sleep(POLL_INTERVAL);
|
await sleep(POLL_INTERVAL);
|
||||||
|
try {
|
||||||
const data = await client.getInspection(inspection_id);
|
const data = await client.getInspection(inspection_id);
|
||||||
if (data.status === "completed") {
|
if (data.status === "completed") {
|
||||||
result = data;
|
result = data;
|
||||||
@ -25,6 +26,13 @@ export async function inspectPage(
|
|||||||
if (data.status === "error") {
|
if (data.status === "error") {
|
||||||
throw new Error(`Inspection failed for ${url}`);
|
throw new Error(`Inspection failed for ${url}`);
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// 404 or transient error — keep polling
|
||||||
|
if (err instanceof Error && err.message.includes("failed")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Timeout — return partial info
|
// 3. Timeout — return partial info
|
||||||
|
|||||||
Reference in New Issue
Block a user