CODE · 5.6 KB
api/quality-gate.js
Workspace snapshot · 08/31 23:51
import { contentModelIssues, normalizeContentModel } from "../src/section-content.js";
const text = (value) => String(value || "").trim();
const array = (value) => (Array.isArray(value) ? value : []);
const normalizeType = (section = {}) =>
text(section.type || section.role || section.label).toLowerCase();
export function auditWireframe(sections, answers = {}, qualityGate = {}) {
const list = array(sections);
const types = new Set(list.map(normalizeType));
const ctaCount = list.filter((section) => text(section.cta) && section.cta !== "なし").length;
const hero = list[0] || {};
const formFields = text(answers.formFields)
.split(/[,、\n]/u)
.map((item) => item.trim())
.filter(Boolean);
const hasType = (...needles) =>
[...types].some((type) => needles.some((needle) => type.includes(needle)));
const hasSectionText = (pattern) =>
list.some((section) => pattern.test([
section.title,
section.body,
section.content,
section.implementation,
].map(text).join(" ")));
const faqSections = list.filter((section) => normalizeType(section).includes("faq"));
const hasStructuredFaqAnswers = faqSections.length > 0 && faqSections.every((section) => {
const typed = normalizeContentModel(section.contentModel).faqs;
if (typed.length >= 2) return true;
const content = text(section.content);
return /(?:^|\n)\s*Q[::]/u.test(content) && /(?:^|\n)\s*A[::]/u.test(content);
});
const typedContentIssues = list.flatMap((section) =>
Object.hasOwn(section, "contentModel")
? contentModelIssues(section).map((issue) => `${section.id}:${issue}`)
: [],
);
const isExternalEndpoint = (value) => /^https:\/\//iu.test(text(value)) && !/\.invalid(?:\/|$)/iu.test(text(value));
const unconfirmed = array(qualityGate.unconfirmed).map(text).join(" ");
const priceIsUnconfirmed = /価格|料金|プラン|契約期間/iu.test(unconfirmed);
const checks = [
["section-depth", "必要な情報量が揃っている", list.length >= 12 && list.length <= 22],
["single-hero", "最初の画面に対象・便益・行動がある", Boolean(text(hero.title) && text(hero.body) && text(hero.cta))],
["hero-visual", "メインビジュアルの生成指示がある", Boolean(hero.imageBrief && text(hero.imageBrief.subject) && text(hero.imageBrief.composition))],
["section-intent", "各セクションに目的と読者の疑問がある", list.every((section) => text(section.role) && text(section.userQuestion))],
["section-content", "各セクションに具体内容と実装条件がある", list.every((section) => text(section.content) && text(section.implementation))],
["typed-content", "表示部品が型付きの実データで埋まっている", typedContentIssues.length === 0],
["heading-length", "見出しが画面内で読める長さ", list.every((section) => text(section.title).length > 0 && text(section.title).length < 48)],
["cta-cadence", "固定CTAと検討途中の行動導線がある", ctaCount >= Math.max(3, Math.ceil(list.length / 5))],
["competition", "競合・代替の入力を比較へ反映", !text(answers.competition) || hasType("comparison", "比較")],
["pricing", "確認済みの価格情報を価格セクションへ反映", !text(answers.priceAmount) || priceIsUnconfirmed || hasType("pricing", "price", "料金")],
["proof", "証拠の入力を実績・信頼へ反映", !text(answers.evidenceDetails) || hasType("proof", "trust", "case", "実績", "事例")],
["form-fields", "CVフォーム項目が定義されている", formFields.length >= 2],
["form-endpoint", "フォーム送信先が定義されている", isExternalEndpoint(answers.formEndpoint)],
["privacy", "個人情報の取り扱いURLが定義されている", /^https:\/\//iu.test(text(answers.privacyUrl))],
["formal-name", "会社名・サービス名の正式表記がある", Boolean(text(answers.formalCompanyName) && text(answers.formalServiceName))],
["brand-assets", "利用可能なブランド資産が明記されている", Boolean(text(answers.brandAssets))],
["compliance", "法務・NG表現・除外対象が明記されている", Boolean(text(answers.complianceSettings || answers.compliance) && text(answers.forbiddenClaims) && text(answers.excludedAudience))],
["measurement", "CV定義と計測条件がある", Boolean(text(answers.conversionDefinition))],
["faq", "検討阻害を解消するFAQがある", hasType("faq") || hasSectionText(/よくある質問|FAQ/iu)],
["faq-answers", "FAQに質問ごとの具体回答がある", hasStructuredFaqAnswers],
].map(([id, label, pass]) => ({ id, label, pass: Boolean(pass), mode: "automatic" }));
const humanChecks = [
{ id: "claim-evidence", label: "数値・実績・顧客の声が一次資料と一致するか", reason: "根拠資料との照合が必要" },
{ id: "journey-order", label: "流入元からCVまで読者心理の順番が自然か", reason: "案件ごとの判断が必要" },
{ id: "legal-review", label: "業種固有の法令・表示・同意要件を満たすか", reason: "公開前の人間確認が必要" },
{ id: "visual-rights", label: "画像・ロゴ・フォントの利用権限があるか", reason: "権利資料との照合が必要" },
{ id: "form-e2e", label: "本番相当環境でフォーム送信と通知を確認したか", reason: "外部送信先を含む実機確認が必要" },
];
return {
checks,
humanChecks,
passed: checks.filter((check) => check.pass).length,
total: checks.length,
mustViolations: checks.filter((check) => !check.pass),
};
}