Ext JS に関しては,新しい記事は Sunvisor Lab. ExtJS 別館 にあります。そちらもよろしくお願いいたします。

サイボウズ ガルーン2 と Google カレンダーを同期する

以前,「職場のサイボウズの予定をiPodで持ち歩く」という記事にて,サイボウズとGoogleを連携し,それをiPhoneやiPod touchで持ち歩くということをやってみました。これは,「サイボウズofffice8と同期するツール暫定版」という記事のおかげで実現できたことでした。対象のサイボウズ製品はOffice8でした。これはすごく便利だったのですが....

うちの職場のサイボウズがガルーン2に変わっちまいました。

この連携方法はスクレイピングという手法を使っているため,対象のシステムが変わると当然ながらうまく動きません。ただ,今はサイボウズ謹製のiPhoneアプリであるKunai Lite で予定を参照することはできるので,それを使ってしばらくは,同期をせずに使っていました。でも,やっぱり不便なんですよ。個人的な予定とサイボウズの予定が同じカレンダーに表示されるという以前の環境がとても使いやすかったんです。

で,一念発起(てほどでもないか)してガルーン2版を作ってみました。どうせならということで,xPathを使ってやってみましたよ。

なお,ガルーン3のデモサイトで試してみたところ,ガルーン3でもちゃんと動くようです。

仕様

  1. サイボウズガルーン2に登録されたスケジュールを当月の前後2ヶ月分取得してiCalendar形式で出力します。
  2. 予定のメモは取得しません。(予定詳細にアクセスするとなぜかエラー...)

ソースコード

サーバ上のあるフォルダに,index.phpとして次のコードを保存します。冒頭のdefineの部分はご自分の環境に合わせて変更して下さいね。

<?php
$sstitle = @$_REQUEST['data'];
define("SCHEDULE_TITLE", $sstitle);
define('BASE_URL', 'http://hoge.hoge.com/cgi-bin/cbgrn/grn.cgi');
define('USER_NAME', 'username');
define('PASSWORD', 'password');

// 月間予定表ページを取得
$url = getMonthlyPageUrl(time());
$pageMonthly = getContents($url);
// スケジュールリストを得る
$scheduleList = getScheduleList($pageMonthly);
// iCalenderで出力
showCalender($scheduleList);

/**
 * ページのコンテンツを得る
 */
function getContents($pUrl)
{
	// ログインとともにページを取得
	$query = http_build_query(
		array(
		    "_system"=> '1',
		    "_account" => USER_NAME,
		    "_password" => PASSWORD
		),
		"", "&"
	);
	$header = array(
	    "Content-Type: application/x-www-form-urlencoded",
	    "Content-Length: ".strlen($query)
	);
	$options = array(
	    "http" => array(
	        "method"  => "POST",
	        "header"  => implode("\r\n", $header),
	        "content" => $query
	    )
	);
	$context = stream_context_create($options);
	// HTMLデータ取得
	$contents = file_get_contents($pUrl, false, $context);
	// 文字コード指定のmetaタグを埋め込む(文字化け防止)
	$contents = str_replace('<head>', '<head>'."\n".'<meta http-equiv="content-type" content="text/html; charset=utf-8" />', $contents);
	return $contents;
}

/**
 * 月間予定ページのURL
 */
function getMonthlyPageUrl($pDate)
{
	$result = BASE_URL . '/schedule/personal_month?bdate=' . date('Y-m', $pDate) . '-01';
	return $result;
}

/**
 * htmlを解析して予定リストを得る
 */
function getScheduleList($pHtml)
{
	$dom = new DOMDocument;
	@$dom->loadHTML($pHtml);
	$xpath = new DOMXPath($dom);
	$resultNode = $xpath->query("//table[@class='calendar']");
	$scheduleList = array();
	foreach ($resultNode as $node) {
		$nodeList2 = $xpath->query("//a[contains(@href, 'view')]", $node);
		foreach($nodeList2 as $node2) {
			$scheduleList[] = getScheduleRec($node2);
		}
	}
	return $scheduleList;
}

/**
 * ノードオブジェクトから予定レコードを得る
 */
function getScheduleRec($pNode)
{
	$result = splitSchedule($pNode->nodeValue);
	$detailUrl = $pNode->getAttribute('href');
	$r = splitUrl($detailUrl);
	$result['id'] = $r['event'];
	$result['date'] = $r['bdate'];
	return $result;
}

/**
 * エレメントの内容から予定の時刻などを得る
 */
function splitSchedule($pTitle)
{
	$result = array();
	$datePtn = '[0-9][0-9]?\/[0-9][0-9]?';
	$timePtn = '[0-9][0-9]?:[0-9][0-9]';
	if( preg_match("/^$timePtn-$timePtn/", $pTitle, $matches) == 1 ) {
		$time = current($matches);
		$result['subject'] = substr($pTitle, strlen($time));
		$time = preg_split('/-/', $time);
		$result['starttime'] = $time[0];
		$result['endtime'] = $time[1];
	} elseif( preg_match("/^$timePtn/", $pTitle, $matches) == 1 ) {
		$time = current($matches);
		$result['starttime'] = $time;
		$result['endtime'] = $time;
		$result['subject'] = substr($pTitle, strlen(current($matches)));
	} elseif( preg_match("/^$datePtn-$datePtn/", $pTitle, $matches) == 1 ) {
		$date = current($matches);
		$result['subject'] = substr($pTitle, strlen($date));
		$date = preg_split('/-/', $date);
		$result['startdate'] = str_replace('/', '-', $date[0]);
		$result['enddate'] = str_replace('/', '-', $date[1]);
	} else {
		$result['subject'] = $pTitle;
	}
	return $result;
}

/**
 * URLを分解して?以下の内容を配列で取得する
 */
function splitUrl($pUrl)
{
	$r = parse_url($pUrl);
	parse_str($r['query'], $r2);
	return $r2;
}

/**
 * iCalender形式で出力
 */
function showCalender($pList)
{
	header('Content-Type: text/calendar; charset=utf-8');
	showClenderHeader();
	foreach( $pList as $rec ){
		showCalenderRec($rec);
	}
	showCalenderFooter();
}

/**
 * ヘッダー部を出力
 */
function showClenderHeader()
{
	echo 'BEGIN:VCALENDAR'. "\n";
	echo 'PRODID:' . SCHEDULE_TITLE. "\n";
	echo 'VERSION:2.0'. "\n";
	echo 'METHOD:PUBLISH'. "\n";
	echo 'CALSCALE:GREGORIAN'. "\n";
	echo 'X-WR-CALNAME:' . SCHEDULE_TITLE . "\n";
	echo 'X-WR-CALDESC:' . SCHEDULE_TITLE . "\n";
	echo 'X-WR-TIMEZONE:Asia/Tokyo'. "\n";
}

/**
 * フッター部を出力
 */
function showCalenderFooter()
{
	echo 'BEGIN:VTIMEZONE'. "\n";
	echo 'TZID:Asia/Tokyo'. "\n";
	echo 'BEGIN:STANDARD'. "\n";
	echo 'DTSTART:19700101T000000'. "\n";
	echo 'TZOFFSETFROM:+0900'. "\n";
	echo 'TZOFFSETTO:+0900'. "\n";
	echo 'END:STANDARD'. "\n";
	echo 'END:VTIMEZONE'. "\n";
	echo 'END:VCALENDAR'. "\n";
}

/**
 * データ部を出力
 */
function showCalenderRec($pRec)
{
	$day = strtotime($pRec['date']);
	if( $pRec['startdate'] ){
		// 期間予定
		$year = date('Y', $day);
		$stDay = strtotime($year.'-'.$pRec['startdate']);
		if( $day != $stDay ){
			// 日付と一致していないところは登録の必要なし
			return;
		}
		$endDay = strtotime($year.'-'.$pRec['enddate']);
		if( $stDay > $endDay ){
			$endDay = strtotime(($year+1).'-'.$pRec['enddate']);
		}
		$dtOption = ';VALUE=DATE';
		$dtStart = date('Ymd', $day);
		$dtEnd = date('Ymd', $endDay+60*60*24);
	} elseif( $pRec['starttime'] ) {
		// 時間指定あり
		$dtOption = '';
		$stTime = strtotime($pRec['date'] . ' ' . $pRec['starttime']);
		$endTime = strtotime($pRec['date'] . ' ' . $pRec['endtime']);
		$dtStart = date('Ymd\THi00', $stTime);
		$dtEnd = date('Ymd\THi00', $endTime);
	} else {
		// 全日予定
		$dtOption = ';VALUE=DATE';
		$dtStart = date('Ymd', $day);
		$dtEnd = date('Ymd', $day+60*60*24);	// 翌日
	}
	echo 'BEGIN:VEVENT'. "\n";
	echo 'UID:' . SCHEDULE_TITLE . '-' . date('Ymd', $day) . $pRec['id'] . "\n";
	echo 'DESCRIPTION:' . $pRec['description'] . "\n";
	echo 'DTSTART;TZID=Asia/Tokyo' . $dtOption . ':' . $dtStart . "\n";
	echo 'DTEND;TZID=Asia/Tokyo' . $dtOption . ':'. $dtEnd . "\n";
	echo 'SUMMARY:'. $pRec['subject']. "\n";
	echo 'END:VEVENT'. "\n";
}

.htaccessは,次のように(名古屋の社長さんのと同じです)

RewriteEngine on

RewriteRule ^([0-9,a-z,A-Z,_]+).ics+ index.php?data=$1

Googleカレンダーの設定

あとは,これらをおいたサーバーにアクセスすると,カレンダーを取ってこれます。

  1. Googleのカレンダーの「他のカレンダー」の右下にある「追加」をクリックします。
  2. 「URLで追加」を選びます。
  3. URL欄に,「http://<ファイルを設置したURL>/hoge.ics」 と入力して「カレンダーを追加」をクリックします。

hoge.icsのhogeのところはなんでもいいです。その名前がGoogleカレンダーに表示されます。

軽い解説

やっていることはだいたい名古屋の社長さんと同じようなことなのですが,予定の詳細からメモ欄を取ってくることはしていません。というかできていません。サーバー上のPHPから予定詳細にアクセスするとなぜかエラーがでるのです。同じURLをブラウザで入力すると表示されるので,クッキーかなにかがからんでいるのでしょうか。僕の場合には必要ないと言うことで,この機能はあきらめました。詳しいことが知りたければKunai Liteがあるわけですし。

ここでは,僕一人が使うスクリプトなので,スクリプトにユーザー名やパスワードを埋め込んでいますが,

https://www.hoge.com/hoge.ics?user=hogehoge&pass=password

のようにgetメソッドで渡せるようにしてやると,複数人で利用できるスクリプトになります。セキュリティ的にアレですけれど,スクリプトを設置するサーバーにSSLが効いていれば,通信自体は暗号化されます。ただGoogleカレンダーの設定画面ではパスワードが丸見えですが...

function getContents

サイボウズガルーン2にアクセスして,月間予定表のhtmlテキストを取ってきます。このあたりは前出のブログの記事を参考にして作りました。

function getScheduleList

htmlを解析して,配列にスケジュールのリストを返します。htmlの解析には,DOMDocumentとDOMXPathを使っています。これらのクラスのおかげで非常に簡単にスケジュールの入ったエレメントを取り出すことができました。

  1. まずclass属性がcalendarなtableタグを探し
  2. その中にあるaタグでhref属性にviewという文字を含んでいるもの

を対象にしています。対象タグのテキストとhref属性の内容からスケジュールデータを抽出しています。

サイボウズ ガルーン2 では,月間予定のページには表示はされないものの前後2ヶ月の予定が内包されているようです。これは非常に好都合なので,それらをそのまま出力することにしました。こういう仕様でなければ,前後の月の予定を順番に処理する必要がありました。

function showCalender

スケジュールリストに入ったデータを,iCalender形式で出力します。全日の予定/時刻のある予定/期間予定ごとに処理をしています。

あとがき

サイボウズのスケジュールをGoogleと連携してなにが嬉しいか。iPhone や Android でスケジュールを持って歩けるのです。で,Google カレンダーに登録した予定は,サイボウズの他のユーザーには見えませんので,スケジュールの使い分けをして,なおかつ,統合して,そしてそれを持って歩けるのです。便利ですよ。

200行にも満たないコードで,この処理が書けたことに驚いています。おまけにそのほとんどはサイボウズの日付出力情報をうまくiCalendarに対応させるためとかiCalendarを出力するためのもの。html解析の部分はわずかです。DOMDocumentとDOMXPathの威力を感じました。簡単ながらもサブルーチンに分けているので,こんど職場のサイボウズがガルーン3にバージョンアップしても修正は楽だと思います。

ただやっぱりスクレイピングというのは,相手のhtmlコードにべったりになってしまうんで,ガルーン2でもマイナーバージョンの違いでうまく動かないことはあると思います。ガルーン2のhtmlはしっかり構造化されていない(Office8の方がマシ)のでよけいその可能性はあります。 

職場のガルーン2をいじれるような立場にいるのなら,ガルーン2のSmartyテンプレートをいじって,予定のデータを拾いやすい構造にカスタマイズしちゃってからこれを作るとさらにいいかもです。でもそんな立場の人なら,最初からMySQLにアクセスしてiCalendar形式で出力するPHPコードをがりがり書くという方がいいかもしれません。

トラックバック


URL から "-MoIyadayo" を削除してトラックバックを送信してください。
トラックバックは承認後に表示されます。