CLOVER🍀

That was when it all began.

W3C Markup Validation ServiceとW3C CSS Validation Serviceを使ったチェックを、自動化しよう

世の中には、W3C Markup Validation Serviceと、W3C CSS Validation ServiceというHTMLやCSSが妥当な形式で書かれているかをチェックしてくれるサービスがあります。

W3C Markup Validation Service
http://validator.w3.org/

W3C CSS Validation Service
http://jigsaw.w3.org/css-validator/

Webページのデザインを考え、それをHTMLやCSSで表現することは大事ですが、書かれたHTMLやCSSが妥当なものかどうかを確認するのも、実装品質として確認すべき項目だと思います。

で、今の仕事でデザイン会社からもらうHTMLやCSSを確認しているのですが、あまりにこのあたりのデキがひどくて(デザインとしても崩れたりするので、そういう面でもアレなのですが…)、手でチェックするのがバカバカしくなってきたので、入力されたディレクトリ配下のHTMLやCSSをスキャンして機械的にValidation Serviceで検証するスクリプトを書いてみました。

Groovy+HTTPBuilder+JSoupです。

なお、今回は簡単のためにそれぞれのServiceのサイトそのものを使用しましたが、できれば自分が使う環境に、このServiceを構築した方がよいかなと。構築手順も公開されていたはずです。

W3C Markup Validation Serviceを使ったHTML検証

まずは、W3C Markup Validation Serviceを使ってHTML検証をやってみます。

Web上で検証する時は、「Direct Input」を使用して、検証するHTMLをテキストエリアに
貼り付けました。

一応、検証はHTML5で行います。

この画像には写っていませんが、下にいくと「Check」というボタンがあるので、こちらを押すとValidなHTMLなら緑の帯で「This document was successfully checked as HTML5!」と言われ

NGなHTMLを突っ込むと赤い帯となり

下の方にいくと、何が悪かったのか教えてくれます。

ちなみに、今回は「Direct Input」を使いましたが、他にURIを指定して確認したり、ファイルアップロードもできたりしますからね。

で、これを自動化したスクリプトがこちら。
exec-html-validator.groovy

import java.nio.charset.StandardCharsets

@Grab('org.jsoup:jsoup:1.7.3')
import org.jsoup.Jsoup

@Grab('commons-codec:commons-codec:1.9')
@Grab('org.codehaus.groovy.modules.http-builder:http-builder:0.6')
@GrabExclude('commons-codec:commons-codec')
import groovyx.net.http.ContentType
import groovyx.net.http.HTTPBuilder
import groovyx.net.http.Method

@Grab('org.apache.httpcomponents:httpmime:4.2.1')
import org.apache.http.entity.mime.HttpMultipartMode
import org.apache.http.entity.mime.MultipartEntity
import org.apache.http.entity.mime.content.StringBody
import org.apache.http.util.EntityUtils

def url = 'http://validator.w3.org/check'  // 必要に応じて、社内とかのサーバへ

if (args.size() == 0) {
    println('[ERROR] 入力するHTMLファイルのルートディレクトリを指定してください')
    System.exit(1)
}

def ignoreErrorPredicates = []

def validateHtmlFile = { htmlFile ->
    def http = new HTTPBuilder(url)
    def body = [
        fragment: htmlFile.getText('UTF-8'),
        charset: 'utf-8',
        prefill: '0',
        doctype: 'HTML5',
        prefill_doctype: 'html401',
        group: '0'
    ]

    def resp = http.request(Method.POST) { req ->
        headers.'User-Agent' = 'Mozilla/5.0'
        def entity = new MultipartEntity()
        body.each { entity.addPart(it.key, new StringBody(it.value, StandardCharsets.UTF_8)) }
        req.entity = entity

        response.success = { resp ->
            [resp.getHeaders('X-W3C-Validator-Status'),
             resp.getHeaders('X-W3C-Validator-Errors'),
             EntityUtils.toString(resp.entity, 'UTF-8')]
        }
    }

    def validatorStatus = resp.get(0)
    def errorsHeader = resp.get(1)
    def entity = resp.get(2)

    if (validatorStatus.grep { /^Invalid$/ }.size() > 0) {
        if (errorsHeader.size() > 0) {
            def html = Jsoup.parse(entity)

            def msgErrs = html.getElementsByClass('msg_err')

            def errors = msgErrs.collect {
                [
                    location: it.getElementsByTag('em').size() > 0 ? it.getElementsByTag('em').collect { it.text() }.head() : '',
                    msg: it.getElementsByClass('msg').size() > 0 ? it.getElementsByClass('msg').collect { it.text() }.head() : '',
                    input: it.getElementsByClass('input').size() > 0 ? it.getElementsByClass('input').collect { it.text() }.head() : '',
                    title: it.getElementsByTag('dt').size() > 0 ? it.getElementsByTag('dt').collect { it.text() }.head() : '',
                    reason: it.getElementsByTag('dd').size() > 0 ? it.getElementsByTag('dd').collect { it.text() }.head() : ''
                ]
            }

            def ignoreErrors = errors.findAll { e -> ignoreErrorPredicates.inject(false) { acc, pred -> acc || pred(e) } }

            ignoreErrors.each { errors.remove(it) }

            [file: htmlFile.path, errorCount: errors.size(), errors: errors]
        } else {
            [file: htmlFile.path, errorCount: errorsHeader.size()]
        }
               } else {
        [file: htmlFile.path, errorCount: 0]
    }
}

def isHtml = { file -> file.file && file.name.endsWith('.html') }

def validate = { file ->
  try {
    def result = validateHtmlFile(file)

    if (result.errorCount > 0) {
      //println("$result.file\t$result.errorCount")

      result.errors.each { error ->
        //def line = "\t" +
        def line =
          [
            result.file,
            error.location,
            error.msg,
            error.input,
            error.title,
            error.reason
          ].join("\t")

        println(line)
      }
    }
  } catch (e) {
    println("[ERROR] file => $file.path")
    e.printStackTrace()
  }
}

def eachFile = { fileName ->
  def f = new File(fileName)

  if (f.exists()) {
    if (f.directory) {
      def dir = f

      dir.eachFileRecurse { file ->
        if (isHtml(file)) {
          validate(file)
        }
      }
    } else {
      def file = f

      if (isHtml(file)) {
        validate(file)
      }
    }
  } else {
    println("[WARN] 指定されたファイルまたはディレクトリ[$fileName]が存在しません")
  }
}

args.each(eachFile)

指定したディレクトリ配下の「.html」ファイルを探してきて、W3C Markup Validation ServiceにPOSTします。結果、戻ってきたHTTPヘッダとHTMLの内容から、エラーがあればそれを表示します。

あくまで、「エラーのみ」です。他の情報が欲しければ、W3C Markup Validation Serviceが返却するHTMLの内容を解析してみてください。

とりあえず、動かしてみましょう。

先ほどエラーになっていたHTMLをファイルとして用意して
input/index.html

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>HTML5サンプル</title>
  </head>
  <body>
    <p>HTML5で作成しました!</p>
    <span id="duplicate-id">IDを重複指定しています</span>
    <span id="duplicate-id">IDを重複指定しています</span>

    <span class="" class="">同じ属性を2重定義</span>
  </body>
</html>

スクリプトを実行。

$ groovy exec-html-validator.groovy input

結果は、こんな感じで出してくれます。

input/index.html	Line 10, Column 28	Duplicate ID duplicate-id.	<span id="duplicate-id">IDを重複指定しています</span>		
input/index.html	Line 12, Column 25	Duplicate attribute class.	<span class="" class="">同じ属性を2重定義</span>		

フォーマットを変えたりしたければ、適当にいじってください。

自分は、後にPOIを使ってExcelに出力するようにしました。

W3C CSS Validation Serviceを使ったCSS検証

続いて、W3C CSS Validation Serviceを使ってCSSの検証を行います。

こちらも、直接入力でCSSを入力します。「Profile」は、「CSS レベル 3」で。

まあ、入力しているCSSはCSS3でもなんでもないですが…。

こちらは、下の方に「検証する」というボタンがあるので、こちら押します。

問題なければ、以下のような表示になり

エラーがあればW3C Markup Validator Service同様に赤くなり、エラーのあった箇所を教えてくれます。

で、自動化したスクリプト
exec-css-validator.groovy

import java.nio.charset.StandardCharsets

@Grab('org.jsoup:jsoup:1.7.3')
import org.jsoup.Jsoup

@Grab('commons-codec:commons-codec:1.9')
@Grab('org.codehaus.groovy.modules.http-builder:http-builder:0.6')
@GrabExclude('commons-codec:commons-codec')
import groovyx.net.http.ContentType
import groovyx.net.http.HTTPBuilder
import groovyx.net.http.Method

@Grab('org.apache.httpcomponents:httpmime:4.2.1')
import org.apache.http.entity.mime.HttpMultipartMode
import org.apache.http.entity.mime.MultipartEntity
import org.apache.http.entity.mime.content.StringBody
import org.apache.http.util.EntityUtils

def url = 'http://jigsaw.w3.org/css-validator/validator' // 必要に応じて、社内とかのサーバへ

if (args.size() == 0) {
    println('[ERROR] 入力するCSSファイルのルートディレクトリを指定してください')
    System.exit(1)
}

def ignoreErrorPredicates = []

def validateCssFile = { cssFile ->
    def http = new HTTPBuilder(url)
    def body = [
        text: cssFile.getText('UTF-8'),
        profile: 'css3',
        usermedium: 'all',
        type: 'none',
        warning: '1',
        vextwarning: '',
        lang: 'ja'
    ]

  def resp = http.request(Method.POST) { req ->
      def entity = new MultipartEntity()
      body.each { entity.addPart(it.key, new StringBody(it.value, StandardCharsets.UTF_8)) }
      req.entity = entity

      response.success = { resp ->
              [resp.getHeaders('X-W3C-Validator-Status'),
              resp.getHeaders('X-W3C-Validator-Errors'),
              EntityUtils.toString(resp.entity, 'UTF-8')]
      }
  }

  def validatorStatus = resp.get(0)
  def errorsHeader = resp.get(1)
  def entity = resp.get(2)

  if (validatorStatus.grep { /^Invalid$/ }.size() > 0) {
      if (errorsHeader.size() > 0) {
          def html = Jsoup.parse(entity)

          def errorMessages = html.getElementsByClass('error')

          def errors = errorMessages.collect {
              [
                  location: it.getElementsByClass('linenumber').size() > 0 ? it.getElementsByClass('linenumber').collect { it.text() }.head() : '',
                  context: it.getElementsByClass('codeContext').size() > 0 ? it.getElementsByClass('codeContext').collect { it.text() }.head() : '',
                  parseError: it.getElementsByClass('parse-error').size() > 0 ? it.getElementsByClass('parse-error').collect { it.text() }.head() : ''
              ]
          }

          def ignoreErrors = errors.findAll { e -> ignoreErrorPredicates.inject(false) { acc, pred -> acc || pred(e) } }

          ignoreErrors.each { errors.remove(it) }

          [file: cssFile.path, errorCount: errors.size(), errors: errors]
      } else {
          [file: cssFile.path, errorCount: errorsHeader.size()]
      }
  } else {
      [file: cssFile.path, errorCount: 0]
  }
}

def isCss = { file ->  file.file && file.name.endsWith('.css') }

def validate = { file ->
    try {
        def result = validateCssFile(file)
        
        if (result.errorCount > 0) {
            //println("$result.file\t$result.errorCount")

            result.errors.each { error ->
                //def line = "\t" +
                def line =
                    [
                    result.file,
                    error.location,
                    error.context,
                    error.parseError
                ].join("\t")
                
                println(line)
            }
        }
    } catch (e) {
        println("[ERROR] file => $file.path")
        e.printStackTrace()
    }
}

def eachFile = { fileName ->
    def f = new File(fileName)

    if (f.exists()) {
        if (f.directory) {
            def dir = f

            dir.eachFileRecurse { file ->
                if (isCss(file)) {
                    validate(file)
                }
            }
        } else {
            def file = f

            if (isCss(file)) {
                validate(file)
            }
        }
    } else {
        println("[WARN] 指定されたファイルまたはディレクトリ[$fileName]が存在しません")
    }
}

args.each(eachFile)

こちらも、指定したディレクトリ配下の「.css」ファイルを探してきて、W3C CSS Validation ServiceにPOSTします。結果、戻ってきたHTTPヘッダとHTMLの内容から、エラーがあればそれを表示します。ほぼ、W3C Markup Validation Serviceを自動化したスクリプトと同じ動きをします。

続いて、チェック対象のCSSとして、先ほどエラーになっていたCSSをファイルとして作成します。
input/sample.css

html {
  margin: 0;
  padding: 0;
  height: 100%;
}

body {
  background-repeat: no-repeat;
  background-position: center top
  background-attachment: fixed;

  font-siz: 12px;
  color: midnightblue;
}

実行。

$ groovy exec-css-validator.groovy input
input/sample.css	10	body	次のプロパティが正しくありません : background-position プロパティ名の前にセミコロン(;)を追加してみて下さい
input/sample.css	12	body	プロパティ font-siz は存在しません : 12px

ちゃんと、同じエラー内容が得られました。

両方のスクリプトとも、特にエラーがなければ何も言いませんし、指定したディレクトリ内にHTMLファイルやCSSファイルがあれば、それを見つけて検証してくれます。

なので、書いておいてなんですが、意図せず大量のリクエストを発行しないように注意しましょう…。そういう用途で使うのであれば、自分でW3C Markup Validation ServiceやW3C CSS Validation Serviceのサーバを構築するのが、望ましいと思います。