← GPT-AI-LP
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),
  };
}