10. ruby - ウェブスクレイピング - リンク切れをチェック 同列以下

 
10.1 概要
10.2 メイン
10.3 リンク URI を抽出する
10.4 リンク先をチェックする
10.5 分類してチェック
10.6 # をチェック
10.7 ./ や ../ の編集
10.8 リンク先を取得
10.9 現時点での問題点

10.1 概要

 前々項までのものは1ページ分のチェックしかできませんでしたが、今回は、ディレクトリの位置が同列のものまでは、再帰的にチェックを行うようにしました。  まぁ、ここまで行けばわたしの思うところは十分に満たせるので・・・。  後は動かしながら、不具合を修正していきたいと思います。  まだまだ、手直しが入ると思いますので・・・。

10.2 メイン

 前項のコンフィグレーションファイルの読込を加えました。

#!/usr/bin/env ruby
=begin

v0.1  リンク切れチェックを行う、下記の形式で起動
      ./linkcheck.rb URI
v0.2  チェック済の結果を保持する処理に誤りがあったので修正

=end

require('./readconf')
require('./checkparent')

tree     = { ARGV[0] => 'UNKNOWN' }
checked  = {}
$checkID = []
$nocheck = []

readconf = ReadConf.new
readconf.execute()

parent = CheckParent.new(ARGV[0])
parent.execute(ARGV[0], tree, checked)

10.3 リンク URI を抽出する

 リンク先 URI を抽出する部分。  これも大きな変更はありません。  例外処理を少し加えただけです。

require './checkchild'
require 'open-uri'
require 'nokogiri'

class CheckParent
  def initialize(original)
    @check = Check.new(original)
  end

  def execute(uri, tree, checked)
    begin

      tree.each do | key, value |
        if (key == uri)
          if (value != 'UNKNOWN')
            return;
          end
        end
      end

      tree[uri] = 'CHECKING'

      originalURI = uri

      now = Time::now()
      nowtime = sprintf("%02d",now.hour)+":"+sprintf("%02d",now.min)+":"+sprintf("%02d",now.sec)
      print("#{nowtime} 検索 [#{originalURI}] ")

      uri = URI.encode(uri)
      uri = URI.parse(uri)

      print(".")

      html = open(uri).read
      html.sub!(/^<!DOCTYPE html(.*)$/, '<!DOCTYPE html>')
      doc  = Nokogiri::HTML(html)

      link = {}

      doc.css('a').each do | tag |
        link[tag[:href]] = nil
      end

      doc.css('input').each do | tag |
        if s = tag[:onclick]
          s = s[/\'.*\'/]
          link[s.delete!("'")] = nil
        end
      end

      doc.css('img').each do | tag |
        link[tag[:src]] = nil
      end

      link = Hash[link.sort]

      @check.execute(originalURI, doc, link, tree, checked)

    rescue OpenURI::HTTPError => error

      puts("  #{uri} ")
      puts("例外発生[#{error.class}]")
      return;

    rescue => error
      puts "例外発生[#{error.class}]"
      puts "#{error.backtrace}"
      puts "[#{self.class.name}][#{error.message}]"
      exit 1
    end
  end
end

10.4 リンク先をチェックする

 リンク先をチェックする部分。  ここが一番変更の多かった部分です。

require('pry')
require('./classific')

class Check
  def initialize(original)
    protocol, userinfo, domain, other  = URI.split(ARGV[0])
    @top = "#{protocol}" + "://" + "#{domain}"
  end

  def execute(parent, doc, child, tree, checked)

    # parent の URI の末尾を加工してインデックスまでを求める
    index = parent

    if (index[-1, 1] != '/')              # 末尾が / でない場合は 末尾のひとつ前の / までをインデックスファイルとする
      split = parent.split('/')
      last  = split.last

      if (last.include?('.'))
        index = parent[0,parent.length-last.length]
      end
    end

    classific = Classific.new
    classific.execute(parent, @top, doc, index, child, tree, checked)

    puts("")
    child.each_value do | value |
      if ((value[:結果] != 'OK')         &&
          (value[:結果] != '未チェック') &&
          (value[:結果] != 'ADV'))
        puts("  #{value[:リンク]} ")
        puts("   → #{value[:URI]}")
      end
    end

    #return;                                             # 再帰検索しない場合はここを生かす

    child.each_value do | value |
      if (value[:種別][0,2] != '内部')                  # 内部リンクでないと検索しない
        next
      end

      if (value[:結果] != 'OK')                         # OK じゃないものは既にリンク切れなので検索しない
        next
      end

      if (value[:リンク][0,2] == '..')                  # .. で上位へ行くものは探さない
        next
      end

      if (value[:URI].include?('#'))                    # # を含んでいるものまでは検索しない
        next
      end

      if (index != value[:URI][0, index.length])        # インデックス部分まで一致しないものは検索しない
        next
      end

      parent = CheckParent.new(value[:URI])
      parent.execute(value[:URI], tree, checked)
    end

  rescue => error
    binding.pry
    puts "例外発生[#{error.class}]"
    puts "#{error.backtrace}"
    puts "[#{self.class.name}][#{error.message}]"
    exit 1
  end
end
 24、25行。  実際にリンク先を分類してチェックするのは別クラスに実装しました。  39~62 行で 他ドメインでない 既にリンク切れでない 上位のディレクトリではない  URI を再帰的に検索するようにしています。

10.5 分類してチェック

 前項でリンク先を分類してチェックする箇所を別に抜き出して処理しています。

=begin

リンクを分類してチェックする

=end

require('pry')
require('open-uri')
require('nokogiri')
require('./assemble')
require('./searchName')
require('./fetch')

class Classific
  def initialize()
  end

  def execute(parent, top, doc, index, child, tree, checked)

    search   = SearchName.new
    assemble = Assemble.new
    fetch    = Fetch.new

    child.each_key do | key |

      full = key
      kind = "                 "
      res  = '未チェック'

      case key[0]
      when '#'                                          # ページ内リンクなので html 内に id=key が存在すること
        kind = "ページ内リンク   "
        res = search.execute(parent, doc, key)
      when 'h'                                          # 外部リンクなので存在の有無のみ
        kind = "外部    リンク   "
        res, full = fetch.execute(parent, top, key, checked)
        checked[full] = res
      when '.'                                          # 内部リンクなのでたどる
        kind = "内部  . リンク   "
        res, full = fetch.execute(parent, top, assemble.execute(index, key), checked)
        checked[full] = res
      else
        if ((key[-3, 3] == 'png') ||                    # イメージなので存在の有無のみ
            (key[-3, 3] == 'gif'))
          kind = "イメージファイル "
          res, full = fetch.execute(parent, top, key, checked)
          checked[full] = res
        elsif ((key[-4, 4] == 'html') ||                # 内部リンクなのでたどる
               (key[-1, 1] == '/'))
          kind = "内部    リンク   "
          res, full = fetch.execute(parent, top, key, checked)
          checked[full] = res
        else                                            # 上記以外なのでチェックしない
          kind = "その他のもの     "
        end
      end

      child[key] = { :リンク元 => parent, :種別 => kind, :リンク => key, :結果 => res, :URI => full }
    end
  rescue => error
    binding.pry
    puts "例外発生[#{error.class}]"
    puts "#{error.backtrace}"
    puts "[#{self.class.name}][#{error.message}]"
    exit 1
  end
end

10.6 # をチェック

 # で始まるリンク先は、ドキュメント内に「id="リンク先"」の記述があれば OK とします。  「id="リンク先"」の記述を含むタグは前ページに掲載したコンフィグレーションファイルからの読込によって設定しています。

require('pry')

=begin

# で始まる要素はページ内の特定の場所へのリンク
  指定したタグ内に id="" の形式で記述してあるものを探す

=end

class SearchName
  def initialize()
  end

  # 引数としてドキュメント自身が必要

  def execute(parent, doc, id)
    begin
      $checkID.each do | tag |                          # タグを検索
        doc.css(tag).each do | attribute |
          if (!attribute[:id])                          # id がない場合はどうしようもない
            next
          elsif ("#" + attribute[:id] == id)
            return 'OK'                                 # 検出したら 'OK' を返す
          end
        end
      end
    rescue => error
      puts("例外発生[#{self.class.name}]")
      puts("#{parent}]内の[#{id}]検索中")
      puts("例外[#{error.class}]")
      puts("#{error.backtrace}")
      puts("#{error.message}")
      exit 1
    end

    return 'NG'
  end
end

10.7 ./ や ../ の編集

 「./」や「../」で始まるリンク先は、リンク元のドキュメントのルートやその上と組み合わせて、URI を再構築します。

require('pry')

=begin

./  や ../ で始まる URI を組み立て直す

=end

class Assemble
  def initialize()
  end

  def execute(index, original)
    uri = original;

    begin
      if (original == '../')                              # ../ のみであれば
        split = index.split('/')                          # ひとつ上のインデックスとする
        uri = index[0, index.length-(split.last.length+1)]

      elsif (original[0, 2] == '..')                      # ../ インデックスのひとつ上に / 以降を加える
        split = index.split('/')
        uri = index[0, index.length-(split.last.length+2)] + original[2,original.length-2]

      else                                                # ./  インデックスに / 以降を加える
        uri = index + original[2,original.length-2]
      end
    rescue => error
      puts "例外発生[#{error.class}]"
      puts "#{error.backtrace}"
      puts "[#{self.class.name}][#{error.message}]"
      exit 1
    end

    return uri
  end
end

10.8 リンク先を取得

 ドキュメント内の記述でないリンク先は取得してみます。

=begin

外部リンク・ドメイン内リンク・イメージファイルは open してみる

=end

class Fetch
  def initialize()
  end

  def execute(parent, top, check, checked)
    begin

      if (check[0, 'https://px.a8.net/'.length] == 'https://px.a8.net/')
        return 'ADV', check
      end

      original = check

      if (check.include?('#'))
        split = check.split('#')
        sharp = split.last
        check = check[0, check.length-(split.last.length+1)]
      end

      case check[0]
      when ('/')                            # / で始まっているものはドメイン内リンクなのでドメインをつける
        check = top + check
      end

      uri = URI.encode(check)
      uri = URI.parse(uri)

      # 既にチェック済であればその結果をそのまま返す

      if (checked.has_key?(check))
        return checked[check], check
      end

      print(".")

      if (sharp)
        html = open(uri).read
        html.sub!(/^<!DOCTYPE html(.*)$/, '<!DOCTYPE html>')
        doc  = Nokogiri::HTML(html)

        search = SearchName.new                         # ページ内リンクなので html 内に id=key が存在すること
        response = search.execute(parent, doc, '#'+sharp)

        if (response != 'OK')
          File.open(sharp+".txt", "w") do | file |
            file.puts(doc)
          end
        end

        return response, check
      else
        open(uri)
      end

    rescue OpenURI::HTTPError => error
      return 'NG', check

    rescue SocketError => error
      return 'SocketError', check

    rescue OpenSSL::SSL::SSLError => error
      binding.pry
      return 'SSLError', check

    rescue RuntimeError => error
      if (error.message.include?('redirection forbidden'))
        return 'redirection forbidden', check
      else
        puts "例外発生[#{error.class}]"
        puts "#{error.backtrace}"
        puts "[#{self.class.name}][#{error.message}]"
        exit 1
      end

    rescue TypeError => error
      if (error.message.include?('no implicit conversion'))
        return error.message, check
      else
        puts "例外発生[#{error.class}]"
        puts "#{error.backtrace}"
        puts "[#{self.class.name}][#{error.message}]"
        exit 1
      end

    rescue Errno::ENOENT => error                         # No such file or directory
      return 'No such file or directory', check

    rescue => error
      binding.pry
      puts "例外発生[#{error.class}]"
      puts "#{error.backtrace}"
      puts "[#{self.class.name}][#{error.message}]"
      exit 1
    end

    return 'OK', check
  end
end

10.9 現時点での問題点

 一応ある程度チェックできているように見えます。  チェック済の URI に関しては、前のチェック結果をそのまま返すようにして、同一 URI を複数回アクセスしないようにしているつもりですが、どうも動作を見ていると同じ URI に何度もアクセスしているように見えるので、調査中です。  本件、2018年6月8日に誤りがあったことがわかりましたので修正しました。  以降、これらの修正は「リンクチェック」をご参照ください。