WebSocketを使ってなんちゃってビデオチャットを作ってみた その3

はじめに

前々回前回からの続き。jQueryのWebカメラプラグインよりキャプチャ速度が早いFlashを作成することができたので、これを用いてなんちゃってビデオチャットを作り直しました。とりあえず、今回で完成ということに致します。

実際に動かしたときの様子を録画しましたが、何故か最初の数秒が変な感じになってしまいました(;・∀・)

上の段の映像は自身のWebカメラの映像を描画し、下の段の映像は相手のWebカメラの映像を描画しています。この動画では、ビデオチャットを行っているノートPC同士を向かいあわせに置き、PC同士の間の空間で手を動かしています。ところどころ描画が引っかかる時がありますが、これぐらいのフレームレートとタイムラグならビデオチャットとしてはギリギリ使い物になる気がします。

上の動画を録画したときの環境では、ノートPC*2台とWebSocketサーバは同一ネットワーク内に居た(ネットワークの距離が近い)のでこのような結果になりました。したがって、もっとネットワーク距離が離れている環境ではタイムラグが大きくなると思われます。

このビデオチャットプログラムは以下の通りです。

WebSocketサーバ側のプログラム

これは前々回のコードと変わっていません。

  • videoChatServer.js
var sys = require('sys')
var http = require("http")
var ws = require('./miksago-node-websocket-server/lib/ws/server');
var server = ws.createServer();
const NODE_MAX = 2;  // 接続上限数
var nodeCount = 0;   // 現在の接続数

// WebSocketコネクションが確立したときの処理
server.addListener("connection", function(conn){
  ++nodeCount;
  if (nodeCount > NODE_MAX) {
    conn.close();
    --nodeCount;
  } else {
      sys.log("connected: " + conn.id);

      // データを受信したときの処理
      conn.addListener("message", function (msg) {
          //sys.log(conn.id + ": length is " + msg.length);
          conn.broadcast(msg);
      });
  }
});

server.listen(8000);

Webサーバ側のプログラム

前回作成したswfファイルを以下のファイルと同じ階層に置いてください。

  • index.html
<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8" />
        <script type="text/javascript" src="videoChatClient.js"></script>
        <title>video chat</title>
    </head>
    <body>
        <embed src="MyJSCam.swf" name="MyJSCam" allowScriptAccess="always" width="0" height="0" />
        <hr style="clear:left;">
        <div style="float:left;"><canvas id="myCamera"></canvas></div>
        <div id="myCameraInfo">
            <span id="captureFPS" style="font-size: 20px;">captureFPS</span><br>
        </div>
        <hr style="clear:left;">
        <div style="float:left;"><canvas id="targetCamera"></canvas></div>
        <div id="targetCameraInfo">
            <span id="drawFPS" style="font-size: 20px;">drawFPS</span><br>
        </div>
        <hr style="clear:left;">
        <input name="mode" type="radio" value="" onclick="imageMode=this.value"/>FULL_COLOR
        <input name="mode" type="radio" value="GRAY_SCALE" onclick="imageMode=this.value"/>GRAY_SCALE
        <input name="mode" type="radio" value="RED" onclick="imageMode=this.value"/>RED
        <input name="mode" type="radio" value="GREEN" onclick="imageMode=this.value"/>GREEN
        <input name="mode" type="radio" value="BLUE" onclick="imageMode=this.value"/>BLUE
        <br>
        <input id="inputText" type="text" size="40">
        <input id="sendButton" type="submit" value="send">
        <hr>
        <div id="log"></div>
    </body>
</html>
  • videoChatClient.js
const DISP_WIDTH = 320
const DISP_HEIGHT = 240;
var webcamObj = null;
var myContext = null;
var myCameraImage = null;
var myCanvas = null;
var targetContext = null;
var targetCanvas = null;
var captureFPS = null;
var drawFPS = null;
var ws = null;
var imageMode = ""

function log (msg) {
    var logObj = $("#log");
    logObj.html(msg + "<br>" + logObj.html());
}

// Webカメラ画像をキャプチャしサーバに画像データを送信する関数
function captureLoop () {
    if (interval) clearInterval(interval);
    // Webカメラの画像データ(320x240のピクセルデータ)を取得する
    var captureImageData = webcamObj.capture();
    var pixel = null, sendData = null;
    var pos = 0, r = 0, g = 0, b = 0;

    for (var y=0; y<DISP_HEIGHT; ++y) {
        for (var x=0; x<DISP_WIDTH; ++x) {
            pixel = captureImageData[x][y];
            r = (pixel >> 16) & 0xFF;
            g = (pixel >> 8) & 0xFF;
            b = pixel & 0xFF;
            if (imageMode) {
                var gray = Math.round((r + g + b) / 3);
                switch (imageMode) {
                case "GRAY_SCALE":
                    r = g = b = gray;
                    break;
                case "RED":
                    r = (r > 50 && g*1.5 < r && b*1.5 < r) ? r : gray;
                    g = b = gray;
                    break;
                case "GREEN":
                    g = (g > 50 && r*1.5 < g && b*1.5 < g) ? g : gray;
                    r = b = gray;
                    break;
                case "BLUE":
                    b = (b > 50 && r*1.5 < b && g*1.5 < b) ? b : gray;
                    g = r = gray;
                    break;
                }
            }
            myCameraImage.data[pos + 0] = r;
            myCameraImage.data[pos + 1] = g;
            myCameraImage.data[pos + 2] = b;
            myCameraImage.data[pos + 3] = 0xFF;
            pos += 4;
        }
    }

    myContext.putImageData(myCameraImage, 0, 0);
    $("#captureFPS").html(Math.round(captureFPS()*100)/100 + "fps");
    sendData = {
        type: "img",
        data: myCanvas[0].toDataURL("image/jpeg")  // JPEGでBase64変換
    };
    ws.send(JSON.stringify(sendData));

    interval = setInterval(captureLoop, intervalTime);
}

function parseData (rawData) {
    var recieveData = JSON.parse(rawData);
    var func = null;
    switch (recieveData["type"]) {
    case "msg":
        func = writeMessage;
        break;
    case "img":
        func = drawImage;
        break;
    }
    func(recieveData["data"]);
}

function createFPS () {
    var oldTime = new Date();
    return function () {
        var newTime = new Date();
        var ps = 1000 / (newTime - oldTime);
        oldTime = newTime;

        return ps;
    }
}

function drawImage (data) {
    var img = new Image();
    img.src = data;
    img.onload = function () {
        targetContext.drawImage(img, 0, 0);
        $("#drawFPS").html(Math.round(drawFPS()*100)/100 + "fps");
    };
}

function writeMessage (msg) {
    log(msg);
}

function sendMessage (msg) {
    var sendData = {
        type: "msg",
        data: msg
    };
    if (ws.send(JSON.stringify(sendData))) {
        log("&lt;You&gt; " + msg);
        return true;
    }

    return false;
}

// init
$(function () {
    myCanvas = $("#myCamera");
    myCanvas.attr("width", DISP_WIDTH);
    myCanvas.attr("height", DISP_HEIGHT);
    myContext = myCanvas[0].getContext("2d");
    myContext.clearRect(0, 0, DISP_WIDTH, DISP_HEIGHT);
    myCameraImage = myContext.getImageData(0, 0, DISP_WIDTH, DISP_HEIGHT);
    targetCanvas = $("#targetCamera");
    targetCanvas.attr("width", DISP_WIDTH);
    targetCanvas.attr("height", DISP_HEIGHT);
    targetContext = targetCanvas[0].getContext("2d");
    targetContext.clearRect(0, 0, DISP_WIDTH, DISP_HEIGHT);

    ws = new WebSocket("ws://" + location.hostname + ":8000");
    ws.onopen = function () {
        log("connected.");
    };
    ws.onmessage = function (e) {
        parseData(e.data);
    };
    ws.onclose = function () {
        log("disconnected.");
        $("#dnSpeed").html("");
    }

    $("#sendButton").click(function () {
        var inputObj = $("#inputText");
        if (sendMessage(inputObj.val())) {
            inputObj.val("");
        }
    });

    captureFPS = createFPS();
    drawFPS = createFPS();
    webcamObj = document["MyJSCam"];
    interval = setInterval(captureLoop, 1000);
    intervalTime = 70;  // 通信環境に応じて適宜変化させるべき
})

前々回のコードを少し変えました。

Webカメラのキャプチャ画像をHTML5Canvasで描画したあと、その画像をBase64エンコードされた文字列に変換してWebSocketサーバに送信していますが、前々回のコードはPNGフォーマットだったものを今回はJPEGフォーマットに変更しました。Webカメラでキャプチャした画像のようなデータは、PNGよりもJPEGの方がデータサイズを小さくすることができます。

59    myContext.putImageData(myCameraImage, 0, 0);
60    $("#captureFPS").html(Math.round(captureFPS()*100)/100 + "fps");
61    sendData = {
62        type: "img",
63        data: myCanvas[0].toDataURL("image/jpeg")  // JPEGでBase64変換
64    };
65    ws.send(JSON.stringify(sendData));


また、captureLoop()関数をintervalTimeミリ秒の間隔で実行するようなコードに変更しました。私の環境では、70ミリ秒間隔でも特に問題がなかったので変化させていませんが、ちゃんとしたビデオチャットアプリを開発するのならば通信状況に応じて適宜変える必要があります。
# 今回は"なんちゃって"ビデオチャットなので(^_^;)

WebSocketを使ってなんちゃってビデオチャットを作ってみた その2

はじめに

前回の続き。Webカメラのキャプチャ速度が遅いのでActionScriptをいじりました。ついでにJavaScriptで簡単なリアルタイム画像処理をする機能も実装しました。以下、その様子を録画した動画です。画像処理の様子はフルスクリーンで再生しないと分かりにくいです(;・∀・)

この動画は、Webカメラの映像をそのまま描画したり、白黒化や赤・緑・青色のみを抽出する画像処理の様子を録画したものです。前回よりもキャプチャ速度が数倍早くなっていることが確認できると思います。ですが、今回はWebSocketサーバとの通信は行っていません。

プラグインの改造

Webカメラの操作ができるようになるjQueryプラグインですが、キャプチャ速度が遅いのでソースコードを見てみました。以下は、そのコードの一部です。

  • jscam.as(214〜234行目)
214 	private static function _stream():Void {
215 
216 		buffer.draw(_root.video);
217 
218 		if (null != stream) {
219 			clearInterval(stream);
220 		}
221 
222 
223 		for (var i = 0; i < 240; ++i) {
224 
225 			var row = "";
226 			for (var j=0; j < 320; ++j) {
227 				row+= buffer.getPixel(j, i);
228 				row+= ";";
229 			}
230 			ExternalInterface.call("webcam.onSave", row);
231 		}
232 
233 		stream = setInterval(_stream, 10);
234 	}

この _stream() 関数は、ディスプレイ機器が映像を描画するときの動きに似ています。216行目でカメラが写している画像を取り出し、223〜231行目でカメラ画像の走査線を1本づつJavaScriptのメソッド(webcam.onSave)に渡しています。つまり、1フレームの画像データ(320×240px)を240回に分けてActionScriptからJavaScriptに渡しています。
この部分がボトルネックになっていると考えたので、1回で1フレームの画像データをActionScriptからJavaScriptに渡すように改造することにしました。

プラグインの公式ページに、

Recompile the Flash binary

If you've made changes to the code or did just adjust the size of the video in the XML specification file, you can easily recompile the swf file from Linux console with the provided Makefile. You are required to install the two open source projects swfmill and mtasc that can be easily installed using apt-get under Debian/Ubuntu:

apt-get install swfmill mtasc
vim src/jscam.xml
make

と書かれていたので、swfmillとmtascをインストールしてmakeしました。MacUbuntuのどちらでもコンパイルに成功しましたが、私の環境では生成されたswfは動きませんでした(ヽ'ω`)
私はActionScriptに関してはHelloWorldに毛が生えた程度の知識しかないので、なぜ動かないのか皆目見当もつきません。なので、プラグインの改造は諦めて、ネット上にあるサンプルコードを参考にしながら最低限の機能を実装したswfをゼロから作ることにしました。

Webカメラの映像をJavaScriptに渡すActionScript

上記のページのサンプルコードとプラグインのコードを参考にしながら以下のプログラムを書きました。

  • MyJSCam.as
package {
    import flash.display.Sprite;
    import flash.display.BitmapData;
    import flash.media.Camera;
    import flash.media.Video;
    import flash.external.ExternalInterface;

    [SWF(backgroundColor=0xFFFFFF, width="320", height="240")]
    public class MyJSCam extends Sprite {
        private var camera:Camera;
        private var video:Video;
        private var bmd:BitmapData;
        private var pixelsData:Array;
        
        function MyJSCam () {
            camera = Camera.getCamera();

            if (camera != null) {
                camera.setMode(320, 240, 24);  // width, height, fps
                video = new Video(camera.width, camera.height);
                video.attachCamera(camera);
                bmd = new BitmapData(camera.width, camera.height, false, 0xffffff);
                pixelsData = [];
                for (var x:uint=0; x<320; ++x) {
                    pixelsData[x] = new Array(240);
                }
                // swfの領域でカメラ映像を表示するとき必要な処理
                //addChild(video);   
                //video.x = 0;
                //video.y = 0;
            }

            ExternalInterface.addCallback("capture", capture);
        }

        private function capture ():Array {
            bmd.draw(video);

            for (var x:uint=0; x<320; ++x) {
                for (var y:uint=0; y<240; ++y) {
                    pixelsData[x][y] = bmd.getPixel(x, y);
                }
            }

            return pixelsData;
        }
    }
}

コンパイルにはmxmlcを使いました。

 % mxmlc MyJSCam.as

もし何度もコンパイルするなら、rlwrapとfcshを使うと速くて楽になりますよ。

 % rlwrap fcsh
Adobe Flex Compiler SHell (fcsh)
Version 4.1.0 build 16076
Copyright (c) 2004-2007 Adobe Systems, Inc. All rights reserved.

(fcsh) mxmlc MyJSCam.as
fcsh : コンパイルターゲット ID として 1 を割り当てました
設定ファイル "/usr/local/flex_sdk_4.1/frameworks/flex-config.xml" をロードしています
/Library/WebServer/Documents/webcamTmp2/MyJSCam.swf (992 バイト)
(fcsh) compile 1
設定ファイル "/usr/local/flex_sdk_4.1/frameworks/flex-config.xml" をロードしています
再コンパイル: /Library/WebServer/Documents/webcamTmp2/MyJSCam.as
理由: ソースファイルまたは含まれているファイルのいずれかが更新されました。
変更されたファイル : 1 影響を受けるファイル : 0
/Library/WebServer/Documents/webcamTmp2/MyJSCam.swf (992 バイト)

MyJSCam.swfの実行例

コンパイルして生成したMyJSCam.swfと同じ階層に以下のHTMLファイルを置いて下さい。

  • index.html
<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8" />
        <script type="text/javascript">
            function init () {
                webcamera = document["MyJSCam"];
                cnvsObj = document.getElementById("cnvs");
                cntxtObj = cnvsObj.getContext("2d");
                cntxtObj.clearRect(0, 0, 320, 240);
                cameraImg = cntxtObj.getImageData(0, 0, 320, 240);
                captureFPS = createFPS();
                imageMode = "";
                interval = setInterval(captureLoop, 1000);
            }

            function createFPS () {
                var oldTime = new Date();
                return function () {
                    var newTime = new Date();
                    var ps = 1000 / (newTime - oldTime);
                    oldTime = newTime;

                    return ps;
                }
            }

            function captureLoop () {
                if (interval) clearInterval(interval);

                imageData = webcamera.capture();
                document.getElementById("captureFPS").innerHTML = Math.round(captureFPS()*100)/100;
                var pos = 0;
                for (var y=0; y<240; ++y) {
                    for (var x=0; x<320; ++x) {
                        var pixel = imageData[x][y];
                        var r = (pixel >> 16) & 0xFF;
                        var g = (pixel >> 8) & 0xFF;
                        var b = pixel & 0xFF;
                        if (imageMode) {  // FULL_COLOR モード以外のとき
                            var gray = Math.round((r + g + b) / 3);
                            switch (imageMode) {
                            case "GRAY_SCALE":  // 白黒にする
                                r = g = b = gray;
                                break;
                            case "RED":    // 赤色を抽出する
                                r = (r > 50 && g*1.5 < r && b*1.5 < r) ? r : gray;
                                g = b = gray;
                                break;
                            case "GREEN":  // 緑色を抽出する
                                g = (g > 50 && r*1.5 < g && b*1.5 < g) ? g : gray;
                                r = b = gray;
                                break;
                            case "BLUE":   // 青色を抽出する 
                                b = (b > 50 && r*1.5 < b && g*1.5 < b) ? b : gray;
                                g = r = gray;
                                break;
                            }
                        }
                        cameraImg.data[pos + 0] = r;
                        cameraImg.data[pos + 1] = g;
                        cameraImg.data[pos + 2] = b;
                        cameraImg.data[pos + 3] = 0xFF;
                        pos += 4;
                    }
                }
                cntxtObj.putImageData(cameraImg, 0, 0);

                interval = setInterval(captureLoop, 0);
            }
        </script>
    </head>
    <body onload="init()">
        <embed src="MyJSCam.swf" name="MyJSCam" allowScriptAccess="always" width="0" height="0" />
        <span id="captureFPS">captureFPS</span>
        <div><canvas id="cnvs" width="320" height="240"></canvas></div>
        <input name="mode" type="radio" value="" checked onclick="imageMode=this.value"/>FULL_COLOR
        <input name="mode" type="radio" value="GRAY_SCALE" onclick="imageMode=this.value"/>GRAY_SCALE
        <input name="mode" type="radio" value="RED" onclick="imageMode=this.value"/>RED
        <input name="mode" type="radio" value="GREEN" onclick="imageMode=this.value"/>GREEN
        <input name="mode" type="radio" value="BLUE" onclick="imageMode=this.value"/>BLUE
    </body>
</html>

この例では、MyJSCam.swfのcapture()メソッドを呼び出してカメラ画像データ(320×240px)を取得し、HTML5canvasを使ってWebブラウザ上に描画します。描画するとき、モードが選択されていれば画像処理(白黒にする、赤・緑・青色の抽出)を行います。

実行するときは前回と同様に、Flashのプライバシー設定でカメラへのアクセスを許可してから行ってください。

おわりに

今回作成したMyJSCam.swfを用いることでWebカメラのキャプチャ速度が向上しました。前回のに比べるとだいぶ滑らかになりましたが、上記の実行例はWebSocketサーバとの通信をまったく行っていません。なので、次回はMyJSCam.swfを前回のプログラムに適用してみようと思います。

本当は以下のライブラリを使って、石仮面の画像をリアルタイムに顔の上に重ねる族長(オサ)モードの実装をしたかったんですが、優先順位を考えて今回は諦めました(´・ω・`)ショボーン

WebSocketを使ってなんちゃってビデオチャットを作ってみた

はじめに

以下のページでjQueryWebカメラを操作できると知ったので、WebSocketを使ってなんちゃってビデオチャットを書いてみました。
WebSocketサーバに接続した2名で1対1のビデオチャットが出来ます。送受信するデータはカメラの映像とテキストのみで、音声データの送受信には対応していません。

WebScoketサーバ側のプログラム

今回はnode.jsを使ってサーバ側のプログラムを書きました。

  • videoChatServer.js
var sys = require('sys')
var http = require("http")
var ws = require('./miksago-node-websocket-server/lib/ws/server');
var server = ws.createServer();
const NODE_MAX = 2;  // 接続上限数
var nodeCount = 0;   // 現在の接続数

// WebSocketコネクションが確立したときの処理
server.addListener("connection", function(conn){
  ++nodeCount;
  if (nodeCount > NODE_MAX) {
    conn.close();
    --nodeCount;
  } else {
      sys.log("connected: " + conn.id);

      // データを受信したときの処理
      conn.addListener("message", function (msg) {
          sys.log(conn.id + ": length is " + msg.length);
          conn.broadcast(msg);
      });
  }
});

server.listen(8000);

Webサーバ側のプログラム

ポート番号が同じでなければ、WebサーバとWebSocketサーバは同一ホストでも大丈夫です。今回は80番ポートでWebサーバを、8000番ポートでWebSocketサーバを動かします。
以下のindex.html、videoChatClient.jsと同じ階層にjquery-1.4.4.min.jsとココからダウンロードしたjquery.webcam.jsとjscam_canvas_only.swfを置きます。

  • index.html
<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8" />
        <script type="text/javascript" src="jquery-1.4.4.min.js"></script>
        <script type="text/javascript" src="jquery.webcam.js"></script>
        <script type="text/javascript" src="videoChatClient.js"></script>
    </head>
    <body>
        <div id="camera"></div>
        <div style="float:left;"><canvas id="myCamera"></canvas></div>
        <div id="myCameraInfo">captureFPS</div>
        <hr style="clear:left;">
        <div style="float:left;"><canvas id="targetCamera"></canvas></div>
        <div id="targetCameraInfo">drawFPS</div>
        <hr style="clear:left;">
        <input id="inputText" type="text" size="40">
        <input id="sendButton" type="submit" value="send">
        <div id="log"></div>
    </body>
</html>
  • videoChatClient.js
const DISP_WIDTH = 320;
const DISP_HEIGHT = 240;
var myContext = null;
var myCameraImage = null;
var myCanvas = null;
var targetContext = null;
var targetCanvas = null;
var captureFPS = null;
var drawFPS = null;
var pos = 0;
var ws = null;

function log (msg) {
    var logObj = $("#log");
    logObj.html(msg + "<br>" + logObj.html());
}

function save (data) {
    var col = data.split(";");
    for (var i=0; i<320; ++i) {
        var tmp = parseInt(col[i]);
        myCameraImage.data[pos + 0] = (tmp >> 16) & 0xFF;
        myCameraImage.data[pos + 1] = (tmp >> 8) & 0xFF;
        myCameraImage.data[pos + 2] = tmp & 0xFF;
        myCameraImage.data[pos + 3] = 0xFF;
        pos += 4;  // R, G, B, alpha
    }
    if (pos >= 4 * DISP_WIDTH * DISP_HEIGHT) {
        pos = 0;
        myContext.putImageData(myCameraImage, 0, 0);
        $("#myCameraInfo").html(Math.round(captureFPS()*100)/100);
        var sendData = {
            type: "img",
            data: myCanvas[0].toDataURL()
        };
        if (ws.readyState == ws.OPEN) {
            setTimeout(function () {
                ws.send(JSON.stringify(sendData));
            }, 0);
        }
    }
}

function parseData (rawData) {
    var recieveData = JSON.parse(rawData);
    var func = null;
    switch (recieveData["type"]) {
    case "msg":
        func = writeMessage;
        break;
    case "img":
        func = drawImage;
        break;
    }
    func(recieveData["data"]);
}

function createFPS () {
    var oldTime = new Date();
    return function () {
        var newTime = new Date();
        var ps = 1000 / (newTime - oldTime);
        oldTime = newTime;

        return ps;
    }
}

function drawImage (data) {
    var img = new Image();
    img.src = data;
    img.onload = function () {
        targetContext.drawImage(img, 0, 0);
        $("#targetCameraInfo").html(Math.round(drawFPS()*100)/100);
    };
}

function writeMessage (msg) {
    log(msg);
}

function sendMessage (msg) {
    var sendData = {
        type: "msg",
        data: msg
    };
    if (ws.send(JSON.stringify(sendData))) {
        log("&lt;You&gt; " + msg);
        return true;
    }

    return false;
}

// init
$(function () {
    $("#camera").webcam({
        width: 0,
        height: 0,
        mode: "stream",
        swffile: "jscam_canvas_only.swf",
        onTick: function () {},
        onSave: save,
        onCapture: function () {},
        debug: function () {},
        onLoad: function () {
            webcam.capture();
            captureFPS = createFPS();
            drawFPS = createFPS();
        }
    });

    myCanvas = $("#myCamera");
    myCanvas.attr("width", DISP_WIDTH);
    myCanvas.attr("height", DISP_HEIGHT);
    myContext = myCanvas[0].getContext("2d");
    myContext.clearRect(0, 0, DISP_WIDTH, DISP_HEIGHT);
    myCameraImage = myContext.getImageData(0, 0, DISP_WIDTH, DISP_HEIGHT);
    targetCanvas = $("#targetCamera");
    targetCanvas.attr("width", DISP_WIDTH);
    targetCanvas.attr("height", DISP_HEIGHT);
    targetContext = targetCanvas[0].getContext("2d");
    targetContext.clearRect(0, 0, DISP_WIDTH, DISP_HEIGHT);

    ws = new WebSocket("ws://" + location.hostname + ":8000");
    ws.onopen = function () {
        log("connected.");
    };
    ws.onmessage = function (e) {
        parseData(e.data);
    };

    $("#sendButton").click(function () {
        var inputObj = $("#inputText");
        if (sendMessage(inputObj.val())) {
            inputObj.val("");
        }
    });
})

実行したときの様子

カメラの画像を取得するjQueryプラグインFlashを使っているので、一度カメラへのアクセスを許可する必要があります。
上記のindex.htmlに初めてアクセスするときはvideoChatClient.jsの一部を以下のように編集して下さい。

// init
$(function () {
    $("#camera").webcam({
        width: 0,   // ここを320に変更
        height: 0,  // ここを240に変更
        mode: "stream",
        swffile: "jscam_canvas_only.swf",
        onTick: function () {},
        onSave: save,

そして、Flashの設定でカメラへのアクセスを許可するようにして下さい。このとき、『設定を保存』にチェックを入れるのを忘れないようにしてください。

設定ができたら、以下のようにWebSocketサーバを動かします。

 % node videoChatServer.js

無事に動いたらindex.htmlに2つのPCでアクセスすると以下のようにビデオチャットができるはずです。


上の段の画像は自分のWebカメラの映像で、その隣にはキャプチャ速度(キャプチャ回数/秒)が表示されます。そして、その下の段には相手のWebカメラの映像とそのフレームレートが表示されます。一番下の段には、お互いが入力したテキストメッセージが積まれていきます。

上のスクリーンショットからもわかるように、キャプチャ速度が1秒間に1回以下という遅さのため、ビデオチャットとは呼べないほどカクカクしてます(;・∀・)

おわりに

次はカメラのキャプチャ速度を上げるためにjQueryプラグインをいじってみようかと思ってます。

今回使ったjQueryプラグインFlashJavaScriptを連携させていましたが、W3Cが策定している
HTML Media Captureを使えるようになれば、Flashを一切使わずにビデオチャットが作れるかもしれませんね(^−^)

JavaScriptでバイナリデータを扱う方法について調べた

追記アリ(2010/11/19)

はじめに

今回は以下の2つ、

  1. JavaScriptでバイナリデータの生成
  2. 生成したデータをWebSocketでサーバに送信

がしたかったので、JavaScriptでバイナリデータを扱う方法について調べてみました。
メモ書き程度の内容ですが、調べた結果を記事にまとめてみました。実行環境は以下の通りです。

バイナリデータの生成

今回は『0x00 0x01 0x75 0xF0 0xFF』という5バイトのバイナリデータを生成してみます。

var dataArray = [0x00, 0x01, 0x75, 0xF0, 0xFF];  // 1バイトづつ配列の要素として保持する
//alert(typeof(dataArray));  => "object"
//alert(dataArray.length);  => 5
var binaryData = String.fromCharCode.apply(this, dataArray);  // 5バイトのバイナリデータの生成
//alert(typeof(binaryData));  => "string"
//alert(binaryData.length); => 5
alert(binaryData);

最後のalert()の結果は以下のようになりました。

ChromeFirefoxで表示内容が少し違いますが、変数binaryDataにはそれっぽいデータが入っているようです。

WebSocketサーバ側のプログラム

上記の方法でバイナリデータっぽいものが出来たので、次はこのデータを受信して保存するプログラムを作成します。今回はnode.jsnode-websocket-serverを使いました。

var sys = require('sys');
var ws = require('./node-websocket-server/lib/ws');
var fs = require("fs");
var server = ws.createServer();
var filename = "binary.dat";  // 受信したデータを保存するファイル
server.addListener("connection", function(conn){
    sys.log("connected as: " + conn.id);
    conn.addListener("message", function (msg) {
        sys.log("recieved message: " + msg);
        // 受信したデータをローカルファイルに書きだす
        fs.writeFile(filename, msg, encode="binary", function (err) {
            if (err) throw err;
            sys.log("It's saved!");
        });
    });
});

server.listen(8000);

このプログラムは、WebSocketで受信したデータをバイナリとしてローカルファイルbinary.datに保存します。

WebSocketでデータを送信する

上記のサーバ側のプログラムを動かしたら、以下のようなコードでWebSocketサーバの8000番ポートに対してバイナリデータを送信します。

var ws = new WebSocket("ws://localhost:8000");
ws.send(binaryData);
ws.close();

受信したデータを調べる

サーバ側のプログラムの実行結果が以下のようになれば、ローカルファイルbinary.datに受信したデータが書きこまれていると思います。

15 Nov 17:10:18 - connected as: 7323540100
15 Nov 17:10:20 - recieved message: u〓〓
15 Nov 17:10:20 - It's saved!

binary.datの中身はバイナリデータなのでhexdumpコマンドで中身を確認してみます。

% hexdump binary.dat
0000000 00 01 75 f0 ff                                 
0000005

5バイトのデータ『0x00 0x01 0x75 0xF0 0xFF』が保存されていることが確認できました。

おわりに

実は、今回の方法は『何となくそれっぽいコードを書いたらそれっぽい実行結果が得られた』って感じで、なぜこういう事が出来るのかは理解してないです(+_+;)
バイトオーダーとかってどうなってるのでしょうか?というかこのやり方は適切な方法なんでしょうか?(;一_一)ウーン...


追記(2010/11/19):
id:koichik さんからコメントを頂きました。上記の方法では、たまたまバイナリデータが保存できたようです。ですが、今後(node.jsでbinaryエンコーディングが廃止された場合)は上記の方法ではできなくなると思います/(^o^)\ナンテコッタイ

UPnPのNAT越えについて調べてみた

はじめに

P2Pアプリケーションを作ろう!と思い立ったのでP2Pについて調べていました。すると、NAT(またはNAPT)越えをしないとP2P通信が出来ないそうじゃありませんか!!
ということで、今度はNAT越えについて調べてみるとUPnP、STUN、UDP Hole Punchingという方法があるそうですね。なので、今回はUPnPのNAT越えについて調べてみました。UPnPについては以下のページを参考にしました。
UPnPとポートマッピング(GARAさんのページ)
作業メモ -upnpデバイスの取得-
UPnPを利用してグローバルIPを取得する - 2 | ::Hikaru's blog

ネットワーク環境(自宅)

NTTからレンタルしているモデムUPnPに対応していたので、外部から自宅のPCにアクセスできる(NAT越えができる)ようにしてみました。自宅のネットワーク環境を図で表すと、以下のような感じです。

UPnP対応機器を探し出す

まずは、自宅LAN内のUPnP対応機器(上の図のモデム)を探し出します。以下のM-SEARCHメッセージをマルチキャストしてUPnP対応機器からのレスポンスを調べます。

M-SEARCH * HTTP/1.1
MX: 3
HOST: 239.255.255.250:1900
MAN: "ssdp:discover"
ST: upnp:rootdevice

このM-SEARCHメッセージをマルチキャストするPythonスクリプトを書きました。以下のコードをsearchRootDevice.pyという名前で保存します。

#!/usr/bin/env python
#-*- coding:utf-8 -*-

import socket

M_SEARCH  = 'M-SEARCH * HTTP/1.1\r\n'
M_SEARCH += 'MX: 3\r\n'
M_SEARCH += 'HOST: 239.255.255.250:1900\r\n'
M_SEARCH += 'MAN: "ssdp:discover"\r\n'
M_SEARCH += 'ST: upnp:rootdevice\r\n'
M_SEARCH += '\r\n'

s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
s.settimeout(5)   # 5秒でタイムアウト
s.bind(('', 1900))

# M-SEARCHをマルチキャストする
s.sendto(M_SEARCH, ('239.255.255.250', 1900))

while True:
    try:
        response, address = s.recvfrom(8192)
        print 'from', address
        print response
        print '=' * 40
    except socket.timeout, e: # タイムアウトしたときの処理
        print e
        break

s.close()

このスクリプトを実行すると以下のような結果になりました。

 % ./searcheRootDevice.py
from ('192.168.1.1', 1900)
HTTP/1.1 200 OK
Ext: 
Date: WED, 22 JAN 2070 04:40:41 GMT
ST: upnp:rootdevice
USN: uuid:53563300-0101-0000-000ba233dcd7::upnp:rootdevice
Location: http://192.168.1.1:2869/DeviceDescription.xml        ← ここ大事!
Cache-Control: max-age=600
Server: VxWorks/5.4.2 UPnP/1.0 UPnP-Device-Host/1.0
Content-Length: 0


========================================
timed out

同じメッセージが繰り返し表示されるときもありますが、この結果で注目するのはLocationの値です。ここにはデバイス情報を取得するためにアクセスするURLが書かれています。なので、実際にこのURLにアクセスしてみます。

 % wget http://192.168.1.1:2869/DeviceDescription.xml
--23:28:17--  http://192.168.1.1:2869/DeviceDescription.xml
           => `DeviceDescription.xml'
Connecting to 192.168.1.1:2869... connected.
HTTP request sent, awaiting response... 200 OK
Length: 5,056 (4.9K) [text/xml]

100%[==================================================>] 5,056         --.--K/s             

23:28:17 (161.25 KB/s) - `DeviceDescription.xml' saved [5056/5056]

このファイル(DeviceDescription.xml)の内容は以下のとおりです。

<?xml version="1.0"?>
<root xmlns="urn:schemas-upnp-org:device-1-0">
<specVersion>
<major>1</major>
<minor>0</minor>
</specVersion>
<URLBase>http://192.168.1.1:2869/</URLBase>
<device>
<deviceType>urn:schemas-upnp-org:device:InternetGatewayDevice:1</deviceType>
<friendlyName>ADSL Modem-SV3</friendlyName>
<manufacturer>NTTEAST/NTTWEST</manufacturer>
<manufacturerURL></manufacturerURL>
<modelDescription>ADSL Broadband Router with VoIP function</modelDescription>
<modelName>ADSL Modem-SV3</modelName>
<modelNumber></modelNumber>
<modelURL></modelURL>
<serialNumber></serialNumber>
<UDN>uuid:53563300-0101-0000-000ba233dcd7</UDN>
<UPC></UPC>
<serviceList>
<service>
<serviceType>urn:schemas-upnp-org:service:Layer3Forwarding:1</serviceType>
<serviceId>urn:upnp-org:serviceId:Layer3Forwarding1</serviceId>
<SCPDURL>/Layer3Forwarding.xml</SCPDURL>
<controlURL>/UD/?0</controlURL>
<eventSubURL>/?0</eventSubURL>
</service>
<service>
<serviceType>urn:schemas-microsoft-com:service:OSInfo:1</serviceType>
<serviceId>urn:microsoft-com:serviceId:OSInfo1</serviceId>
<SCPDURL>/OSInfo.xml</SCPDURL>
<controlURL>/UD/?1</controlURL>
<eventSubURL>/?1</eventSubURL>
</service>
</serviceList>
<deviceList>
<device>
<deviceType>urn:schemas-upnp-org:device:WANDevice:1</deviceType>
<friendlyName>WANDevice1</friendlyName>
<manufacturer>NTTEAST/NTTWEST</manufacturer>
<manufacturerURL></manufacturerURL>
<modelDescription>ADSL Broadband Router with VoIP function</modelDescription>
<modelName>ADSL Modem-SV3</modelName>
<modelNumber></modelNumber>
<modelURL></modelURL>
<serialNumber></serialNumber>
<UDN>uuid:53563300-0201-0000-000ba233dcd7</UDN>
<UPC></UPC>
<serviceList>
<service>
<serviceType>urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1</serviceType>
<serviceId>urn:upnp-org:serviceId:WANCommonIFC1</serviceId>
<SCPDURL>/WANCommonInterfaceConfig.xml</SCPDURL>
<controlURL>/UD/?2</controlURL>
<eventSubURL>/?2</eventSubURL>
</service>
</serviceList>
<deviceList>
<device>
<deviceType>urn:schemas-upnp-org:device:WANConnectionDevice:1</deviceType>
<friendlyName>WANConnectionDevice1</friendlyName>
<manufacturer>NTTEAST/NTTWEST</manufacturer>
<manufacturerURL></manufacturerURL>
<modelDescription>ADSL Broadband Router with VoIP function</modelDescription>
<modelName>ADSL Modem-SV3</modelName>
<modelNumber></modelNumber>
<modelURL></modelURL>
<serialNumber></serialNumber>
<UDN>uuid:53563300-0301-0000-000ba233dcd7</UDN>
<UPC></UPC>
<serviceList>
<service>
<serviceType>urn:schemas-upnp-org:service:WANDSLLinkConfig:1</serviceType>
<serviceId>urn:upnp-org:serviceId:WANDSLLinkC1</serviceId>
<SCPDURL>/WANDSLLinkConfig.xml</SCPDURL>
<controlURL>/UD/?4</controlURL>
<eventSubURL>/?4</eventSubURL>
</service>
<service>
<serviceType>urn:schemas-upnp-org:service:WANPPPConnection:1</serviceType>
<serviceId>urn:upnp-org:serviceId:WANPPPConn1</serviceId>
<SCPDURL>/WANPPPConnection.xml</SCPDURL>
<controlURL>/UD/?6</controlURL>
<eventSubURL>/?6</eventSubURL>
</service>
<service>
<serviceType>urn:schemas-upnp-org:service:WANPPPConnection:1</serviceType>
<serviceId>urn:upnp-org:serviceId:WANPPPConn2</serviceId>
<SCPDURL>/WANPPPConnection.xml</SCPDURL>
<controlURL>/UD/?7</controlURL>
<eventSubURL>/?7</eventSubURL>
</service>
<service>
<serviceType>urn:schemas-upnp-org:service:WANPPPConnection:1</serviceType>
<serviceId>urn:upnp-org:serviceId:WANPPPConn3</serviceId>
<SCPDURL>/WANPPPConnection.xml</SCPDURL>
<controlURL>/UD/?8</controlURL>
<eventSubURL>/?8</eventSubURL>
</service>
<service>
<serviceType>urn:schemas-upnp-org:service:WANPPPConnection:1</serviceType>
<serviceId>urn:upnp-org:serviceId:WANPPPConn4</serviceId>
<SCPDURL>/WANPPPConnection.xml</SCPDURL>
<controlURL>/UD/?9</controlURL>
<eventSubURL>/?9</eventSubURL>
</service>
<service>
<serviceType>urn:schemas-upnp-org:service:WANPPPConnection:1</serviceType>
<serviceId>urn:upnp-org:serviceId:WANPPPConn5</serviceId>
<SCPDURL>/WANPPPConnection.xml</SCPDURL>
<controlURL>/UD/?10</controlURL>
<eventSubURL>/?10</eventSubURL>
</service>
<service>
<serviceType>urn:schemas-upnp-org:service:WANPPPConnection:1</serviceType>
<serviceId>urn:upnp-org:serviceId:WANPPPConn6</serviceId>
<SCPDURL>/WANPPPConnection.xml</SCPDURL>
<controlURL>/UD/?11</controlURL>
<eventSubURL>/?11</eventSubURL>
</service>
<service>
<serviceType>urn:schemas-upnp-org:service:WANPPPConnection:1</serviceType>
<serviceId>urn:upnp-org:serviceId:WANPPPConn7</serviceId>
<SCPDURL>/WANPPPConnection.xml</SCPDURL>
<controlURL>/UD/?12</controlURL>
<eventSubURL>/?12</eventSubURL>
</service>
<service>
<serviceType>urn:schemas-upnp-org:service:WANPPPConnection:1</serviceType>
<serviceId>urn:upnp-org:serviceId:WANPPPConn8</serviceId>
<SCPDURL>/WANPPPConnection.xml</SCPDURL>
<controlURL>/UD/?13</controlURL>
<eventSubURL>/?13</eventSubURL>
</service>
</serviceList>
</device>
</deviceList>
</device>
</deviceList>
<presentationURL>http://192.168.1.1/</presentationURL>
</device>
</root>

このファイルの中で大事なのは以下の部分です。

<?xml version="1.0"?>
<root xmlns="urn:schemas-upnp-org:device-1-0">
<specVersion>
<major>1</major>
<minor>0</minor>
</specVersion>
<URLBase>http://192.168.1.1:2869/</URLBase>    ← ここが大事!
<device>
<deviceType>urn:schemas-upnp-org:device:InternetGatewayDevice:1</deviceType>

     省略

<service>
<serviceType>urn:schemas-upnp-org:service:WANPPPConnection:1</serviceType>
<serviceId>urn:upnp-org:serviceId:WANPPPConn1</serviceId>
<SCPDURL>/WANPPPConnection.xml</SCPDURL>
<controlURL>/UD/?6</controlURL>   ← ここが大事!
<eventSubURL>/?6</eventSubURL>
</service>
<service>

上記のURLBaseとcontrolURLの値を用いてWAN側のIPアドレスの取得、ポートマッピングの設定・削除などを行っていきます。

WAN側のIPアドレスの取得

上記の方法でcontrolURLが分かれば、以下のようなSOAPアクションをUPnP対応機器に送信することでWAN側のIPアドレス(グローバル)が取得できます。

POST /UD/?6 HTTP/1.1
HOST: 192.168.1.1:2869
CONTENT-LENGTH: 294
CONTENT-TYPE: text/xml; charset="utf-8"
SOAPACTION: "urn:schemas-upnp-org:service:WANPPPConnection:1#GetExternalIPAddress"

<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:GetExternalIPAddress xmlns:u="urn:schemas-upnp-org:service:WANPPPConnection:1">
</u:GetExternalIPAddress>
</s:Body>
</s:Envelope>

SOAPアクションはtelnetコマンドで192.168.1.1:2869に送信できますが、とりあえずPythonスクリプトを書いてみました。以下のコードをgetExternalIPAddress.pyという名前で保存します。

#!/usr/bin/env python
#-*- coding:utf-8 -*-

import urllib2

HOST = '192.168.1.1'
PORT = 2869
CONTROL = '/UD/?6'
URL = 'http://' + HOST + ':' + str(PORT) + CONTROL

SOAP  = '<?xml version="1.0"?>\r\n'
SOAP += '<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">\r\n'
SOAP += '<s:Body>\r\n'
SOAP += '<u:GetExternalIPAddress xmlns:u="urn:schemas-upnp-org:service:WANPPPConnection:1">\r\n'
SOAP += '</u:GetExternalIPAddress>\r\n'
SOAP += '</s:Body>\r\n'
SOAP += '</s:Envelope>\r\n'

req = urllib2.Request(URL)
req.add_header('Content-Type', 'text/xml; charset="utf-8"')
req.add_header('SOAPACTION', '"urn:schemas-upnp-org:service:WANPPPConnection:1#GetExternalIPAddress"')
req.add_data(SOAP)

res = urllib2.urlopen(req)

print res.read()

このスクリプトを実行すると以下のような結果になりました。

 % ./getExternalIPAddress.py
<s:Envelope
 xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:GetExternalIPAddressResponse xmlns:u="urn:schemas-upnp-org:service:WANPPPConnection:1">
 <NewExternalIPAddress>XXX.XXX.XXX.XXX</NewExternalIPAddress>    ← ここ大事!
</u:GetExternalIPAddressResponse>
</s:Body>
</s:Envelope>

WAN側のIPアドレスが記述されている部分は隠していますが、ちゃんと取得することができました。念のため、レンタルしているモデムにWebブラウザーからアクセスしてWAN側のIPアドレス(モデムではADSLアドレスという表現をしてた)を見てみると、SOAPアクションで取得したアドレスと一致していました。

現在のポートマッピング情報の取得

WAN側のIPアドレス取得と同様に、以下のSOAPアクションを送信することで現在のポートマッピング情報を取得できます。

POST /UD/?6 HTTP/1.1
HOST: 192.168.1.1:2869
CONTENT-LENGTH: 353
CONTENT-TYPE: text/xml; charset="utf-8"
SOAPACTION: "urn:schemas-upnp-org:service:WANPPPConnection:1#GetGenericPortMappingEntry"

<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<m:GetGenericPortMappingEntry xmlns:m="urn:schemas-upnp-org:service:WANPPPConnection:1">
<NewPortMappingIndex>0</NewPortMappingIndex>    ← ここが大事!
</m:GetGenericPortMappingEntry>
</s:Body>
</s:Envelope>

このSOAPアクションのNewPortMappingIndexの値を0から順に変化させることでポートマッピングの情報を取得できます。もし、NewPortMappingIndexの値が登録されているポートマッピングの数より大きいとHTTPステータスコード 500を返すそうです。以下のコードをgetGenericPortMappingEntry.pyという名前で保存します。

#!/usr/bin/env python
#-*- coding:utf-8 -*-

import urllib2

HOST = '192.168.1.1'
PORT = 2869
CONTROL = '/UD/?6'
URL = 'http://' + HOST + ':' + str(PORT) + CONTROL

ID = 0
while True:
    SOAP  = '<?xml version="1.0"?>\r\n'
    SOAP += '<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">\r\n'
    SOAP += '<s:Body>\r\n'
    SOAP += '<m:GetGenericPortMappingEntry xmlns:m="urn:schemas-upnp-org:service:WANPPPConnection:1">\r\n'
    SOAP += '<NewPortMappingIndex>' + str(ID) + '</NewPortMappingIndex>\r\n'
    SOAP += '</m:GetGenericPortMappingEntry>\r\n'
    SOAP += '</s:Body>\r\n'
    SOAP += '</s:Envelope>\r\n'

    req = urllib2.Request(URL)
    req.add_header('Content-Type', 'text/xml; charset="utf-8"')
    req.add_header('SOAPACTION', '"urn:schemas-upnp-org:service:WANPPPConnection:1#GetGenericPortMappingEntry"')
    req.add_data(SOAP)

    try:
        res = urllib2.urlopen(req)
        print res.read()
    except urllib2.HTTPError, e:
        if e.code != 500:
            print e.code
            print e.msg
        break
    print '=' * 40
    ID += 1

このスクリプトを実行すると以下のような結果になりました。

 % ./getGenericPortMappingEntry.py
<s:Envelope
 xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:GetGenericPortMappingEntryResponse xmlns:u="urn:schemas-upnp-org:service:WANPPPConnection:1">
 <NewRemoteHost></NewRemoteHost>
 <NewExternalPort>80</NewExternalPort>
 <NewProtocol>TCP</NewProtocol>
 <NewInternalPort>80</NewInternalPort>
 <NewInternalClient></NewInternalClient>
 <NewEnabled>0</NewEnabled>
 <NewPortMappingDescription>Webサーバ (HTTP)</NewPortMappingDescription>
 <NewLeaseDuration>0</NewLeaseDuration>
</u:GetGenericPortMappingEntryResponse>
</s:Body>
</s:Envelope>

========================================

私は何も設定していませんでしたが、始めから80番ポートに対して何らかの転送設定がされていました。ここで、先ほどのgetExternalIPAddress.pyで取得したWAN側のIPアドレスWebブラウザーからアクセスすると、http://192.168.1.1と同じ画面(モデムの設定画面)が表示されることがわかりました。なので、既に登録された転送設定はWAN側のIPアドレスからでもモデムの設定が行えるようにするためだと思います。

ポートマッピングの設定を行う

以下のSOAPアクションを送信して、モデムの9090番ポートにきたアクセスを自宅PC(192.168.1.3)の9090番ポートに転送するように設定します。

POST /UD/?6 HTTP/1.1
Host: 192.168.1.1:2869
Content-Length: 671
Content-Type: text/xml; charset="utf-8"
Connection: Close
SOAPACTION: "urn:schemas-upnp-org:service:WANPPPConnection:1#AddPortMapping"

<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<m:AddPortMapping xmlns:m="urn:schemas-upnp-org:service:WANPPPConnection:1">
<NewRemoteHost></NewRemoteHost>
<NewExternalPort>9090</NewExternalPort>
<NewProtocol>TCP</NewProtocol>
<NewInternalPort>9090</NewInternalPort>
<NewInternalClient>192.168.1.3</NewInternalClient>
<NewEnabled>1</NewEnabled>
<NewPortMappingDescription>Test</NewPortMappingDescription>
<NewLeaseDuration>0</NewLeaseDuration>
</m:AddPortMapping>
</s:Body>
</s:Envelope>

これをPythonスクリプトで行うために、以下のコードをaddPortMapping.pyという名前で保存します。

#!/usr/bin/env python
#-*- coding:utf-8 -*-

import urllib2

HOST = '192.168.1.1'
PORT = 2869
CONTROL = '/UD/?6'
URL = 'http://' + HOST + ':' + str(PORT) + CONTROL

NEW_EXTERNAL_PORT = 9090   # WAN側のポート番号
NEW_INTERNAL_PORT = 9090   # 転送先ホストのポート番号
NEW_INTERNAL_CLIENT = '192.168.1.3'   # 転送先ホストのIPアドレス
NEW_PROTOCOL = 'TCP'
LEASE_DURATION = '0'    # 設定の有効期間(秒)。0のときは無期限
DESCRIPTION = 'test'

SOAP  = '<?xml version="1.0"?>\r\n'
SOAP += '<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">\r\n'
SOAP += '<s:Body>\r\n'
SOAP += '<m:AddPortMapping xmlns:m="urn:schemas-upnp-org:service:WANPPPConnection:1">\r\n'
SOAP += '<NewRemoteHost></NewRemoteHost>\r\n'
SOAP += '<NewExternalPort>' + str(NEW_EXTERNAL_PORT) + '</NewExternalPort>\r\n'
SOAP += '<NewProtocol>' + NEW_PROTOCOL + '</NewProtocol>\r\n'
SOAP += '<NewInternalPort>' + str(NEW_INTERNAL_PORT) + '</NewInternalPort>\r\n'
SOAP += '<NewInternalClient>' + NEW_INTERNAL_CLIENT + '</NewInternalClient>\r\n'
SOAP += '<NewEnabled>1</NewEnabled>\r\n'
SOAP += '<NewPortMappingDescription>' + DESCRIPTION + '</NewPortMappingDescription>\r\n'
SOAP += '<NewLeaseDuration>' + LEASE_DURATION + '</NewLeaseDuration>\r\n'
SOAP += '</m:AddPortMapping>\r\n'
SOAP += '</s:Body>\r\n'
SOAP += '</s:Envelope>\r\n'

req = urllib2.Request(URL)
req.add_header('Content-Type', 'text/xml; charset="utf-8"')
req.add_header('SOAPACTION', '"urn:schemas-upnp-org:service:WANPPPConnection:1#AddPortMapping"')
req.add_data(SOAP)

try:
    res = urllib2.urlopen(req)
    print res.read()
except urllib2.HTTPError, e:
    print e.code
    print e.msg

このスクリプトを実行すると、以下のような結果になりました。

 % ./addPortMapping.py
<s:Envelope
 xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:AddPortMappingResponse xmlns:u="urn:schemas-upnp-org:service:WANPPPConnection:1">
</u:AddPortMappingResponse>
</s:Body>
</s:Envelope>

特にエラーらしきものが出力されていないので、9090番ポートに対する設定が行われたと思います。本当に設定できたのかをgetGenericPortMappingEntry.pyを実行して確認してみます。

 % ./getGenericPortMappingEntry.py
<s:Envelope
 xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:GetGenericPortMappingEntryResponse xmlns:u="urn:schemas-upnp-org:service:WANPPPConnection:1">
 <NewRemoteHost></NewRemoteHost>
 <NewExternalPort>9090</NewExternalPort>
 <NewProtocol>TCP</NewProtocol>
 <NewInternalPort>9090</NewInternalPort>
 <NewInternalClient>192.168.1.3</NewInternalClient>
 <NewEnabled>1</NewEnabled>
 <NewPortMappingDescription>test</NewPortMappingDescription>
 <NewLeaseDuration>0</NewLeaseDuration>
</u:GetGenericPortMappingEntryResponse>
</s:Body>
</s:Envelope>

========================================
<s:Envelope
 xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:GetGenericPortMappingEntryResponse xmlns:u="urn:schemas-upnp-org:service:WANPPPConnection:1">
 <NewRemoteHost></NewRemoteHost>
 <NewExternalPort>80</NewExternalPort>
 <NewProtocol>TCP</NewProtocol>
 <NewInternalPort>80</NewInternalPort>
 <NewInternalClient></NewInternalClient>
 <NewEnabled>0</NewEnabled>
 <NewPortMappingDescription>Webサーバ (HTTP)</NewPortMappingDescription>
 <NewLeaseDuration>0</NewLeaseDuration>
</u:GetGenericPortMappingEntryResponse>
</s:Body>
</s:Envelope>

========================================

この実行結果より、新たに9090番ポートへの転送設定が追加されていることが分かります。また、実際に自宅PC上でechoサーバーを立ち上げ(192.168.1.3:9090)、外部のネットワークからWAN側のIPアドレス:9090にtelnetでアクセスすると、NAT越えをしてechoサーバーと通信することができました!

ポートマッピングの設定を削除する

以下のSOAPアクションを送信して、先ほど追加した9090番ポートへの転送設定を削除することができます。

POST /UD/?6 HTTP/1.1
Host: 192.168.1.1:2869
Content-Length: 424
Content-Type: text/xml; charset="utf-8"
Connection: Close
SOAPACTION: "urn:schemas-upnp-org:service:WANPPPConnection:1#DeletePortMapping"

<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<m:DeletePortMapping xmlns:m="urn:schemas-upnp-org:service:WANPPPConnection:1">
<NewRemoteHost></NewRemoteHost>
<NewExternalPort>9090</NewExternalPort>
<NewProtocol>TCP</NewProtocol>
</m:DeletePortMapping>
</s:Body>
</s:Envelope>

これをPythonスクリプトで行うために、以下のコードをdeletePortMapping.pyという名前で保存しました。

#!/usr/bin/env python
#-*- coding:utf-8 -*-

import urllib2

HOST = '192.168.1.1'
PORT = 2869
CONTROL = '/UD/?6'
URL = 'http://' + HOST + ':' + str(PORT) + CONTROL

NEW_EXTERNAL_PORT = 9090
NEW_PROTOCOL = 'TCP'

SOAP  = '<?xml version="1.0"?>\r\n'
SOAP += '<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">\r\n'
SOAP += '<s:Body>\r\n'
SOAP += '<m:DeletePortMapping xmlns:m="urn:schemas-upnp-org:service:WANPPPConnection:1">\r\n'
SOAP += '<NewRemoteHost></NewRemoteHost>\r\n'
SOAP += '<NewExternalPort>' + str(NEW_EXTERNAL_PORT) + '</NewExternalPort>\r\n'
SOAP += '<NewProtocol>' + NEW_PROTOCOL + '</NewProtocol>\r\n'
SOAP += '</m:DeletePortMapping>\r\n'
SOAP += '</s:Body>\r\n'
SOAP += '</s:Envelope>\r\n'

req = urllib2.Request(URL)
req.add_header('Content-Type', 'text/xml; charset="utf-8"')
req.add_header('SOAPACTION', '"urn:schemas-upnp-org:service:WANPPPConnection:1#DeletePortMapping"')
req.add_data(SOAP)

try:
    res = urllib2.urlopen(req)
    print res.read()
except urllib2.HTTPError, e:
    print e.code
    print e.msg

このスクリプトを実行すると、以下のような結果になりました。

 % ./deletePortMapping.py
<s:Envelope
 xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:DeletePortMappingResponse xmlns:u="urn:schemas-upnp-org:service:WANPPPConnection:1">
</u:DeletePortMappingResponse>
</s:Body>
</s:Envelope>

特にエラーらしきものが出力がされていないので、9090番ポートへの転送設定は削除されたはずです。この後のgetGenericPortMappingEntry.pyの実行結果は割愛しますが、実際に外部からWAN側のIPアドレス:9090への通信はできなくなっていました。

WAN側のIPアドレスがプライベートだった場合

多分、2重ルーティングになってると思います。私の自宅も、始めは2重ルーティングになっていたのでgetExternalIPAddress.pyを実行するとWAN側のIPアドレスが192.168.1.2というプライベートアドレスになっていました。原因は、モデムとPCの間にある無線LANルーターがルーティングモードに設定されていたためでした。なので、Webブラウザーから無線LANルーターの設定をブリッジモードに変換することで解決できました。

おわりに

とりあえず、私の自宅においてはUPnPでNAT越えを行うことが出来ました。他の環境では試していないので、上のスクリプトがちゃんと動くかどうかわかりません。あしからず。
この調子で、次はSTUNかUDP Hole Punchingについて調べてみようと思います。

ある地域の天気予報を取得するスクリプトを書いてみた

はじめに

ちょっと天気予報データを集めることになったので、スクリプトを組んでみました。Python(v2.6.5)で。

BeautifulSoupのインストール

PythonでHTMLをパースする方法について調べてるとBeautifulSoupというライブラリが良さげだったので、これを使ってスクリプトを書くことにしました。

まずはアーカイブをダウンロードして展開します。

 % wget http://www.crummy.com/software/BeautifulSoup/download/3.x/BeautifulSoup-3.0.8.1.tar.gz
 % tar zxf BeautifulSoup-3.0.8.1.tar.gz
 % ls BeautifulSoup-3.0.8.1
BeautifulSoup.py  BeautifulSoupTests.py  PKG-INFO  setup.py

無事に展開できたらsetup.pyを使ってインストールするか、以下のスクリプトと同じ階層にBeautifulSoup.pyを置いてください。

parser.py

以下のスクリプトをparser.pyという名前で保存してください。

#!/usr/bin/env python
#-*- coding:utf-8 -*-

import re
import urllib2
from BeautifulSoup import BeautifulSoup
import unicodedata

def p (unicodeStr):
    '''
    unicode文字列をutf-8にエンコードして出力する。
    '''
    print unicodeStr.encode("utf-8")


def parseWeatherForecast (_htmlData):
    '''
    引数で受け取った天気予報のHTMLをパースして返す
    '''

    # HTMLパースの準備
    soup = BeautifulSoup(_htmlData)

    # 対象となる地方や都道府県名の取得
    m = re.match(u"天気予報\n: (.*)", soup.find("h1").contents[0])
    regionName = m.group(1)
    #p(regionName)    # 例:"沖縄本島地方"
    
    # 発表日時、気象台名、天気概況を取り出す
    textframeObj = soup.find("pre", "textframe")
    tmp = textframeObj.contents[0].split("\n")[1]
    # 例:平成22年9月26日04時41分 沖縄気象台発表
    pattern = u"平成([0-9]+)年([0-9]+)月([0-9]+)日([0-9]+)時([0-9]+)分 (.*)発表"
    m = re.match(pattern, tmp)
    tmp = m.groups()  # 例:(u"22", u"9", u"26", u"04", u"41", u"沖縄気象台")
    observatoryName = tmp[-1]   # 気象台の名前を取り出す(例:u"沖縄気象台")
    # 発表日時の全角数字を半角に変換する
    ymdhmArray = [int(unicodedata.normalize("NFKC", i)) for i in tmp[:-1]]
    ymdhmArray[0] += 1988       # 和暦(平成[\d+]年)を西暦に変換
    announceTime  = "/".join([str(x) for x in ymdhmArray[:3]]) + " "
    announceTime += ":".join([str(x) for x in ymdhmArray[3:]])
    #print announceTime         # 例:"2010/9/26 4:41"
    # 天気概況を取り出す
    summary = unicode(textframeObj.renderContents(), "utf-8")
    #p(summary)

    # 各地域の気象予報データをまとめたtableオブジェクトを取り出す
    tableObj = soup.find("table", id="forecasttablefont")
    # tableタグの子要素であるtrタグのリストを作成
    trList = []
    [trList.append(child) for child in tableObj if child != "\n"]  # 改行文字だけの要素は無視
    # 地域ごとにオブジェクトを分ける
    areaObjParts = []
    for i in range(len(trList)/4):
        areaObjParts.append(trList[i*4:(i+1)*4])
    
    # 各地域の気象予報データを取り出して配列に入れる
    areaParts = []
    for areaObj in areaObjParts:
        name = areaObj[0].div.contents[0]
        periodParts = parseEachPeriod(areaObj[1:])
        areaParts.append({"name": name,
                          "periodParts": periodParts})

    # パースしたデータを一つにまとめる
    parsedData = {"regionName": regionName,
                  "announceTime": announceTime,
                  "observatoryName": observatoryName,
                  "areaParts": areaParts,
                  "summary": summary}

    return parsedData

    
def parseEachPeriod (_areaObj):
    '''
    引数で受け取った地域のHTMLから今日(今夜)、明日、明後日ごとの
    予報データ(天気、降水確率、気温など)をパースして返す
    '''

    periodParts = []
    for periodObj in _areaObj:  # 今日、明日、明後日の予報データを順にパースする
        periodData = {}
        day_code = periodObj.find("th", "weather")
        # 日付を取り出す 例:今日26日、明日27日、明後日28日
        m = re.match(u"\n(今日|今夜|明日|明後日)(\d+)日", day_code.contents[0])
        periodData["day"] = m.group(2)
        # 天気コードを取り出す
        if day_code.img:
            m = re.match("img\/(\d+)\.png", day_code.img["src"])
            periodData["code"] = m.group(1)
        else: periodData["code"] = ""
        # 天気、風、波の予報を取り出す
        WEATHER_NAME_LIST = [u"晴れ", u"くもり", u"煙霧", u"砂じんあらし", u"地ふぶき",
                u"霧", u"霧雨", u"雨", u"みぞれ", u"雪", u"あられ", u"ひょう", u"雷"]
        infoObj = periodObj.find("td", "info")
        wind_weather_wave = unicode(infoObj.renderContents(), "utf-8")
        wind_weather, wave = wind_weather_wave.split("<br />")
        periodData["wave"] = wave if wave != "\n" else ""
        windWords = []
        weatherWords = []
        flag = False
        for word in wind_weather.split(" "):
            if flag:
                weatherWords.append(word)
            else:
                if word in WEATHER_NAME_LIST:
                    flag = True
                    weatherWords.append(word)
                else:
                    windWords.append(word)
        periodData["weather"] = " ".join(weatherWords)
        periodData["wind"] = " ".join(windWords)
        # 降水確率の予報を取り出す
        rainObj = periodObj.find("table", "rain")
        percentList = []
        for td in rainObj.findAll("td", align="right"):
            m = re.match(u"(\d+)%", td.contents[0])
            percentList.append(m.group(1) if m else "")
        periodData["rain"] = percentList[:]
        # 気温の予報(都市名、最高・最低気温)を取り出す
        tmpObj = periodObj.find("td", "city")
        cityName = tmpObj.contents[0] if tmpObj else ""
        lowest = ""
        minObj = periodObj.find("td", "min")
        if minObj and minObj.string:
            m = re.match(u"(\d+)度", minObj.string)
            lowest = m.group(1) if m else ""
        highest = ""
        maxObj = periodObj.find("td", "max")
        if maxObj and maxObj.string:
            m = re.match(u"(\d+)度", maxObj.string)
            highest = m.group(1) if m else ""
        periodData["temperature"] = {"cityName": cityName,
                                     "lowest": lowest,
                                     "highest": highest}
        periodParts.append(periodData)

    return periodParts


def printWeatherForecast (_weatherForecast):
    '''
    引数で受け取った天気予報データを見やすく整形して出力する
    '''

    info = _weatherForecast
    p(u"天気予報:" + info["regionName"])
    p(u"発表日時:" + info["announceTime"])
    p(u"観測所 :" + info["observatoryName"])
    print ""
    for area in info["areaParts"]:   # 各地域ごとに
        p(area["name"])
        for period, dayName in zip(area["periodParts"], [u"今日", u"明日", u"明後日"]):
            p("\t" + dayName + period["day"] + u"日")
            p("\t\t" + u"天気コード:" + period["code"])
            p("\t\t" + u"天気予報:" + period["weather"])
            p("\t\t" + u"風の予報:" + period["wind"])
            p("\t\t" + u"波の予報:" + period["wave"])
            p("\t\t" + u"降水確率:")
            for k, v in zip(["00-06", "06-12", "12-18", "18-24"], period["rain"]):
                p("\t\t\t" + k + ":" + v + "%")
            p("\t\t" + u"気温予報:" + period["temperature"]["cityName"])
            p("\t\t\t" + u"最低:" + period["temperature"]["lowest"] + u"度")
            p("\t\t\t" + u"最高:" + period["temperature"]["highest"] + u"度")
        print ""
    print "=" * 50
    p(info["summary"])


def main ():
    BASE_URL = "http://www.jma.go.jp/jp/yoho/"
    TARGET_NUM = 353  # [301-356]
    TARGET_FILE = str(TARGET_NUM) + ".html"

    # HTMLデータの取得
    userAgent = "Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 6.1; Trident/4.0)"
    request = urllib2.Request(BASE_URL + TARGET_FILE)
    request.add_header("User-Agent", userAgent)
    respons = urllib2.urlopen(request)
    htmlData = respons.read()
    htmlTimestamp = respons.info()["Last-Modified"]
    #print htmlTimestamp

    # HTMLデータから気象予報データを取り出す
    result = parseWeatherForecast(htmlData)

    # 気象予報データを整形して出力する
    printWeatherForecast(result)


if __name__ == "__main__": main()

このスクリプトでは、現在の沖縄本島地方の天気予報(http://www.jma.go.jp/jp/yoho/353.html)を取得しますが、main()内のTARGET_NUMの値を301から356の間で変化させることで他の地域の天気予報を取得することができます。実際に実行すると、以下のように沖縄本島地方の天気予報データが出力されはずです。公開されていない天気予報データの部分は、空文字列を出力します。

 % python parser.py
天気予報:沖縄本島地方
発表日時:2010/10/1 13:42
観測所 :沖縄気象台

本島中南部
	今日1日
		天気コード:210
		天気予報:くもり 夜 晴れ 所により 夕方 まで 雷
		風の予報:北東の風 後 東の風
		波の予報:波 1.5メートル
		降水確率:
			00-06:%
			06-12:%
			12-18:30%
			18-24:20%
		気温予報:那覇
			最低:度
			最高:30度
	明日2日
		天気コード:110
		天気予報:晴れ 昼過ぎ から くもり
		風の予報:東の風
		波の予報:波 1.5メートル
		降水確率:
			00-06:10%
			06-12:10%
			12-18:20%
			18-24:20%
		気温予報:那覇
			最低:25度
			最高:31度
	明後日3日
		天気コード:201
		天気予報:くもり 時々 晴れ
		風の予報:東の風 後 北西の風
		波の予報:波 1.5メートル
		降水確率:
		気温予報:
			最低:度
			最高:度

本島北部
	今日1日
		天気コード:210
		天気予報:くもり 夜 晴れ 所により 夕方 まで 雷
		風の予報:北東の風 後 東の風
		波の予報:波 1.5メートル
		降水確率:
			00-06:%
			06-12:%
			12-18:30%
			18-24:20%
		気温予報:名護
			最低:度
			最高:30度
	明日2日
		天気コード:112
		天気予報:晴れ 後 くもり 夕方 一時 雨
		風の予報:東の風
		波の予報:波 1.5メートル
		降水確率:
			00-06:10%
			06-12:10%
			12-18:50%
			18-24:20%
		気温予報:名護
			最低:25度
			最高:31度
	明後日3日
		天気コード:201
		天気予報:くもり 時々 晴れ
		風の予報:東の風 後 北西の風
		波の予報:波 1.5メートル
		降水確率:
		気温予報:
			最低:度
			最高:度

久米島
	今日1日
		天気コード:210
		天気予報:くもり 夜 晴れ 所により 夕方 まで 雷
		風の予報:北東の風 後 東の風
		波の予報:波 1.5メートル
		降水確率:
			00-06:%
			06-12:%
			12-18:30%
			18-24:20%
		気温予報:久米島
			最低:度
			最高:30度
	明日2日
		天気コード:100
		天気予報:晴れ 明け方 から 朝 くもり
		風の予報:東の風
		波の予報:波 1.5メートル
		降水確率:
			00-06:20%
			06-12:20%
			12-18:10%
			18-24:10%
		気温予報:久米島
			最低:25度
			最高:30度
	明後日3日
		天気コード:201
		天気予報:くもり 時々 晴れ
		風の予報:東の風 後 北西の風
		波の予報:波 1.5メートル
		降水確率:
		気温予報:
			最低:度
			最高:度

==================================================
天気概況
平成22年10月1日13時42分 沖縄気象台発表

<b>沖縄本島地方では、大気の状態が不安定となっているため、1日夕方ま
で発達した積乱雲の下での落雷や突風、急な強い雨に十分注意して下さい。
</b>
 沖縄地方は、高気圧に覆われておおむね晴れていますが、沖縄本島地方で
は、大気の状態が不安定なため、にわか雨の所があります。

 1日は、沖縄地方は、高気圧に覆われておおむね晴れますが、沖縄本島地
方では、大気の状態が不安定なため、所によってはにわか雨か雷雨となる見
込みです。

 2日は、沖縄地方は、高気圧に覆われておおむね晴れますが、所によって
はにわか雨があるでしょう。

 沖縄本島地方では、1日夕方まで発達した積乱雲の下での落雷や突風、急
な強い雨に注意して下さい。

 沖縄地方の沿岸の海域では、波がやや高いでしょう。

おわりに

このスクリプトでは晴れ、雨、くもりのような日常的な天気予報に対してはテストしましたが、台風や降雪のような天気予報に対してはまだテストしてないので、ちゃんと動作するかは未確認です。

あと、TARGET_NUMの値を変化させれば全国の天気予報データが取得できますが、気象庁のFAQには、

天気予報やレーダーの画像、地震情報などを巡回ソフトを使って自動的に取得したい。

防災気象情報のページは、通常のブラウザで閲覧することを前提に各種情報を掲載しております。
自動巡回ソフト等による、定期的、自動的な気象データの収集等は、サーバーに負荷がかかる等の理由から、原則としてご遠慮いただいております。
ご理解お願いします。

と書かれているので、クローラーなどを作る場合は自己責任でね☆(ゝω・)v キャピ

チラシ裏

実は、わざわざスクリプトを書かなくても気象データを取得する方法があります。財団法人気象業務支援センターという所が、気象庁が集めた気象データの配信を行っているので、ここに気象データの配信申請を出せば良いみたいです。有料ですが…。ちなみに、料金表はココに書いてあります。
最近はこういうXMLでの配信も行っているらしいですが、月に数万も払えないのでスクリプトを書きました(´・ω・`)ショボーン


あと、Pythonでの日本語の扱い方がよくわかんないです。ググって見つけた、事前にsitecustomize.pyというファイルを作成して

import sys
sys.setdefaultencoding('utf-8')

という記述をしておく、という方法が何か嫌でした。なので日本語の文字列は、unicode型に変換してからいじくって(正規表現でパターンマッチとか)、出力する直前でunicode.encode()メソッドを使うって感じにしました。どんな方法が一番良いんですかね(-_-;)

JavaScriptの値をローカルファイルに保存する方法について調べた2

はじめに

前回の続き。今回はユーザーにswfファイルをクリックさせて、JavaScriptの値をローカルファイルに保存するプログラムを書いてみました。

  • FireWrite.as
package {
    import flash.display.Sprite;
    import flash.events.MouseEvent;
    import flash.external.ExternalInterface;
    import flash.net.FileReference;
    import mx.utils.Base64Decoder;

    public class WriteFile extends Sprite {
        function WriteFile () {
            // マウスクリックイベントを検出するとdoWrite()を呼び出すように登録する
            stage.addEventListener(MouseEvent.CLICK, doWrite);
        }

        private function doWrite (eventObj:MouseEvent):void {
            var fr:FileReference = new FileReference();
            var fileData:* = ExternalInterface.call("getFileData"); // 保存するファイル情報を取得する
            try {  // ローカルファイルに保存できるか試みる
                if (fileData["base64"]) {
                    // Base64エンコードされた文字列ならデコードして保存する
                    var b64d:Base64Decoder = new Base64Decoder();
                    b64d.decode(fileData["body"]);
                    fr.save(b64d.toByteArray(), fileData["name"]);
                } else {
                    // ただの文字列ならそのまま保存する
                    fr.save(fileData["body"], fileData["name"]);
                }
            } catch (error:Error) {
                // 保存できなければアラートを表示する
                ExternalInterface.call("function(){javascript:alert('だが断る " + error.message + "')}");
            }
        }
    }
}

このActionScriptではswfがユーザーにクリックされると、JSのgetFileData()関数を呼び出して保存したいファイルの情報を取得し、ASのFileReference.save()メソッドでファイルの保存を行っています。また、ただの文字列データを保存するだけだとおもしろくないので、Base64エンコードされた文字列はデコードしてバイナリファイルとして保存するようにしました。

上記のASファイルをコンパイルしてWriteFile.swfを作成し、これと同じ階層に以下のHTMLファイルを置いてWebブラウザーからアクセスします。

<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8" />
        <script type="text/javascript">
            function getFileData () {
                return {"body"  : document.getElementById("fbody").value,
                        "name"  : document.getElementById("fname").value,
                        "base64": document.getElementById("chkbx").checked ? true : false}
            }
        </script>
    </head>
    <body>
        File Name : <input id="fname" type="text"><input id="chkbx" type="checkbox">Base64<br>
        <textarea id="fbody" cols="30" rows="5" wrap="soft"></textarea><br>
        <div style="position: relative;">
            <img src="button.png" style="position: absolute;"> <!-- 60x30 の画像 -->
            <div style="width: 60px; height: 30px; position: absolute;">
                <embed src="WriteFile.swf" name="wf" allowScriptAccess="always" width="100%" height="100%" wmode="transparent">
            </div>
        </div>
    </body>
</html>

このHTMLでは、保存ボタンを表す画像(button.png)の上にWriteFile.swfが重なるように並べています。Webブラウザーからアクセスすると以下のように表示されます。

適当にファイル名とメッセージを入力して保存ボタンの画像をクリックすると…

保存ダイアログが表示され、ローカルファイルとして保存する事ができます。また、バイナリファイルとして保存したいときは、

このようにBase64エンコードされた文字列を入力し、チェックボックスにチェックを飛ばして保存ボタンをクリックすると…

保存ダイアログが表示され、バイナリファイルとして保存することが出来ます。念のため確認すると、以下のように画像ファイルとして保存されていることが分かります。


おわりに

とりあえず、AS経由でJSの値をローカルファイルに保存することが出来ました。JSで出来ない事をASにさせるのは卑怯な感じがしますが、やりたかったことが出来たし、ASもちょっとだけ触れて満足です(^ω^)♪