Google Feed APIを使わずXMLHttpRequestでクロスドメインのRSSを取得する方法 まとめ

「Google Feed API」を使わず、XMLHttpRequestでクロスドメインのRSSを取得して最新記事一覧を表示させるスクリプトを書いたんですが、その際にわからなかったことをまとめておきます。JavaScriptに関しては下に書きますが、jQueryは使っていません。

RSS取得後のネームスペース付きXMLからデータを抽出する方法はクロスドメインとは別の問題なので、他のページ「querySelectorでIEでもそれ以外でもネームスペース付のXMLからデータを取得する方法」にまとめています。

クロスドメインのRSSを取得するサーバー構築

需要があるかどうかは別にして、単純に自ドメインのRSSを取り込むだけなら通常通りのやり方で問題ないんですが、クロスドメインになるとアクセス拒否されます。そういった問題に対する手段として「Google Feed API」がありますけど、先日一時的に「Google Feed API」が使えないという騒動になりました。外部サービスに頼るとそのサービスが止まった時に困るという事で、自前で外部RSSを中継するサーバーを立てました。

中継サーバーは数行のスクリプトで出来るんですが、PHPの場合なら次のようになると思います。

<?php
// GETで ?rss=URL という形でリクエストされる場合

// GETのパラメーターにrssがなかったら
// エラーステータスを返して終了
if(!isset($_GET['rss'])){
    header('HTTP/1.0 400 Bad Request');
    exit();
}

$url = rawurldecode($_GET['rss']);

// $_GET['rss']の文字列がURLのパターンにマッチしてなかったら
// エラーステータスを返して終了
if(!preg_match('|^(\S+?:/)?/\S+?\.\S+?/\S+$|', $url)){
    header('HTTP/1.0 400 Bad Request');
    exit();
}

$host = parse_url($url, PHP_URL_HOST);

// 指定のアドレスが存在しない場合
// エラーステータスを返して終了
if(@gethostbyname($host) == $host){
    header('HTTP/1.0 400 Bad Request');
    exit();
}

// 指定されたアドレスのデータを取得
$rss = @file_get_contents($url);

// 注)
// file_get_contents自体のトラブルで通信不能でも
// 通信出来てて200以外の場合でもfalseが返る。
// なので下はレスポンスヘッダを調べている

// レスポンスヘッダがなかったら通信できていない
// エラーステータス(500)を返して終了
if(!isset($http_response_header)){
    header('HTTP/1.0 500 Internal Server Error');
    exit();
}

$response = array_shift($http_response_header);

// レスポンスヘッダが200 OKでないなら
// そのステータス(おそらく500以外)を返して終了
if(false === strpos($response, '200')){
    header($response);
    exit();
}

$content_type = null;

// レスポンスヘッダのContent-Typeを調べる
foreach($http_response_header as $line){
    if(false !== strpos(strtolower($line), 'content-type:')){
        if(false !== strpos($line, 'xml')){
            $content_type = $line;
        }
        break;
    }
}

// 取得したファイルがxmlでない(RSSでない)なら
// エラーステータスを返して終了
if(!$content_type){
    header('HTTP/1.0 400 Bad Request');
    exit();
}

// 取得したRSS(と思われるxml)をそのまま送信
header('HTTP/1.0 200 OK');
header($content_type);
header('Access-Control-Allow-Origin: *');  
header('Access-Control-Allow-Methods: GET');
print($rss);

/*
 PHPのみの単体ファイルなので終了タグ ?> は不要
 PHPマニュアル http://php.net/manual/ja/language.basic-syntax.phptags.php より
 「ファイル全体が純粋な PHP コードである場合は、ファイルの最後の終了タグは省略するのがおすすめです。」
*/

もし特定ドメインからのアクセスしか許可しないなら、file_get_contents の結果がxmlなのかどうかをチェックせずにそのまま返してもいいのかも知れませんが、今回はアクセス制限をしないので悪用防止でxml以外は返さないようにしています。

ここで重要なのは赤文字の2行です。これがないとクロスドメイン問題が解消されません。

注)このスクリプトを置くURLと同じドメインからのアクセスなら赤文字2行がなくても通信できます。そうではなく、他のドメインとも通信する場合には必要です。例えばこのスクリプトにアクセスして特定サイトの最新記事一覧を取得するブログパーツを配布する場合などは、どのURLのブログからアクセスが来るかわかりません。そういう時には赤文字の2行が必要です。

HTTP access control

HTTP access control (CORS) | MDN

上のサイトに詳しく書いてあるんですが、XMLHttpRequestでクロスドメイン間通信を行うには、サーバー側でそれを許可する旨のヘッダーを送信しないといけません。そのための仕様が「Cross-Origin Resource Sharing (CORS) 」というらしくて、XMLHttpRequest以外にもWebフォントなどを特定ドメインだけに限定して許可したりできます。

今回は説明を簡単にするためにPHPのheader関数で送信していますが、.htaccessに書くことができます。

上の2行を.htaccessに書く場合はこうなります。

Header append Access-Control-Allow-Origin: *
Header append Access-Control-Allow-Methods: GET

Access-Control-Allow-Origin

Access-Control-Allow-Originには、アクセスを許可するURIを指定します。

たとえば、http://example.com にだけアクセスを許すのであれば、次のように書きます。

Access-Control-Allow-Origin: http://example.com

アクセスを許可するサイトを複数指定することも出来なくはないのですが、工夫が必要みたいです。
参考ページ「Access-Control-Allow-Originヘッダで複数のオリジンドメインを許可する方法 – ぷれすとぶろぐ

アドレスを指定せず、すべてのURIに解放するのであれば、上のようにワイルドカード * を使います。次の場合は、すべてのURIからのアクセスを許可することになります

Access-Control-Allow-Origin: *

Access-Control-Allow-Methods

Access-Control-Allow-Methodsには、アクセスに許可するメソッドを指定します。

GET通信のみ許可する場合は次のようになります。

Access-Control-Allow-Methods: GET

Access-Control- で始まるヘッダは他にもあるんですが、この2つの指定だけでもXMLHttpRequestでの通信は出来るようになります。

JavaScript側の対応

サーバー側で上記の設定をしていれば、JavaScript側では普通にXMLHttpRequest通信をするだけです。

WindowsのInternetExplorerだけは、XMLHttpRequest ではなく XDomainRequest でないとクロスドメイン間通信は出来ないと書いてあるサイトが多かったのですが今は大丈夫みたいです。

というかInternetExplorer11(バージョン: 11.0.9600.18124)では window.XDomainRequest が見つかりませんでした。次のようにして試してみたらIE11でもそれ以外でも2が表示されます。

if(window.XDomainRequest){
    alert(1);
}else if(window.XMLHttpRequest){
    alert(2);
}

InternetExplorer11でも window.XMLHttpRequest が返ってくるんですがクロスドメイン間通信は出来ました。ただ、古いバージョンのIEではダメでしょうから、結論としてJavaScriptのXMLHttpRequestでRSSを取得するには下のようになります。

ただし、XMLHttpRequest Level2に対応していないブラウザ(クロスドメイン間通信自体が出来ないブラウザ)ではcallback関数は呼ばれませし、エラーメッセージも表示しません。

私は「サイドバーに最新記事一覧を表示させるブログパーツ」に使ったので、未対応ブラウザではエラー表示をするよりも、パーツのタイトルも含めて一切何も表示させない方がユーザーが混乱しないと思ったので。

jQueryを使わずXMLHttpRequestでRSSを取得するサンプル

/*
取得したいRSSが   http://example.com/index.rdf で
先ほどのサーバーが http://hoge.dwm.me/ の場合
*/

function callback(){
  /* ここに取得したRSSの処理を記述 */
}

var rss_url = 'http://example.com/index.rdf';
var url = 'http://hoge.dwm.me/?rss=' + encodeURIComponent(rss_url);

if(window.XDomainRequest){
    var xhr = new XDomainRequest();
    xhr.onload  = function(){callback();};
    xhr.onerror = function(){}
    xhr.open('GET', url, true);
    xhr.send('');
}else if(window.XMLHttpRequest){
    var xhr = new XMLHttpRequest();
    xhr.onreadystatechange = function(){
        if(this.readyState == 4 && this.status == 200){
            callback();
        }
    };
    xhr.open('GET', url, true);
    xhr.send('');
}

これでRSSのxml自体は取得できます。後は取得したデータを処理するcallback関数の中身を書くだけです。

RSSはネームスペース付きXMLなのでquerySelectorでうまくデータが取得できなかったんですが、JavaScriptでネームスペース付きXMLからデータを取得する方法がわかれば解決します。

それに関してはXMLHttpRequestでのクロスドメイン通信とは違う話なので別のページ「querySelectorでIEでもそれ以外でもネームスペース付のXMLからデータを取得する方法」にまとめました。必要ならそちらをご覧ください。取得したRSSから各ページの最初の画像も抽出して一覧表示するサンプルを掲載しています。

取得するURLが同ドメインかクロスドメインか判断して取得先を振り分けるJavaScript

取得するRSSのURLが固定なら問題ないですが、場合によって同じドメインのRSSを取りに行ったり別ドメインのRSSを取りに行ったりする場合、同じドメインなのに上の自作サーバーを経由するのはムダです。

取得するURLに自ドメインと別ドメインが混在する場合は、JavaScript側でアクセスする先を切り替えるようにします。次はその一例です。

function callback(rss){
  /* ここに取得したRSSの処理を記述 */
}

function get_rss(url){
    var rss_server = 'ここに自作RSS中継サーバーのURL';

    var pattern       = /^(\S+?:\/)?\/([^\/\s]+).*$/;
    var server_domain = rss_server.replace(pattern, '$2');
    var location      = window.location.href.replace(pattern, '$2');

    /*
     指定されたRSSのドメインが閲覧中のページのドメインと
   同じならそのままRSSが取得できるので
   違う場合のみ中継サーバーを使う。
    */
    if(server_domain != location){
        url = rss_server + '?rss=' + encodeURIComponent(url);
    }

    if(window.XDomainRequest){
        var xhr = new XDomainRequest();
        xhr.onload  = function(){callback(this.responseXML);};
        xhr.onerror = function(){}
        xhr.open('GET', url, true);
        xhr.send('');
    }else if(window.XMLHttpRequest){
        var xhr = new XMLHttpRequest();
        xhr.onreadystatechange = function(){
            if(this.readyState == 4 && this.status == 200){
                callback(this.responseXML);
            }
        };
        xhr.open('GET', url, true);
        xhr.send('');
    }
}

get_rss('http://example.com/index.rdf');

取得したデータのContent-Typeを調べるにはgetResponseHeaderを使う

今回のスクリプトでは使っていませんが、もし取得したデータのレスポンスヘッダを見るにはgetResponseHeaderメソッドを使います。Content-Typeを取得したい場合は次のようにします。

var content_type = xhr.getResponseHeader('Content-Type');

PHPでTwitterのOAuth認証するサイトのサンプルページを作ってみました

PHPでTwitterのOAuth認証を使ってログインするサイトのサンプルページを作って見ました。
http://php-oauth-sample.dwm.me/

追記:上のはあまりにもみすぼらしいので、ちょっと真面目に作り直しました。こちらが新しいサンプルです。
http://dwm.me/sample/todo/

ただし、今ご覧頂いているページの内容は最初のサンプルにそって書かれています。

作ったファイルは、.htaccess と index.php、page_1.php、page_2.php、404.php、それから template.php です。

ファイルはhttp://php-oauth-sample.dwm.me/twitter_oauth_sample.zip からダウンロードできます。

実行内容は、次の通りです。

  • .htaccess を使ってすべてのアクセスを index.php に渡す。
  • index.php でログイン済みかどうかをチェック。
  • ログインしていない場合、template.php を使って状況に応じたメッセージを表示。
  • 「ログイン」をクリックしたら、Twitterの認証ページヘリダイレクト。
  • 認証がすんだら page_1.php を開く。
  • あとは自由にページを見られる。(他には page_2.php しかありませんけど)
  • 存在しないページをリクエストされたら、404.php を表示する。
  • 「ログアウト」をクリックしたらログアウトする。

page_1.php と page_2.php それから 404.php は独立していて、それ以外の処理を template.php に渡して表示します。

また、ライブラリとして https://github.com/abraham/twitteroauthで公開されている twitteroauth を使っています。(上に書いた圧縮ファイルの http://php-oauth-sample.dwm.me/twitter_oauth_sample.zip には同梱していません)

Twitter Apps で認証キーを取得する

まずはじめにTwitter Apps(https://apps.twitter.com/)で、API key と API secret を取得します。

Twitter AppsTwitter Apps

Website と書かれた項目と Callback URL と書かれた項目以外は、好きに書いて構いませんが、認証画面で次のように表示されます。

Twitter Apps

Website には、認証させるサイトのURLを入力します。

Callback URL は、認証が済んだ後に自動でリダイレクトされる先のURLです。私のサンプルでは http://php-oauth-sample.dwm.me/twitter_callback/ になっていますが、ここで必要なデータを取得したら、再びリダイレクトして http://php-oauth-sample.dwm.me/page_1/ に飛ばしています。結果として、ログインしたユーザーが最初に見るのは http://php-oauth-sample.dwm.me/twitter_callback/ ではなく http://php-oauth-sample.dwm.me/page_1/ になります。

また、Twitter Apps に登録する際に注意しなくてはいけないのが、Allow this application to be used to Sign in with Twitter という部分です。ここにチェックを入れておかないと、ログインする度に認証ページが開いてしまいます。

ここにチェックをいれておけば、最初の認証以外は、すぐに http://php-oauth-sample.dwm.me/twitter_callback/ へリダイレクトさせることが出来ます。その説明は次のページが詳しいです。

PHPで「Sign in with Twitter」を実装する方法 – 頭ん中

同ページより引用

  • ユーザーが呼び出し元アプリケーションを承認している場合
    • ユーザーが Twitter にログインしている場合:直ちに承認されて、呼び出し元のアプリケーション(callback URL)にリダイレクトされる。
    • ユーザーが Twitter にログインしていない場合:Twitter のログイン画面が表示され、ログイン後は直ちに承認されて、呼び出し元のアプリケーションにリダイレクトされる。
  • ユーザーがまだ呼び出し元アプリケーションを承認していない場合、あるいは承認を取り消している場合
    • ユーザーが Twitter にログインしている場合:OAuth の承認画面が表示され、承認後は呼び出し元のアプリケーションにリダイレクトされる。
    • ユーザーが Twitter にログインしていない場合:まず Twitter のログイン画面が表示され、ログイン後に OAuth の承認画面に移り、承認後は呼び出し元のアプリケーションにリダイレクトされる。

ライブラリ twitteroauth の取得

OAuth認証をするために使うライブラリを https://github.com/abraham/twitteroauth から取得します。

開いたページの右側に Download ZIP というボタンがあるので、それをクリックするとダウンロードできます。

twitteroauth

ダウンロードした .zip ファイルを解凍するとファイルがたくさんありますが、必要なのは twitteroauth というフォルダだけです。このフォルダの中に入っている2つのファイル OAuth.phptwitteroauth.php を使います。

.htaccess の作成

この項目は私のやった方法の場合で、OAuth認証と基本的に無関係です。

サイトへの、すべてのリクエストを index.php に渡して、そこで処理するために次の内容の .htaccess を用意しました。

<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
RewriteRule . index.php [QSA,L,PT]
</IfModule>

実際の .htaccess には他にもいろいろ書いていますが、mod_rewrite に必要なところだけ書き出しています。これで、すべてのアクセスは index.php に渡されるので、そこで処理をします。

URLは http://php-oauth-sample.dwm.me/page_1/ という形式で、この場合だと page_1.php を読み込んで表示するようにします。

index.php の内容

大きく分けると switch 文で、次のように処理を振り分けています。

実際に使っているファイルは http://php-oauth-sample.dwm.me/twitter_oauth_sample.zip からダウンロードできますので、そちらを参照してください。

switch($match[1]){
	// ログインをリクエストされた場合
	case TWITTER_LOGIN:

		// 認証用のtokenを取得

		if(token が取得できたら){
			// Twitter へリダイレクトして終了
			//(認証後、登録したリダイレクトページに返ってくる)
			return;
		}

		// token が取得できなければ、表示するメッセージを用意してbreak
		break;

	// Twitterの認証画面からリダイレクトされてきた場合
	case TWITTER_CALLBACK:

		if(認証されていたら){
			// 送られたデータをセッションに渡し
			// http://php-oauth-sample.dwm.me/page_1/ ヘ リダイレクトして終了
			return;
		}

		// 認証されなていなければ、表示するメッセージを用意してbreak
		break;

	// ログアウトをリクエストされた場合
	case LOGOUT:

		// セッションをクリア

		// ログアウト完了のメッセージを用意してbreak
		break;

	// その他のケース
	default:

		if(指定のページが存在する場合){

			if(ログインしていれば){
				// そのページを表示して終了
				return;
			}

			// ログインしていなければ、ログインを促すメッセージを用意

		}else{
			// ページが存在しなければ 404ページを読み込んで終了
			return;
		}

		// 指定のページが存在するけど、ログインしていない場合はbreak
		break;
}

// ここまでで return していなければ
// template.php に用意したメッセージを埋め込んで表示

header('Content-Type: text/html; charset=UTF-8');
require_once(TEMPLATE_FILE);

ログインをリクエストされた場合

// API key と API secret は https://dev.twitter.com/apps から取得する

define('API_KEY', '*************************');
define('API_SECRET', '**************************************************');

define('LOCATION_BASE', 'http://php-oauth-sample.dwm.me');
define('TWITTER_CALLBACK', 'twitter_callback'); // Twitterからのコールバックページ

/************************************************************************/

// switch文から該当部分のみ抜粋

case TWITTER_LOGIN:

	// token を取得

	$auth = new TwitterOAuth(API_KEY, API_SECRET);
	$url = sprintf('%s/%s/', LOCATION_BASE, TWITTER_CALLBACK);
	$token = $auth->getRequestToken($url);

	// token が取得できたら Twitter へリダイレクトして終了
	//(認証後、登録したリダイレクトページに返ってくる)

	if(isset($token['oauth_token']) && isset($token['oauth_token_secret'])){

		// セッションに登録
		$_SESSION['oauth_token']        = $token['oauth_token'];
		$_SESSION['oauth_token_secret'] = $token['oauth_token_secret'];

		// 2つ目の引数が true だと
		// アプリケーションを承認済みユーザーは即座にcallbackページにリダイレクト
		// 未登録の場合は、承認画面を表示後、承認が終わるとリダイレクトされる
		// ただし、Twitter Apps で
		// Allow this application to be used to Sign in with Twitter
		// にチェックを入れておく事が必要

		$auth_url = $auth->getAuthorizeURL($_SESSION['oauth_token'], true);

		header("Location: " . $auth_url);
		return;

	}

	// token が取得できなければ、表示するメッセージを用意してbreak

	$message = 'エラーが発生しました。恐れ入りますが、もう一度やり直してください。';

	break;

Twitterの認証画面からリダイレクトされてきた場合

case TWITTER_CALLBACK:

	// 認証されていたら
	if(isset($_REQUEST['oauth_verifier']) && ('' != $_REQUEST['oauth_verifier'])){

		$auth = new TwitterOAuth(API_KEY, API_SECRET,
			$_SESSION['oauth_token'], $_SESSION['oauth_token_secret']);

		$access_token = $auth->getAccessToken($_REQUEST['oauth_verifier']);

		$_SESSION['user_id']     = $access_token['user_id'];
		$_SESSION['screen_name'] = $access_token['screen_name'];

		// ログイン後、最初に表示するページヘリダイレクトして終了
		// URLにGETで oauth_token と oauth_verifier が含まれているので
		// それを消すために require でファイルを読むのではなくリダイレクトさせる

		// define('FIRST_PAGE', '/page_1/');

		header('Location: ' . LOCATION_BASE . FIRST_PAGE);
		return;

	}

	// 認証されなければ、表示するメッセージを用意してbreak

	$message = 'ログイン出来ません。Twitterアカウントを確認してください。';

	// 認証されていなければセッションの削除
	session_destroy();
	unset($_SESSION);

	break;

ログアウトをリクエストされた場合

case LOGOUT:

	// ログアウト完了のメッセージを用意

	$message = 'ログアウトしました。';

	// セッションの削除
	session_destroy();
	unset($_SESSION);

	break;

閲覧用のページが指定された場合

default:

	// define('FILE_PATTERN', FILES_PATH . '/files/%s.php');
	// .php ファイルは FILES_PATH/files/ にある

	$path = sprintf(FILE_PATTERN, $match[1]);

	// 指定のページが存在する場合

	if(file_exists($path)){

		// ログインしていれば

		if(isset($_SESSION['user_id'])){

			// そのページを表示して終了

			header('Content-Type: text/html; charset=UTF-8');
			require_once($path);
			return;

		}

		// ログインしていなければ、ログインを促すメッセージを準備

		$message = sprintf('%sをご覧になるにはログインが必要です。', $match[1]);

	}else{ // 指定のページが存在しない場合

		// ページが見つからない404ページを読み込んで終了

		header('HTTP/1.0 404 Not Found');

		$file_404 = sprintf(FILE_PATTERN, PAGE_404);
		require_once($file_404);
		return;

	}

	break;

大体、このような流れで処理しています。

動作はデモページ http://php-oauth-sample.dwm.me/ でご確認ください。

実際のファイルはhttp://php-oauth-sample.dwm.me/twitter_oauth_sample.zip からダウンロードできます。(ライブラリの twitteroauth は同梱していません)

PHPのmb_convert_encodingを使って文字コードを検出する方法

PHPには文字コードを変換してくれるmb_convert_encodingという関数があります。

元の文字コードがわからなくても自動判別をしてくれるようになっているんですが、それでうまく判別できない時に mb_convert_encoding 関数を利用して、文字コードを調べる方法です。

mb_convert_encoding 関数

string mb_convert_encoding( $str, $to_encoding, $from_encoding );

$str に変換したい文字列
$to_encoding に変換後の文字コード
$from_encoding に変換前の文字コード を指定します。

$from_encoding には配列またはカンマ区切りの文字列で文字コードを複数指定できます。複数指定した場合は順番に試していって該当する文字コードが適用されます。

例えば、Shift_JISのWebページを $buff に読み込んで($buffの中にShift_JISの文字列)

$string = mb_convert_encoding( $buff, ‘UTF-8’, ‘SJIS’ );

とすれば、$string にはUTF-8に変換されたWebページが入ります。
(PHPのマニュアルにはSJISと書いてありますが、試してみたらShift_JISでも大丈夫でした)

変換前の文字コードがわからない場合は、$from_encoding を省略すると、そこに mb_internal_encoding() の結果が割り当てられます。(内部文字エンコーディングが割り当てられます)

また言語設定が Japanese の場合は、$from_encoding を auto にすると “ASCII,JIS,UTF-8,EUC-JP,SJIS” が割り当てられます。(PHP: サポートされる文字エンコーディング – Manual

$from_encoding を省略したり、auto を指定したりしてうまくいけばいいんですが、うまくいかない場合は $from_encoding を正しく指定しなければいけません。

読み込んだ文字列の文字コードがわからない場合はどうすればいいか?

次の方法を試してみたら、うまく行きました。$str に変換したいけど文字コードがわからない文字列が入っています。

$to_encoding = 'UTF-8';
$from_encoding = null;

foreach(array('UTF-8','SJIS','EUC-JP','ASCII','JIS') as $charcode){
	if(mb_convert_encoding($str, $charcode, $charcode) == $str){
		$from_encoding = $charcode;
		break;
	}
}

if(!$from_encoding){
	echo 'ERROR: 文字コードが判別出来ませんでした。';
	return;
}

if($to_encoding == $from_encoding){
	echo $str;
	return;
}

echo mb_convert_encoding($str, $to_encoding, $from_encoding);

mb_convert_encoding($str, $charcode, $charcode) == $str

例えば $str が ‘UTF-8’ だった場合、mb_convert_encoding($str, ‘UTF-8’, ‘UTF-8’) の結果は、元の $str と同じになるはずです。

そうなった場合には、$from_encoding に’UTF-8’を代入して break します。

foreach を抜けても $from_encoding が null のままだったなら、array(‘UTF-8′,’SJIS’,’EUC-JP’,’ASCII’,’JIS’) の中には該当文字コードがなかった事になるのでエラーを表示して終了します。

もし、$from_encoding と $to_encoding が同じなら、変換の必要がないので、そのまま $str を表示します。

そうでない場合は $str を ‘UTF-8’ に変換して表示します。

文字列判別関数を作っておくと便利

たとえば、次のような文字列判別関数を作っておくと便利です。

function hoge($str){
	foreach(array('UTF-8','SJIS','EUC-JP','ASCII','JIS') as $charcode){
		if(mb_convert_encoding($str, $charcode, $charcode) == $str){
			return $charcode;
		}
	}

	return null;
}

WordPressで勝手にaddslashesされてバックスラッシュがついてしまう件

この記事を書いた際のWordPressのバージョンは3.9.1です。

バージョン5.4.0以前のPHPでは、magic_quotesがONに設定されていると、POSTやGETで送ったデータの中にある全ての ' (シングルクオート)、 " (ダブルクオート)、 \ (バックスラッシュ)、NULL文字の4つに自動的にバックスラッシュがついてエスケープされます。

バージョン5.4.0から削除されたんですが、まだそれ以前のPHPは多いです。

そこで、get_magic_quotes_gpc関数で、magic_quotesがONかOFFか判別して、ONの場合にはstripslashesを使ってバックスラッシュを削除します。

if(get_magic_quotes_gpc()){
    $hoge = stripslashes($_POST['hoge']);
}else{
    $hoge = $_POST['hoge'];
}

ところが、今作っているプラグインで、この作業をしてもPOSTで送ったtextareaのデータにバックスラッシュがついたまま消えません。しかも、テスト環境のmagic_quotesはOFFなのにです。

調べたところ、「POST送信時にバックスラッシュが付加される現象: wordpressメモ」というページを見つけました。

結論を書くと、WordPressを使っているとmagic_quotesの設定は関係なく、$_GET,$_POST,$_COOKIE,$_SERVERの4つは、必ずaddslashesされてバックスラッシュがつきます。

詳細は「POST送信時にバックスラッシュが付加される現象: wordpressメモ」に書いてあるとおりなんですが、wp-includes/load.phpに、wp_magic_quotesという関数があります。

ここで、上記のget_magic_quotes_gpc関数で、magic_quotesが有効になっているか調べ、ONの場合にはstripslashesを使ってバックスラッシュを削除しています。

そして、それで終わらずに、add_magic_quotesという関数を実行しています。

function wp_magic_quotes() {
	// If already slashed, strip.
	if ( get_magic_quotes_gpc() ) {
		$_GET    = stripslashes_deep( $_GET    );
		$_POST   = stripslashes_deep( $_POST   );
		$_COOKIE = stripslashes_deep( $_COOKIE );
	}

	// Escape with wpdb.
	$_GET    = add_magic_quotes( $_GET    );
	$_POST   = add_magic_quotes( $_POST   );
	$_COOKIE = add_magic_quotes( $_COOKIE );
	$_SERVER = add_magic_quotes( $_SERVER );

	// Force REQUEST to be GET + POST.
	$_REQUEST = array_merge( $_GET, $_POST );
}

この、add_magic_quotesは何かというとWordpressの独自関数で、実体はwp-includes/functions.phpにあります。

この中で、addslashes関数を使って、$_GET,$_POST,$_COOKIE,$_SERVERの全てのデータをエスケープしています。

function add_magic_quotes( $array ) {
	foreach ( (array) $array as $k => $v ) {
		if ( is_array( $v ) ) {
			$array[$k] = add_magic_quotes( $v );
		} else {
			$array[$k] = addslashes( $v );
		}
	}
	return $array;
}

wp_magic_quotes関数はどこで呼び出されているかというと、wp-settings.phpの242行目です。(バージョン3.9.1の場合)

なのでWordPressを使う場合は、magic_quotesの設定に関係なく、stripslashesが必要でした。

これがわかるまで随分ハマりました。「POST送信時にバックスラッシュが付加される現象: wordpressメモ」を書かれた方には感謝です。

ブログ移転で rel="canonical" は有効か?

Yahoo!JAPANの「カスタムサーチ」のページで「内容が重複する複数のURLをまとめるよう、検索エンジンに通知」という記事を見つけました。

サイトには、URLは異なるがウェブページの内容は変わらないというケースがあります。たとえば、URLがトラッキング用のパラメータを含んでいる場合や、URLに含まれるパラメータをもとに閲覧環境によってウェブページの表示を最適化しているような場合です。

  • http://www.example.com/products?trackingid=feed
  • http://www.example.com/products?sessionid=hgjkeor2
  • http://www.example.com/products?printable=yes&trackingid=footer

このような場合、検索エンジンのインデックスに個々のURLで登録されると、同じ内容のウェブページが検索結果に表示されたり、同じ内容のウェブページであるにもかかわらず、各URLにクローラーが巡回し、サーバーに負荷をかけてしまう、また、サイト内の巡回が効率的に行われないといった不都合が発生することがあります。このようなことを避けるため、サイト管理者がウェブページのなかで<link>タグを利用して、重複を避けるように検索エンジンに通知する方法が用意されています。

具体的にはヘッダに下の様に書いたとすると「このページはhttp://www.example.com/と同じ内容です」と宣言したことになるようです。

<link rel=”canonical” href=”http://www.example.com/”>

ということは、ここでひとつの疑問が出てきます。

別のドメインでもcanonicalは適用できるのだろうか?

その回答はGoogleの「rel=”canonical” 属性について – ウェブマスター ツール ヘルプ」にありました。

rel=”canonical” 属性を使用して、まったく別のドメイン上の URL を指定できるか

リダイレクトは簡単に設定できない場合があります。たとえば、サーバーサイドのリダイレクトを作成できないウェブ サーバーを使用しているときに、新しいドメイン名に移行する場合などです。このような場合には、rel=”canonical” リンク要素を使用して、このドメインでインデックス登録の対象になる正確な URL を指定できます。rel=”canonical” リンク要素は絶対的な指示ではなく、ヒントとしてみなされますが、Google では可能な限りこの要素を追跡します。

つまり、解釈が間違っていなければ「rel=”canonical” 属性」は、次のような使い方が出来るということでしょうか?

ブログやサイトを別ドメインに移転した。今までに作ったページも新しいドメインにコピーした。今後は新ドメインで運用するが、これまでの遺産として古いドメインにも記事を残しておきたい。しかし、そうすると後から作った新しいサイトがコピーサイトとして検索エンジンに低く扱われる可能性がある。

そうならないために、今までの(古い)サイトのHTMLに「rel=”canonical” 属性」でコピー済みの新しいサイトの記事を指定しておく。そうすることで検索エンジンには、新サイトを優先するよう要望する。

そうすると「Google では可能な限りこの要素を追跡」してくれるのでしょうか?実は最近、この条件に合致した移転ブログがあります。

パート派遣主婦のぐーたら子育て生活 (長いので以下「パー…」と省略表記)

「パー…」は最近、独自ドメインを取って「ココロデザイン」というサイトに移転しました。その移転の際のリポートを「技術のタマゴ」というブログに書いています。

ということで「技術のタマゴ」を開いたら、こんな記述が見つかりました。

※rel=”canonical”で重複記事の中で、新記事を一番だと主張する
 という案もありましたが、同一ドメイン間じゃないと
 使えないということで、却下になりました。

rel=”canonical”は使わなかったようです。しかしGoogleのページには、こう書いてあります。「リダイレクトを作成できないウェブ サーバーを使用しているときに、新しいドメイン名に移行する場合などです。このような場合には、rel=”canonical” リンク要素を使用して、このドメインでインデックス登録の対象になる正確な URL を指定できます

そうであるなら「同一ドメイン間じゃないと使えない」というのは間違いじゃないでしょうか?

追記:今ご覧いただいているこのブログの移転で試したところrel=”canonical”は適用されました。尚「技術のタマゴ」の記事は訂正されています。

しかし、rel=”canonical” で新サイトを指定できるとしても、ブログで実際にどうやればいいのかという問題があります。すべての記事のヘッダにrel=”canonical”属性で、新しいURLを書き込まないといけませんが、多くの無料ブログのテンプレートでは1記事ごとに違うURLを指定する事は出来ません。

記事の中に書いたら記事毎にURLを変えられるがどうだろうか、と思い調べてみたのですが「rel=canonicalタグによくある5つの間違い | 海外SEO情報ブログ」によると「間違い その5: <body>内のrel=”canonical”」という項目で次のように書いてありました。記事の中に書いても有効化されないようです。

rel=”canonical”はHTMLドキュメントの<head>セクションだけに出現しなければならない。またHTMLの読み取りの問題を避けるために<head>の先頭にできるだけ近いほうがいい。

<body>にrel=”canonical”が出てきたときは無視する。

ファンブログハックの記事から

ファンブログハックに「「タイトルタグの重複」をjavascriptで回避する(ファンブログ、FC2)」という記事があります。JavaScriptを使ってHTMLに書いてあるのと違うタイトルに書きかえたところ、Googleには書きかえたタイトルが反映されたという内容です。

実はこのブログ(移転前の旧ブログ)は今月1日に新しいスキンに変えているんですが、「site:fanblogs.jp/ayzfqir5」をググるとおもしろい結果が出ます。このブログのスキンには、タイトルにページ番号やカテゴリー名を表示させる機能がありません。なので、JavaScriptで書き加えているんですが、スキンを変えて数日で、検索結果に反映されるようになりました。ファンブログハックの記事の通りです。

赤枠内はスクリプトで追加したものですが、見事に反映されています。

検索結果にはJavaScriptで修正したタイトルが表示されている

ヘッダにrel=”canonical” 属性を追加するスクリプト

ということは、JavaScriptで、ヘッダにrel=”canonical” 属性を追加すれば、検索に反映されるのではないだろうか?つまり、新しいサイトを優先して表示してくれるのではないだろうか?と仮定できるのです。もし、それが出来るなら「パー…」にnoindexを指定する必要はなくなります。

とは言ったものの、ページ数がたくさんある時は大変です。ちなみにページが数ページしかない時には、下のようなスクリプトで実現できると思います。

実際にスクリプトを使った処理で rel=”canonical” が適用されるかは不明です。このブログの実際の移転では、この下の「新ドメイン側の.htaccessに旧URLを渡して、振り分ける」方法を使って適用に成功しました。

前提条件として、古い記事のURLはすべて「http://old_url.com/archive/xxx」というフォーマットになっているものとします。URLの中のxxxは数字です。この数字ごとに新しいURLをヘッダに書き加えます。新しいサイトでは、記事の内容ごとに違うブログに分けていたとしても大丈夫です。

<script type="text/javascript"><!--
var urls={
	"001":"http:\/\/hoge.example.com/page_001.html",
	"002":"http:\/\/moge.example.com/page_002.html",
	"003":"http:\/\/kome.example.com/page_003.html"
};

var key=window.location.href.match(/\/archive\/([0-9]+)/);

if(key){
	document.write('\<link rel="canonical" href="'+urls[key[1]]+'"\>');
}
//-->
</script>

これをheadタグの中で、なるべく上の方に書けばいいと思います。古い記事のURLがhttp://old_url.com/archive/001なら「http://hoge.example.com/page_001.html」というように、それぞれの新URLをヘッダに追記します。

しかし、記事が何百ページもあったら上のやり方では無理があります。

新ドメイン側の.htaccessに旧URLを渡して、振り分ける

大抵の無料ブログでは.htaccessは使えないし、だからこの記事のような困りごとになるわけですが、新しいサイトでは.htaccessが使えます。だから新サイトの.htaccessにパラメータを渡してリダイレクトさせればいいわけです。

旧ブログのヘッダには次のように書き加えます。スクリプトで追記したタグが有効に作用するとは断言出来ないので完全なURLを書き込むようにしました。

<link rel=”canonical” href=”http://new_domain.com/url_converter.php?old_url={記事のURLを書き出すタグ}“>

{記事のURLを書き出すタグ}はブログによって違いますが

  • ライブドアブログなら {$ArticlePermalink$}
  • FC2ブログなら <%topentry_link>

となります。

参考:
独自タグ一覧(変数) – livedoor ブログ ヘルプセンター
FC2ヘルプ | FC2ブログ | テンプレート用 変数一覧

これで古いブログのURLが「http://old_url.com/archive/001」である記事が開かれると、ヘッダの中に次のタグが書き込まれます。URLエンコードが出来ないのが心配ですが、このブログでは無事にパラメータを渡せました。

<link rel=”canonical” href=”http://new_domain.com/url_converter.php?old_url=http://old_url.com/archive/001“>

Googleなどの検索エンジンがこのタグを読んだら、新ドメインのサーバに「http://new_domain.com/url_converter.php?old_url=http://old_url.com/archive/001」を問い合わせます。新しいサーバには古いURLがパラメータで渡されるので、それを判断材料に新しいURLに誘導すれば(リダイレクトさせれば)いいわけです。

参考記事:GET送信とは?

サーバー側のリダイレクト手順

ということで、サーバー側で.htaccessを使ってリダイレクトと書きましたが、何百個もある記事を、一定のパターンに沿って書きかえるならともかく、記事ごとに違うブログに振り分けるとなると.htaccessでは大変です。こうなると、旧URLと新URLを紐付けたデータベースを作って処理する方が簡単ではないでしょうか。作るデータベースのtableはこんな仕様になると思います。

CREATE TABLE url(
	old varchar(256),
	new varchar(256)
);

このテーブルに、URL対応表を作ってから、PHP等のインタープリタでアクセスすればいいと思います。アクセスする際は「SELECT new FROM url WHERE old=’xxx’」というクエリで新URLを取れるでしょう。

PHPの場合のソースは次のようになります。

<?php
$host='localhost';
$db='detabase_name';
$user='user_name';
$pwd='password';

$old=$_GET['old_url'];

if(empty($old)){
	header('HTTP/1.1 400 Bad Request');
	return;
}

try{
	$pdo=new PDO('mysql:host='.$host.'; dbname='.$db,$user,$pwd);
	$stmt=$pdo->query('SELECT new FROM url WHERE old="'.$old.'"');
	$row=$stmt->fetch(PDO::FETCH_ASSOC);

	if($row){
		header('HTTP/1.1 301 Moved Permanently'); 
		header('Location: '.$row['new']);
	}else{
		header('HTTP/1.1 404 Not Found'); 
	}
}catch (PDOException $e){
	header('HTTP/1.1 500 Internal Server Error');
}

$pdo=null;

余談ですがPHPコードのみからなるファイルでは、終了タグは書かない方がいいようです。
Zend Framework: Documentation: PHP ファイルの書式 – Zend Framework Manualより

PHP コードのみからなるファイルでは、終了タグ (“?>”) は決して含めてはいけません。これは必須なものではなく、 終了タグを省略することで、ファイルの最後にある空白文字が出力に影響することを防ぎます。

私はHTMLとの混合PHPの場合でも、最後がPHPの場合はいつも閉じタグを書きませんが、エラーが出たことはありません。

このブログが実際に使っているスクリプト

データベースを使わない場合は、連想配列で処理します。実際にこのブログで使っているのはデータベースを使わない以下のスクリプトです。

<?php
if(empty($_GET['old_url'])||!preg_match('|/ayzfqir5/archive/(\d+)|',$_GET['old_url'],$m)){
	header('HTTP',TRUE,400);
	exit();
}

$url_table=array(
	"826" => "1264",
	"825" => "1263",
	"824" => "1262",
	(中略)
	"12" => "587",
	"9" => "586",
	"6" => "585"
);

if(empty($url_table[$m[1]])){
	header('HTTP',TRUE,404);
	exit();
}

header('Location: http://dwm.me/archives/'.$url_table[$m[1]],TRUE,301);

$url_tableの左側のキーが旧ブログのURL、右が新しいブログのURLに当たります。

古いブログのURLは http://fanblogs.jp/ayzfqir5/archive/xxx/0 という形でした。xxx の部分だけ記事毎に違う数字です。その数字部分を正規表現で抽出してキーにします。新しいこのブログのURLは http://dwm.me/archives/xxx という形ですが、古いブログとは番号が違います。それを古いブログのキーと照らし合わせて抜き出し http://dwm.me/archives/ にくっつけて、header関数で返しています。

このブログは実際の移転でこの手法を使い、rel=”canonical”を発動させる事に成功したのですが、どのような効果を発揮したかを別記事「ブログの引越しで rel="canonical" が適用されたら検索結果とアクセス数がこう変わった」にまとめました。

PDOでストアドプロシージャの結果取得

一つ前の記事で書いたように、MySQLのストアドプロシージャを使ったアプリを書いたんですが、
ストアドプロシージャで作ったデータをPHPのPDOでどう取得すればいいのかわからなかったので覚書き。

参考までにつくったのは下のものです。ちなみに「ふっかつのじゅもん」はアクセス誘導のために
別の場所に書いてあったんですが、ここにも書いておきます。「モウイッカイ」です。


ハロウィンの運命






入力したデータをRESTで送って、返却値をJavaScriptで黄色い枠内に書き込んでいます。

送信するデータは以下のとおりです。

  • id : 結果表示場所のid
  • hidden : 送信ボタンの上に追加する表示部分のid
  • type : halloween、xmas、otherのどれかを指定(他でも使いまわすため)
  • name : 入力された名前
  • pass : ふっかつのじゅもん(オプション)

上記パラメータを受信したサーバー側で、JavaScriptを生成。それを送り返して来ます。
その戻ってきたデータを読み込んだブラウザで、スクリプトが作動して結果が表示されます。

サーバー側のPHPソースですが、パラメーター取得方法とエラー処理、それから生成されるJavaScriptに関しては、今回のテーマと違うので省略します。

プロシージャの仕様は以下のとおりです。

fortune(OUT _string VARCHAR(2048), IN _user VARCHAR(128), IN _type VARCHAR(128), IN _pass VARCHAR(1024));

コマンドで使うときは以下のカンジです。

call fortune(@str, “username”, “halloween”, “password”);
SELECT @str;

以下、PDOでストアドプロシージャのデータを取得する部分のソースです。

ちなみにコメントアウトしてある3行ですが、環境によって必要であったり、なかったりします。
実際に設置したのはCORESERVERですが、CORESERVERでは、ないと文字化けしますのでコメントアウトは外して有効にしてあります。ただ、ローカルのLinux mintにaptで構築した環境では不要でした。

$host="localhost";
$dbname="xxxxxxxxxxxxx";
$username="xxxxxxxxxxxxx";
$password="xxxxxxxxxxxxx";

$base="var pos=document.getElementById('%s');以下省略";

try{
	$pdo=new PDO("mysql:host=$host;dbname=$dbname",$username,$password);

	//以下の3行は環境によっては必要
	//$pdo->query("SET character_set_client=utf8");
	//$pdo->query("SET character_set_connection=utf8");
	//$pdo->query("SET character_set_results=utf8");

	$stmt=$pdo->prepare("call fortune(@str,?,?,?)");

	$stmt->bindValue(1,$name,PDO::PARAM_STR);
	$stmt->bindValue(2,$type,PDO::PARAM_STR);
	$stmt->bindValue(3,$pass,PDO::PARAM_STR);

 	$stmt->execute();

	header("Content-type: text/javascript; charset=UTF-8");
	printf($base,$id,$pdo->query('SELECT @str')->fetchColumn());
}catch(PDOException $e){
	header("HTTP/1.1 500 Internal Server Error");
}

$pdo=null;

prepareを使って処理するということがわかれば簡単なのですが、そこにたどり着くまで苦労しました。

bindValueの第3引数には、第2引数の型を指定します。
今回はたまたま文字列型だけでしたが、PHPのマニュアルページに色々な型が書いてあります。

PHP implodeで簡単表作成

CodeZineの「PDOでサクサクDB開発」という記事に、下のコードが掲載されていた。

try {
    $pdo = new PDO("mysql:host=localhost; dbname=pdotest",
                   "root", "pass");
    $stmt = $pdo->query("SELECT * FROM CD");

    while($row = $stmt->fetch(PDO::FETCH_ASSOC)){
        echo implode(", ", $row) . PHP_EOL;
    }
} catch (PDOException $e){
    var_dump($e->getMessage());
}

$pdo = null;

この中のimplodeという関数を知らなかった。

implodeとは何ぞや?」と思い、調べてみたらチョー簡単にHTMLのテーブルが作れることがわかった。

“PHP implodeで簡単表作成”の続きを読む