feat: get_issues에 사이트 검사 이슈 집계 지원
- site_inspection_id를 받으면 모든 페이지의 이슈를 자동 수집/집계 - page_url 파라미터로 특정 페이지 필터링 가능 - 페이지별 이슈 수 테이블 + 심각도순 상위 이슈 15개 표시 - i18n 설명 업데이트 (en/ko) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@ -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 (부분 문자열)",
|
||||||
|
|||||||
@ -134,6 +134,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"],
|
||||||
@ -209,6 +213,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;
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user