Bloggerのフィードウィジェット - 泥沼日誌

2007年9月13日

Bloggerのフィードウィジェット

先月の末あたりからか、このブログに組み込んである『最近のコメント』一覧で、コメント本文が表示されなくなりました。ブログのコメントフィードをBlogger標準のフィードウィジェットで表示してたんですが、フィードデータのフォーマットが変わったか、ウィジェット処理ルーチンがおかしいみたいで、ブログやテンプレートの設定だけではどうにもうまく行きません。

他のBlogger上のブログでも同じ症状なので、そのうちBlogger側で対処してくれると思ったのですが、一向に直る様子もないため、いろいろ不満もあったので自前でフィードウィジェットをでっち上げることにしました。

興味のない方は、ここから先は読み飛ばしてください。


■調査

まず、基本情報の収集から。

BloggerのHELPを調べてもいいんですがほとんど英語なので、いつも参考にさせていただいている クリボウの Blogger Tips新 Blogger のフィードURLとパラメータ という投稿から。

フィードURLのパラメータによって、JSONPでデータを取得できることがわかりました。これならRSSやATOMのXMLを扱わなくてもいいので、javascriptだけで簡単にできそうです。
具体的なURLは以下のようになります。
http://m-nagase.blogspot.com/feeds/comments/full
?alt=json-in-script&callback=getcommentscallback&max-results=50

URLの?以降のパラメータはこのようになります。
alt=json-in-script           JSONP形式を指定 alt=jsonならJSON形式
alt=rssならRSS 2.0 alt=atomならATOM 1.0
callback=getcommentscallback JSONPのコールバック関数名
max-results=50 フィード中のデータ数

これをhtmlソースに組み込むには、スクリプトの外部ソース取り込みとして記述します。
<script charset="utf-8" src="http://m-nagase.blogspot.com/feeds/comments/full
?alt=json-in-script&callback=getcommentscallback&max-results=50"
type="text/javascript"></script>

そしてこのJSONデータを受け取るためのコールバック関数を用意します。
var comments;
function getcommentscallback(obj) { comments=obj; }

これで comments という変数にコメントフィードのデータが展開されます。コールバック関数の中でフィードデータを表示出力してもいいのですが、後ほどのことを考えていったん変数に展開しておきます。

フィードデータの中身に関しては詳しく解説しませんが、javascriptのオブジェクトとして展開されているので、以下のようなプロパティーでフィード中の個々のデータにアクセスできます。
comments.feed.entry[i]
iは0~comments.comments.feed.entry.length-1まで

360といっしょ。Bloggerに最新のコメントを10件表示する の投稿を参考にさせていただきました。


■設計

さて、Blogger標準のフィードウィジェットを使っていて不満だったのが、データが5個までしか表示できないのと、どの投稿へのコメントなのかがわからないという点でした。
データの個数はパラメータで設定できるので良しとして、コメント先がどこなのかを表示できないか調べてみました。

コメントフィードのJSONPのデータをダウンロードして解析してみると、title というそれらしいノードがありました。javascriptのプロパティーだとこのようになります。
comments.feed.entry[i].title.$t

しかし、このプロパティーの中身は空でした。RSSやATOM形式のデータも調べてみましたが、どれも title の中身は空でした。

そこで、同じくJSONP形式で投稿フィードデータを取得し、コメントフィード中の link プロパティーにあるURLから、コメントがつけられた投稿のタイトルを拾ってみることにしました。
JSONPで投稿フィードを取得するには、以下のようなURLとなります。
http://m-nagase.blogspot.com/feeds/posts/full
?alt=json-in-script&callback=getpostscallback&max-results=10

同じように、スクリプトの外部ソース取り込みとして記述します。
var posts;
function getpostscallback(obj) { posts=obj; }

<script charset="utf-8" src="http://m-nagase.blogspot.com/feeds/posts/full
?alt=json-in-script&callback=getpostscallback&max-results=10"
type="text/javascript"></script>

そして中身を解析すると、コメントフィードの comments.feed.entry[j].link[0].href と投稿フィードの posts.feed.entry[i].link[0].href が対応していることがわかりました。あとは、コメントフィードに一致する投稿フィード中のタイトルを取り出せばいいわけです。
コールバック関数でJSONデータを変数に代入するだけになっているのは、こういう理由があったからなのです。


さて、ここまで来てちょっと欲が出てきました。どうせ投稿フィードも取得しているなら、最近の投稿一覧を表示してそこにコメントをぶら下げて、それぞれのリストに投稿やコメントへのリンクを設定してあげればいいかと。
イメージはこんな感じです。
[最近の投稿]
・続.WR250R (2)
欲しいけどね。 初物に手を出してえらい苦労してるから...
nagase - 09/12 18:10
まさか…鬼コーチ様、ついにご購入ですか?!
Anonymous - 09/12 17:56
・相撲とモトクロス
・レースの運営
・第6回 ワコーズカップエンデューロ (5)
セロー250さんご苦労様です。 昨日のうちに筋肉痛来ましたか? ...
nagase - 09/11 15:40
お疲れ様です。私も走っていました。 1週目走った時は、田んぼみた...
Anonymous - 09/11 14:47

ようするに、投稿をリストにしてその投稿へのコメントを子リストにしてしまえば、現在のブログの盛況ぶりが一目瞭然です。まあ、あまりコメントをくれる人もいませんが...

これをHTMLのソースで書くと、以下のようになります。
<ul>
<li><a href="投稿のURL">続.WR250R (2)</a>
<ul>
<li><a href="コメントのURL">欲しいけどね。 初物に手を出してえらい</a></li>
<li><a href="コメントのURL2">まさか…鬼コーチ様、ついにご購入ですか?!</a></li>
</ul>
</li>
<li><a href="投稿のURL2">相撲とモトクロス</a></li>
<li><a href="投稿のURL3">レースの運営</a></li>
</ul>

このようなHTMLソースを出力するjavascriptのプログラムを書けばいいわけです。
方針が決まれば、ゴリゴリとプログラムを書いていきます。

まず、投稿リストの作成はこのようなコードになります。
var postlist='<ul>';
for(var i=0;i<posts.feed.entry.length;i++){
postlist+='<li>';
postlist+=('<a href="'+posts.feed.entry[i].link[0].href+'">');
postlist+=(posts.feed.entry[i].title.$t+'</a>');
postlist+='</li>';
}
postlist+='</ul>';

これでpostlistという変数に、投稿リストのHTMLソースが格納されます。
document.write(postlist);として、HTMLドキュメントに出力してあげればよいのです。

リストに投稿のタイトルだけ表示するのも寂しいので、アンカーリンクのtitle属性に、投稿本文を入れてあげれば、マウスがリンクの上にきたときに、ヒント表示で本文が表示されます。(ブラウザによって動作が異なりますが)
投稿本文は長いので先頭の128文字か、Blogger にも「続きを読む」機能
を参考に組み込んでみた『続きを読む』機能の冒頭文のどちらか短い方をtitle属性に設定することにします。
このブログでは続きを読むのマークとして『►』の文字をつけているため、この直前で文字列をぶった切ればいいわけです。
参考コードは以下のようになります。
var postlist='<li>';
// -- a要素のtitle属性に本文を割り当て
// htmlタグを除去
var ttl=posts.feed.entry[i].content.$t.replace(/<.*?>/g,' ');
// 続きを読むの前まで
var k=ttl.indexOf('►');
if(k>=0){ ttl=ttl.substring(0,k); }
// 字数制限
if(ttl.length>128){ ttl=(ttl.substring(0,128)+'...'); }
postlist+=(' <a href="'+posts.feed.entry[i].link[0].href+'" title="'+ttl+'">');
postlist+=(posts.feed.entry[i].title.$t+'</a>');


コメントリストを作成する際にも、スペースの都合上コメント内容を全て表示するわけにはいかないので、コメント本文は32文字程度でぶった切ります。
また、コメントが増えてくるとリストが長くなって見にくくなるため、普段はコメント部分を消しておいて、どこかをクリックするとコメントが表示されればいいかなと思い、ちょっと小細工をしました。以下のような関数を用意し、コメントのリストに固有のidを付けて、クリックされるごとに指定idのスタイルシートのdisplayプロパティーを切り替えてあげます。
function DisplaySwitchB(id) {
var obj=document.getElementById(id);

if(obj.style.display=='none'){
obj.style.display='block';
}else{
obj.style.display='none';
}
}

投稿とコメント一覧のHTMLソースはこんな感じになります。
<ul>
<li><a href="投稿のURL">投稿タイトル</a>
<a href="javascript:DisplaySwitchB('commentlist0')"
title="コメントを展開">(2)</a>
<a href="#" onclick="DisplaySwitchB('commentlist0');return(false);"
title="コメントを展開">(2)</a>
<ul id="commentlist0" style="display:none;">
<li><a href="コメントのURL">コメント内容</a></li>
<li><a href="コメントのURL2">コメント内容2</a></li>
</ul>
</li>
</ul>

javascriptのコードは以下のようになります。
for(var i=0;i<posts.feed.entry.length;i++){
postlist+='<li>';

...

// -- コメントリストを付加
var cmntid=('commentlist'+i);
var cmnt='<ul id="'+cmntid+'" style="display:none;">';
var k=0;
for(var j=0;j<comments.feed.entry.length;j++){
// 投稿URLと一致するコメントを抽出
if(comments.feed.entry[j].link[0].href.indexOf(
posts.feed.entry[i].link[0].href)>=0){
cmnt+=('<li><a href="'+comments.feed.entry[j].link[0].href+'">');
// htmlタグを除去
var temp=comments.feed.entry[j].content.$t.replace(/<.*?>/g,' ');
// 字数制限
if(temp.length>32){ temp=(temp.substring(0,32)+'...'); }
cmnt+=(temp+'</a><br />');
cmnt+=(comments.feed.entry[j].author[0].name.$t
+' - '+RFCtoDateStrS(comments.feed.entry[j].published.$t)+'</li>');
k++;
}
}
cmnt+='</ul>';
// コメントが存在する時のみリストを追加
if(k>0){
// コメント表示ON/OFFスイッチ
var cmntfunc="javascript:DisplaySwitchB('"+cmntid+"')";
postlist+=(' <a href="'+cmntfunc+'">('+k+')</a>');
var cmntfunc='onclick=DisplaySwitchB("'+cmntid+'");return(false);';
postlist+=(' <a href="#" '+cmntfunc+'">('+k+')</a>');
postlist+=cmnt;
}
}
postlist+='</li>';

この中で RFCtoDateStr*() という関数はフィードデータ中の日付・時刻情報(RFC3339)を、自分の好きな形式の文字列に変換する関数です。ソースはあとで掲載します。

追記:やっぱりステータスバーに関数名が丸見えはまずそうなので、onclick属性に変更しました。


■試験

幸いフィードデータがJSONPなので、外部ドメインでも問題なくデータが取得できます。ということで最初はローカルのHTMLファイルでテストします。

テスト環境は、Windows2000 SP4 上の Firefox 2.0.0.6、Opera 9.23、Internet Explorer 6 SP1 です。Firefoxでは、エラーコンソールにcssやjavascriptのエラーメッセージが詳しく表示されるほか、開発用の拡張機能が多数あって、webサイトのテストをするには大変便利です。
HTMLやスクリプトのソース編集は秀丸ですね。市販やフリーで出回っているweb開発ツールは、性に合わないので使えません。

テスト中に、
HTMLの文字コードと外部スクリプトの文字コードが違う場合どうなるのか?
というのが気になって実験してみました。

HTMLをShift_JISにして、JSONPはUTF-8でテストしてみたら、IE6では見事沈没。FirefoxとOperaでは問題なく動きました。
解決策が無いのか検索してみたら、scriptタグの属性にcharset="utf-8"のように文字コードを明示してやることでIE6でも動作することがわかりました。
これで自分のサイトもUTF-8に書き換えずに組み込めそうです。

一通りコードを書いて動作することを確認し、いよいよブログに組み込みます。
Bloggerのページから マイレポート > レイアウト > テンプレート > ページ要素 > ページ要素を追加 と進んで、その中から HTML/JavaScript をブログに追加します。
ページ要素の編集を選んで、エディタからコードをコピ&ペーストし保存します。

さて、本番環境で表示して見たら...動かない。

気を取り直して、ブラウザにロードされたソースを見てみると、スクリプトコードが壊れてます。ずーっとローカルでテストしていて、そのままの状態のFirefoxで貼り付けたのがいけなかったのか、Bloggerのページ要素の編集で見てみてもコードが壊れていました。
いったんWindowsをシャットダウンし、再起動後スクリプトコードをコピー&ペーストすると、今度はちゃんと動きました。

とりあえず、手持ちのFirefox、Opera、IEで動作することを確認。IE7とかmacのSafariの表示だけならスクリーンショット.jpでも確認できた。

やれやれ完成かと、見栄えのためにテンプレートのスタイルシートをいじっていたら、投稿本文のヒント表示が『►』マークの前で切れてない。またもやブラウザにロードされたソースを見てみると、『►』の文字が変なコードにエスケープされていた。

どうやら、Blogger側でページ要素のデータをXMLで保存しているため、都合の悪そうな文字をエスケープしているのではと推測。とあれば、あらかじめスクリプトコードでエスケープしておけば良いのではと思い、\u25BA としてみた。

やっと正常に動いた。

この程度、朝飯前と思っていたら、次の日の朝飯前までかかってしまった。


■ソースコード

古くさいコード記述で恐縮です。Bloggerユーザーのみ対象ですが、良ければ参考にでもしてください。

長い行は途中で折り返しているので、エラーになる場合は連結してください。
ソース中の m-nagase.blogspot.com という部分は、ご自分のブログのアドレスに置き換えてください。

このコードの利用に関して連絡は不要です。
このコードを利用されて、万が一損害を被っても保証はできません。

・追記 2007.11.09 ver0.20

投稿を50件取得し、10項目ごとにページを切り替えて表示出来るようにしました。
タイトル部分をクリックすると、投稿本文とコメントの表示をON/OFFできるようにしました。

・追記 2007.12.03 ver0.30

ページ一ナビゲーションの変更と、記事およびコメントの全開閉操作を追加しました。

<script type="text/javascript">
<!--
var bfCommentFlag=true; // コメントの付加 ON/OFF
var bfPageItems=10; // 一度に表示する投稿数
var bfPageNumber=0; // 表示中ページ
var bfsq="'";

// 投稿本文フィードのコールバック
var bfPosts;
function bfgetpostscallback(obj) { bfPosts=obj; }

// コメントフィードのコールバック
var bfComments;
function bfgetcommentscallback(obj) { bfComments=obj; }

// RFC3339 -> M/D hh:mm
function bfRFCtoMDhm(str) {
return (parseInt(str.substring(5,7),10)
+'/'+parseInt(str.substring(8,10),10)
+' '+str.substring(11,16));
}

// RFC3339 -> M/D
function bfRFCtoYMD(str) {
return (parseInt(str.substring(0,4),10)
+'/'+parseInt(str.substring(5,7),10)
+'/'+parseInt(str.substring(8,10),10));
}

// ブロックの表示ON/OFF
function bfToggleDisplay(id) {
var obj=document.getElementById(id);
if(obj.style.display=='none'){
obj.style.display='block';
}else{
obj.style.display='none';
}
}

// 全て展開
function bfExpandAll() {
for(var i=bfPageNumber;
(i<bfPosts.feed.entry.length)&&(i<(bfPageNumber+bfPageItems));
i++){
var postid=('postitem'+i);
var commentid=('commentitem'+i);
var obj;

if(obj=document.getElementById(postid)){ obj.style.display='block'; }
if(obj=document.getElementById(commentid)){ obj.style.display='block'; }
}
}

// 全て閉じる
function bfCloseAll() {
for(var i=bfPageNumber;
(i<bfPosts.feed.entry.length)&&(i<(bfPageNumber+bfPageItems));
i++){
var postid=('postitem'+i);
var commentid=('commentitem'+i);
var obj;

if(obj=document.getElementById(postid)){ obj.style.display='none'; }
if(obj=document.getElementById(commentid)){ obj.style.display='none'; }
}
}


// 投稿リストを表示
function bfDispPosts(page) {
var listhtml='';
// 表示位置の正規化
bfPageNumber=page;
if(bfPageNumber<0){ bfPageNumber=0; }
if(bfPageNumber>(bfPosts.feed.entry.length-1)){
bfPageNumber=(bfPosts.feed.entry.length-1); }

// ページナビゲーション
var pmax=Math.floor(bfPosts.feed.entry.length/bfPageItems);
var pidx=Math.floor(bfPageNumber/bfPageItems);
listhtml='<div style="float:left; text-align:left; '+
'margin:0 0.25em 0.5em 0.25em">Page ';
for(i=0;i<pmax;i++){
if(pidx==i){
listhtml+=((i+1)+' ');
}else{
listhtml+=('<a href="#" onclick=bfDispPosts('+
(i*bfPageItems)+');return(false);>'+(i+1)+'</a> ');
}
}
listhtml+='</div>';
listhtml+=('<div style="float:right; text-align:right;">'+
' <a href="#" onclick="bfExpandAll();return(false)" '+
'title="全て展開">□</a> '+
'<a href="#" onclick="bfCloseAll();return(false)" '+
'title="全て閉じる">×</a></div>');
listhtml+='</div><div style="clear:both;"></div>';

// 投稿リスト作成
listhtml+='<ul>';
for(var i=bfPageNumber;
(i<bfPosts.feed.entry.length)&&(i<(bfPageNumber+bfPageItems));
i++){

// コメントリスト作成
var commenthtml='';
var commentid=('commentitem'+i);
var comments=0;

if(bfCommentFlag){
commenthtml='<ul style="display:none; margin:0.5em 0 0 0.5em; list-style-type:none;" '+
'id="'+commentid+'">';
// 取得したコメントフィードから投稿IDに一致するコメントを収集
for(var j=0;j<bfComments.feed.entry.length;j++){
if(bfComments.feed.entry[j].link[0].href.indexOf(bfPosts.feed.entry[i].link[0].href)>=0){
commenthtml+=('<li><span>▲</span> <a class="bfcommenttextanchor" href="'+
bfComments.feed.entry[j].link[0].href+'" title="コメントページへジャンプ">');
// コメント文からHTMLタグを除去
var temp=bfComments.feed.entry[j].content.$t.replace(/<.*?>/g,' ');
if(temp.length>40){ temp=(temp.substring(0,40)+'...'); }
commenthtml+=(temp+'</a>');
commenthtml+=('<div style="text-align:right; color:#999999;">'+
bfComments.feed.entry[j].author[0].name.$t+
' - '+bfRFCtoMDhm(bfComments.feed.entry[j].published.$t)+
'</div></li>');
comments++;
}
}
commenthtml+='</ul>';
}

// 投稿本文
var posthtml='<li>';
var postid=('postitem'+i); // 投稿ID
// 投稿展開用関数
var postfunc='onclick="bfToggleDisplay('+bfsq+postid+bfsq+');return(false);"';
// 本文からHTMLタグを除去
var posttext=bfPosts.feed.entry[i].content.$t.replace(/<.*?>/g,' ');
var k=posttext.indexOf('\u25BA'); // 続きを読むがあればそこまで
if(k>=0){ posttext=posttext.substring(0,k); }
// 最大256文字
if(posttext.length>256){ posttext=(posttext.substring(0,256)+'...'); }
posthtml+=('<span >■</span> <a href="#" '+
postfunc+' title="投稿を展開">'+
bfPosts.feed.entry[i].title.$t+'</a>');

// コメントリンク
if(comments>0){
var commentfunc=('onclick="bfToggleDisplay('bfsq+commentid+bfsq+');return(false);"');
posthtml+=(' <a href="#" '+
commentfunc+' title="コメントを展開">('+comments+')</a>');
}
// 本文
posthtml+=('<div style="display:none; margin:0.5em 0 0 0.5em;" id="'+postid+'">'+
'<a class="bfposttextanchor" href="'+
bfPosts.feed.entry[i].link[0].href+
'" title="投稿ページへジャンプ">'+posttext+'</a>'+
'<div style="text-align:right; color:#999999;">'+
bfRFCtoYMD(bfPosts.feed.entry[i].published.$t)+
'</div></div>');
// コメントリスト
if(comments>0){
posthtml+=commenthtml;
}
posthtml+='</li>';
listhtml+=posthtml;
}
listhtml+='</ul>';

// HTMLを出力
document.getElementById('recentposts').innerHTML=listhtml;
}
//-->
</script>
<script charset="utf-8"
src="http://m-nagase.blogspot.com/feeds/posts/full
?alt=json-in-script&callback=bfgetpostscallback&max-results=50"
type="text/javascript"></script>
<script charset="utf-8"
src="http://m-nagase.blogspot.com/feeds/comments/full
?alt=json-in-script&callback=bfgetcommentscallback&max-results=100"
type="text/javascript"></script>

<div id="recentposts">
<script type="text/javascript">bfDispPosts(0);</script>
<noscript>JavascriptをONにしてください m(_ _)m</noscript>
</div>


HTML/JavaScript ウィジェットのエディターを使ってコードを入力する場合、コード部分をコメントで囲むと、< や > などの文字をエスケープしなくても、正常に動作するようです。

テンプレートのスタイルシートに、以下の部分を追加します。
.bfposttextanchor {
color:$textcolor;
text-decoration:none;
}

.bfposttextanchor:hover,.bfposttextanchor:focus {
color:$hovercolor;
text-decoration:underline;
}

.bfcommenttextanchor {
color:$textcolor;
text-decoration:none;
}

.bfcommenttextanchor:hover,.bfcommenttextanchor:focus {
color:$hovercolor;
text-decoration:underline;
}


以上

4 件のコメント:

nagase さんのコメント...

コメント展開の関数呼び出しを、onclick属性に変更しました。

nagase さんのコメント...

ver0.20に進化しました。

nagase さんのコメント...

w3mから投稿てすと

nagase さんのコメント...

どうも、これをサイドバーに貼っておくと、検索エンジンの受けが悪いようで、検索順位が圏外になってしまいました。
とうことで、これは本家サイトに組み込んで見ます。