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:
jungwoo choi
2026-02-15 09:12:18 +09:00
parent 39ffe879f0
commit 39cf58df01
4 changed files with 177 additions and 15 deletions

View File

@ -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",

View File

@ -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 (부분 문자열)",

View File

@ -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;

View File

@ -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> {
const result = await client.getIssues(id, category, severity); // Try single-page first
try {
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;
}