文字列中に存在するURLのタイトルを取得して自動リンクする

# 前書き

[OKWave](http://okwave.jp/)でこんな質問があったので、いつも通りローレベルな非同期処理でパパッとやっちゃおうかってことで思い立って書いたクラスです。やっつけで書いたのでソースきれいじゃないしコメントも入ってないですがご了承ください（汗

**URLを見つけたら自動でリンク、タイトルを取得する**
http://okwave.jp/qa/q8482830.html

# 作ったやつ

## 使い方

```php:コード
<?php

class UrlUtil {
    /*省略*/
}

$html = 'http://qiita.comの文字セットはちろん<strong>UTF-8</strong>だけど・・・一応http://www.shtml.jp/mojibake/meta.htmlみたいなShift_JISのページもOKやで！！';

echo UrlUtil::escapeAndLinkify($html);
```

```html:結果
<a href="https://qiita.com" target="_blank">Qiita - プログラマの技術情報共有サービス</a>の文字セットはちろん&lt;strong&gt;UTF-8&lt;/strong&gt;だけど・・・一応<a href="http://www.shtml.jp/mojibake/meta.html" target="_blank">メタタグによる文字コード指定の有効性</a>みたいなShift_JISのページもOKやで！！
```

## 特徴・注意点

- 全てのリクエスト送信・レスポンス受信を非同期的に行います。
- TCP接続の場合、接続も全て非同期的に行います。
- SSL接続の場合、プラットフォームが **Windows** であるもしくは **PHPバージョンが5.3.1未満** の場合には、非同期接続が必ず失敗してしまうので、接続のみ同期的に行います。これに該当しない場合は接続も非同期的に行います。
- **キャッシュを一切使っていない** のでそのあたり工夫したほうがいいかもしれません。
- **存在しないホストの名前解決には無駄に時間を食ってしまいます。** こっちもなんとかしたいところ。
- URLの正規表現には **[「RFC3986定義の厳密なHTTP URIの正規表現」をPHP用に最適化](http://qiita.com/mpyw/items/1e422848030fcde0f29a)** したものを使っています。
- BASIC認証が必要なURLはスルーしています。
- タイトルが見つかった時点でレスポンス受信を中断します。
- タイトルが最後まで見つからなかった場合はURLがそのままリンクの文字列に使われます。


## クラス定義

```php
class UrlUtil {
    
    const STEP_WRITE_HEADERS = 0;
    const STEP_READ_HEADERS  = 1;
    const STEP_READ_BODY     = 2;
    const STEP_FINISHED      = 3;
    
    private $text   = '';
    private $urls   = array();
    private $offset = 0;
    
    public static function escapeAndLinkify($text) {
        $self = new self(htmlspecialchars($text, ENT_QUOTES, 'UTF-8'));
        return $self->text;
    }
    
    private function __construct($text) {
        $this->prepare($text);
        $this->start();
        while ($this->isRunning()) {
            $this->proceed();
        }
        $this->replace();
    }
    
    private function prepare($text) {
        $this->text = $text;
        if (!preg_match_all(self::getRegex(), $text, $matches, PREG_OFFSET_CAPTURE)) {
            return;
        }
        foreach ($matches[0] as $match) {
            switch (true) {
                case !$parts = parse_url($match[0]):
                case isset($parts['user']):
                case isset($parts['pass']):
                case !isset($parts['host'], $parts['scheme']):
                    continue;
            }
            $this->urls[] = (object)array(
                'string'   => $match[0],
                'offset'   => $match[1],
                'length'   => strlen($match[0]),
                'scheme'   => $scheme = strtolower($parts['scheme']),
                'host'     => $parts['host'],
                'port'     => isset($parts['port'])  ? $parts['port']  : ($scheme === 'https' ? 443 : 80),
                'path'     => isset($parts['path'])  ? $parts['path']  : '/', 
                'query'    => isset($parts['query']) ? $parts['query'] : '',
            );
        }
    }
    
    private function start() {
        foreach ($this->urls as $i => $url) {
            if (!$fp = self::createSocket($url)) {
                unset($this->urls[$i]);
                continue;
            }
            stream_set_blocking($fp, 0);
            $this->urls[$i]->fp     = $fp;
            $this->urls[$i]->step   = self::STEP_WRITE_HEADERS;
            $this->urls[$i]->buffer = '';
        }
    }
    
    private function isRunning() {
        foreach ($this->urls as $i => $url) {
            if ($url->step !== self::STEP_FINISHED) {
                return true;
            }
        }
        return false;
    }
    
    private function proceed() {
        $read = $write = array();
        $except = null;
        foreach ($this->urls as $i => $url) {
            switch ($url->step) {
                case self::STEP_FINISHED:
                    continue 2;
                case self::STEP_WRITE_HEADERS:
                    $write[$i] = $url->fp;
                    continue 2;
                default:
                    $read[$i] = $url->fp;
            } 
        }
        if (false === stream_select($read, $write, $except, 0, 200000)) {
            foreach ($this->urls as $i => $url) {
                $url->buffer = '';
                $url->step   = self::STEP_FINISHED;
            }
        }
        foreach ($this->urls as $i => $url) {
            switch ($url->step) {
                case self::STEP_WRITE_HEADERS:
                    isset($write[$i]) and self::writeHeaders($url);
                    break;
                case self::STEP_READ_HEADERS:
                    isset($read[$i]) and self::readHeaders($url);
                    break;
                default:
                    if (isset($read[$i])) {
                        self::readBody($url);
                        if ($url->step !== self::STEP_FINISHED and feof($url->fp)) {
                            $url->buffer = '';
                            $url->step   = self::STEP_FINISHED;
                        }
                    }
            }
        }
    }
    
    private function replace() {
        foreach ($this->urls as $url) {
            if ($url->buffer === '') {
                $url->buffer = $url->string;
            }
            $replace = sprintf(
                '<a href="%s" target="_blank">%s</a>',
                $url->string,
                $url->buffer
            );
            $offset = $url->offset + $this->offset;
            $this->text = substr_replace($this->text, $replace, $offset, $url->length);
            $this->offset += strlen($replace) - $url->length;
        }
    }
    
    private static function writeHeaders($url) {
        if ($url->buffer === '') {
            $path = $url->path;
            if ($url->query !== '') {
                $path .= '?' . $url->query;
            }
            $headers = array(
                "GET {$path} HTTP/1.1",
                "Host: {$url->host}",
                'Connection: Close',
                'User-Agent: ' . 
                    'Mozilla/5.0 (Windows NT 6.1) ' .
                    'AppleWebKit/537.36 (KHTML, like Gecko) ' .
                    'Chrome/28.0.1500.63 Safari/537.36'
                ,
                'Accept: ' .
                    'text/html,' . 
                    'application/xhtml+xml,' .
                    'application/xml' .
                    ';q=0.9,*/*;q=0.8'
                ,
                'Accept-Language: ' .
                    'ja,en-us;q=0.7,en;q=0.3'
                ,
                '',
                '',
            );
            $url->buffer = implode("\r\n", $headers);
        }
        $url->buffer = (string)substr($url->buffer, (int)fwrite($url->fp, $url->buffer, 8192));
        if ($url->buffer === '') {
            $url->step = self::STEP_READ_HEADERS;
        }
    }
    
    private static function readHeaders($url) {
        while (true) {
            $items = explode("\r\n", $url->buffer, 2);
            if (isset($items[1])) {
                $url->buffer = $items[1];
                $url->step   = self::STEP_READ_BODY;
                return;
            }
            if (isset($tmp)) {
                return;
            }
            $url->buffer .= $tmp = fread($url->fp, 8192);
        }
    }
    
    private static function readBody($url) {
        while (true) {
            $buffer = mb_convert_encoding($url->buffer, 'UTF-8', 'ASCII,JIS,UTF-8,CP51932,SJIS-win');
            if (preg_match('@<title>(.*?)</title>@i', $buffer, $matches)) {
                $url->buffer = strip_tags($matches[0]);
                $url->step   = self::STEP_FINISHED;
                return;
            }
            if (isset($tmp)) {
                return;
            }
            $url->buffer .= $tmp = fread($url->fp, 8192);
        }
    }
    
    private static function createSocket($url) {
        static $flag;
        if ($flag === null) {
            $flag = PHP_OS === 'WINNT' || version_compare(PHP_VERSION, '5.3.1') < 0;
        }
        if ($url->scheme === 'https') {
            return 
                $flag ? 
                @fsockopen("ssl://{$url->host}", $url->port) :
                @stream_socket_client("ssl://{$url->host}:{$url->port}", $dummy, $dummy, 0, 6)
            ;
        } else {
            return
                @stream_socket_client("tcp://{$url->host}:{$url->port}", $dummy, $dummy, 0, 6)
            ;
        }
    }   
    
    private static function getRegex() {
        return  
            '`https?+:(?://(?:(?:[-.0-9_a-z~]|%[0-9a-f][0-9a-f]' .
            '|[!$&-,:;=])*+@)?+(?:\[(?:(?:[0-9a-f]{1,4}:){6}(?:' .
            '[0-9a-f]{1,4}:[0-9a-f]{1,4}|(?:\d|[1-9]\d|1\d{2}|2' .
            '[0-4]\d|25[0-5])\.(?:\d|[1-9]\d|1\d{2}|2[0-4]\d|25' .
            '[0-5])\.(?:\d|[1-9]\d|1\d{2}|2[0-4]\d|25[0-5])\.(?' .
            ':\d|[1-9]\d|1\d{2}|2[0-4]\d|25[0-5]))|::(?:[0-9a-f' .
            ']{1,4}:){5}(?:[0-9a-f]{1,4}:[0-9a-f]{1,4}|(?:\d|[1' .
            '-9]\d|1\d{2}|2[0-4]\d|25[0-5])\.(?:\d|[1-9]\d|1\d{' .
            '2}|2[0-4]\d|25[0-5])\.(?:\d|[1-9]\d|1\d{2}|2[0-4]\\' .
            'd|25[0-5])\.(?:\d|[1-9]\d|1\d{2}|2[0-4]\d|25[0-5])' .
            ')|(?:[0-9a-f]{1,4})?+::(?:[0-9a-f]{1,4}:){4}(?:[0-' .
            '9a-f]{1,4}:[0-9a-f]{1,4}|(?:\d|[1-9]\d|1\d{2}|2[0-' .
            '4]\d|25[0-5])\.(?:\d|[1-9]\d|1\d{2}|2[0-4]\d|25[0-' .
            '5])\.(?:\d|[1-9]\d|1\d{2}|2[0-4]\d|25[0-5])\.(?:\d' .
            '|[1-9]\d|1\d{2}|2[0-4]\d|25[0-5]))|(?:(?:[0-9a-f]{' .
            '1,4}:)?+[0-9a-f]{1,4})?+::(?:[0-9a-f]{1,4}:){3}(?:' .
            '[0-9a-f]{1,4}:[0-9a-f]{1,4}|(?:\d|[1-9]\d|1\d{2}|2' .
            '[0-4]\d|25[0-5])\.(?:\d|[1-9]\d|1\d{2}|2[0-4]\d|25' .
            '[0-5])\.(?:\d|[1-9]\d|1\d{2}|2[0-4]\d|25[0-5])\.(?' .
            ':\d|[1-9]\d|1\d{2}|2[0-4]\d|25[0-5]))|(?:(?:[0-9a-' .
            'f]{1,4}:){0,2}[0-9a-f]{1,4})?+::(?:[0-9a-f]{1,4}:)' .
            '{2}(?:[0-9a-f]{1,4}:[0-9a-f]{1,4}|(?:\d|[1-9]\d|1\\' .
            'd{2}|2[0-4]\d|25[0-5])\.(?:\d|[1-9]\d|1\d{2}|2[0-4' .
            ']\d|25[0-5])\.(?:\d|[1-9]\d|1\d{2}|2[0-4]\d|25[0-5' .
            '])\.(?:\d|[1-9]\d|1\d{2}|2[0-4]\d|25[0-5]))|(?:(?:' .
            '[0-9a-f]{1,4}:){0,3}[0-9a-f]{1,4})?+::[0-9a-f]{1,4' .
            '}:(?:[0-9a-f]{1,4}:[0-9a-f]{1,4}|(?:\d|[1-9]\d|1\d' .
            '{2}|2[0-4]\d|25[0-5])\.(?:\d|[1-9]\d|1\d{2}|2[0-4]' .
            '\d|25[0-5])\.(?:\d|[1-9]\d|1\d{2}|2[0-4]\d|25[0-5]' .
            ')\.(?:\d|[1-9]\d|1\d{2}|2[0-4]\d|25[0-5]))|(?:(?:[' .
            '0-9a-f]{1,4}:){0,4}[0-9a-f]{1,4})?+::(?:[0-9a-f]{1' .
            ',4}:[0-9a-f]{1,4}|(?:\d|[1-9]\d|1\d{2}|2[0-4]\d|25' .
            '[0-5])\.(?:\d|[1-9]\d|1\d{2}|2[0-4]\d|25[0-5])\.(?' .
            ':\d|[1-9]\d|1\d{2}|2[0-4]\d|25[0-5])\.(?:\d|[1-9]\\' .
            'd|1\d{2}|2[0-4]\d|25[0-5]))|(?:(?:[0-9a-f]{1,4}:){' .
            '0,5}[0-9a-f]{1,4})?+::[0-9a-f]{1,4}|(?:(?:[0-9a-f]' .
            '{1,4}:){0,6}[0-9a-f]{1,4})?+::|v[0-9a-f]++\.[!$&-.' .
            '0-;=_a-z~]++)\]|(?:\d|[1-9]\d|1\d{2}|2[0-4]\d|25[0' .
            '-5])\.(?:\d|[1-9]\d|1\d{2}|2[0-4]\d|25[0-5])\.(?:\\' .
            'd|[1-9]\d|1\d{2}|2[0-4]\d|25[0-5])\.(?:\d|[1-9]\d|' .
            '1\d{2}|2[0-4]\d|25[0-5])|(?:[-.0-9_a-z~]|%[0-9a-f]' .
            '[0-9a-f]|[!$&-,;=])*+)(?::\d*+)?+(?:/(?:[-.0-9_a-z' .
            '~]|%[0-9a-f][0-9a-f]|[!$&-,:;=@])*+)*+|/(?:(?:[-.0' .
            '-9_a-z~]|%[0-9a-f][0-9a-f]|[!$&-,:;=@])++(?:/(?:[-' .
            '.0-9_a-z~]|%[0-9a-f][0-9a-f]|[!$&-,:;=@])*+)*+)?+|' .
            '(?:[-.0-9_a-z~]|%[0-9a-f][0-9a-f]|[!$&-,:;=@])++(?' .
            ':/(?:[-.0-9_a-z~]|%[0-9a-f][0-9a-f]|[!$&-,:;=@])*+' .
            ')*+)?+(?:\?+(?:[-.0-9_a-z~]|%[0-9a-f][0-9a-f]|[!$&' .
            '-,/:;=?+@])*+)?+(?:#(?:[-.0-9_a-z~]|%[0-9a-f][0-9a' .
            '-f]|[!$&-,/:;=?+@])*+)?+`i'
        ;
    }
    
}
```