自動巡回エージェントを作る(『スタパライフ』 Ver 1.01)

 Chapter.16 収集対象に合わせたカスタマイズ

 tcupの構造を踏まえた上で、『スタパライフ』の自動巡回エージェントを設計してみましょう。細かな部分の調整はともかくとして、Tea-Cup掲示板とスタパライフのページの一番の違いは何でしょうか?
 ……もちろんそれは、目次ページと本文ページの分割です。Tea-Cup掲示板は、複数のログをまとめたページが複数存在します。それに対してスタパライフは、一定件数ごとにタイトルを納めた目次ページが複数存在し、タイトルからリンクされている本文ページがさらに別に存在します。そのため、ウェブページを読み込む部分は、目次部分と本文部分の二つに分割しなければなりません。
 具体的には、以下のような構造になります。


 ・全体設計

 ぱっと見て分かるように、目次ページの読み込みをcheckmessagesで、本文ページの読み込みをchecknewmessageで行うようにしています(命名は安直ですけど……)。新しく追加した関数で行うべき処理は、tcupや他の標準エージェントを参考にすることができます。今回、ページの読み込みに関しては『asahi.com』エージェントのasahitop.rを、ページ解析についてはtcup.rを参考にしています。



 それでは、具体的に『tcup』を『スタパライフ』用にカスタマイズするにあたっての変更点を説明します。
 『スタパライフ』Ver 1.01の自動巡回エージェントの全てのソースファイルは、こちらを参照してください。以下のセンテンスでは、tcupからの変更点や重要点のみピックアップして解説します。


・レジストリィから設定値を読み込む(main)

 219:    path = getenv("DATA_DIR") + "\\stpl\\user.dat";
 220:    leg = leg_open(path, lmOpenRead);
 221:    if(leg) {
 222:        leg_readstring(leg, "/common/url", url, 1024, "");
 223:        leg_readstring(leg, "/common/mbox", mbox, 1024, "");
 224:        autobrowseropen=leg_readbool(leg, "/common/autobrowseropen", 0);
 225:    }
 226:    leg_close(leg);
 227:
 228:    strcpy(TGTFILE,getenv("DATA_DIR"));
 229:    strcpy(TGTFILE+strlen(TGTFILE),"\\");
 230:    strcpy(TGTFILE+strlen(TGTFILE),mbox);

 まずは、自動巡回エージェントの挙動に関る設定を、レジストリィから読み込みます。tcup.rではこの手の情報をパラメタとして受取っていましたが、autostpl.rではレジストリィを利用しています。
 上記のように、leg_open関数でレジストリィをオープンしたのち、leg_readstring関数でurl及びmbxという文字列変数に巡回先URLとローカルログファイル(Mail Boxファイル)を転記し、自動起動オプション使用の可否を、leg_readbool関数でautobrowseropenという論理型変数に格納しています。レジストリィは使用後にはleg_close関数でクローズする必要があります。この時取得するMail Boxファイルのファイル名は、AirWebのDataディレクトリィからの相対パスになっていますので、環境変数『DataDir』から実パス名を取得し、『\』を挟んだフルパス名を生成してTGTFILEに転記しています。


 ・URLの分解(main)

 231:    memset(&loc,0,sizeof(LOCATION));
 232:    loc.size=sizeof(LOCATION);
 233:    decodeurl(&loc,url);

 ウェブサーバとの接続確立、ウェブページの読み込みでは、それぞれホスト名とディレクトリィ名が必要になります。ここでは、巡回先URLをdecordeurl関数にかけ、LOCATION型構造体の変数であるlocに結果を保存しています。例えば、巡回先URLが『http://www.alt-r.com/di/toc5-0.html』であった場合、loc.hostには『www.alt-r.com』が、loc.pathには『/di/toc5-0.html』が格納されています。


 ・ウェブサーバとの接続確立(main)

 235:    inet=inet_open(NULL,loc.host,loc.port,INET_SERVICE_HTTP,NULL,NULL,0);
 236:    if(inet) {

 ページの読み込みの前に、ウェブサーバとの通信処理が可能であるかどうかチェックします。INET型の変数inetには、接続が確立すれば0以外の数値が転記されます。以降ウェブ上のデータの送受信を行う場合、このINET型変数を使って対象ホストを指定します。


 ・ページの読み込みのループ(main)

 241:        do {
 242:            ofs = getmessages(&buffer,inet,loc.path,ofs);
 243:        } while(ofs && !terminated);

 特にtcup.rと違う部分はありません。getmessagesの戻り値が0でない限りループします。逆に、ループを終了する場合には戻り値に0を返せば良いわけです。


 ・ウェブページの読み込み(getmessages)

 167:    http=http_open(inet,"GET",path,NULL,0);
 168:    if (http) {
 169:        buf->bsize+=BUFSIZEUNIT;
 170:        buf->text=realloc(buf->text,buf->bsize+1);
 171:        sprintf(tmp,"ダウンロード中... %d メッセージ (%d bytes)",offset,buf->esize);
 172:        setstatustext(tmp);
 173:        tmplen=http_request(http,NULL,0,buf->text+buf->esize,BUFSIZEUNIT+1);
 174:        buf->esize+=tmplen;
 175:        while(tmplen==BUFSIZEUNIT) {
 176:            buf->bsize+=BUFSIZEUNIT;
 177:            buf->text=realloc(buf->text,buf->bsize+1);
 178:            tmplen=http_read(http,buf->text+buf->esize,BUFSIZEUNIT+1);
 179:            buf->esize+=tmplen;
 180:            sprintf(tmp,"ダウンロード中... %d メッセージ (%d bytes)",offset,buf->esize);
 181:            setstatustext(tmp);
 182:        }
 183:        buf->text[buf->esize]='\0';
 184:        sprintf(tmp,"ダウンロード中... %d メッセージ (%d bytes)",offset,buf->esize);
 185:        setstatustext(tmp);
 186:        http_close(http);
 187:    }

 ほぼtcup.rのままです。ただし、tcupの場合最初のhttp_open時に初回ループかそうでないかで、処理を分けています。Vwe 1.01の『スタパライフ』エージェントでは、まだ過去ページへ遡る機能は実装しないので、巡回先URLから読み取ったディレクトリィをそのまま指定しています。


 ・checknewmessageの起動と、ループ終了の判断(checkmessages)
 188:    result = 0;
 189:    if(count = checkmessages(inet,path,buf)) {
 190:        if(strstr(buf->text,"<input type=submit value=\"次のページ\">")) result = offset + count;
 191:        else result = 0;
 192:    }

 これもほとんどtcupと変わりありません。後続の処理のために、引数が若干増えているくらいです。inputタグの有無により、戻り値の内容を操作しているのはtcup.rの名残です。将来バージョンアップした時には修正しましょう……。もちろん、スタパライフにはこの情報は無いので、戻り値はいつでも0になってしまい、ループは初回のみしか発生しません



 ・メッセージのあるURLのパスを求める(checkmessages)

 127://メッセージのあるURLのパスを求める
 128:    sep=0;
 129:    while(strstr(path+sep,"/")>0){
 130:        sep=strstr(path+sep,"/")-path+1;
 131:    }
 132:    strncpy(dirpath,path,sep);

 スタパライフの目次ページのhtmlソースを見ればわかりますが、本文ページへのリンクは相対パスで書かれています。これを補うために、目次ページのディレクトリィ名のみを書き出しています。


 ・目次ページの解析(checkmessages)

 137:    page=page_create(buf->text,buf->esize);
 138:    ea=page_elements(page);
 139:    el=page_find_elements(page,NULL,"TABLE");
 140:
 141:    for(scan=0;scancount;scan++) {
 142:        index=el->items[scan].number+1;
 143:        if((index+5)>=ea->count) break;
 144:        if( (ea->items[index].tag=="TR")&&
 145:            (ea->items[index+1].tag=="TD")&&
 146:            (ea->items[index+2].tag=="FONT")&&
 147:            (ea->items[index+3].tag=="")&&
 148:            (ea->items[index+4].tag=="TD")&&
 149:            (ea->items[index+5].tag=="")){
 150:            count=checknewmessage(qd,inet,dirpath+page_element_att(page,&ea->items[index+7],"href"),
 151:                page_element_text(page,&ea->items[index+3]));
 152:            if(!count) break;
 153:            result += count;
 154:        }
 155:    }
 156:    page_free(page);

 まずは、目次ページの解析です。処理の手順としては、tcupと変わりません。違うのは、検索する書式にマッチした時に、ログの追記にいかず本文ページを処理するためのchecknewmessageを呼び出しているところです。
 ページの解析にあたって、タイトルを取り出すためにTABLEタグを使って頭出しを行っています。airWebのページの解析がどのように行われているかは、次のChapterにて詳述します。


 ・新規取得かどうかの判断(chesknewmessage)

  75:    strcpy(idstr,log_no);
  76:    if(qfind(qd,QFIND_NUMBER,atoi(idstr))==-1) {

 cheknewmessageは、本文ページの取得と解析を行う関数です。この時点で、既にログを特定するための番号は分かっているので、引数としてcheckmessagesから渡されたlog_noを元に、qfind関数でログデータベース(Mail Boxファイル)を検索しています。同番号のログが存在しない場合のみ、続く本文ページの解析処理を行います


 ・本文ページの取得と解析(checknewmessage)

  77:         // HTTP GET メソッドで / を取得する準備
  78:        http = http_open(inet, "GET", path, NULL, 0);
  79:        if (http != NULL) {
  80:            // HTTP のリクエストを実行(上の GET / )
  81:            len = http_request(http, NULL, 0, buffer, BUFFER_SIZE);
  82:            page=page_create(buffer,sizeof(buffer));
  83:            ea=page_elements(page);
  84:            el=page_find_elements(page,NULL,"TABLE");
  85:            for(scan=0;scancount;scan++) {
  86:                index=el->items[scan].number+1;
  87:                if((ea->items[index].tag=="TR") &&
  88:                    (ea->items[index+1].tag=="TD") &&
  89:                    (ea->items[index+2].tag=="") &&
  90:                    (ea->items[index+3].tag=="TD") &&
  91:                    (ea->items[index+4].tag=="") &&
  92:                    (ea->items[index+5].tag=="TR") &&
  93:                    (ea->items[index+6].tag=="TD") &&
  94:                    (ea->items[index+7].tag=="P") &&
  95:                    (ea->items[index+8].tag=="BR") ){
  96:                    count=storemessage(
  97:                        log_no,
  98:                        page_element_text(page,&ea->items[index+4]),
  99:                        page_element_text(page,&ea->items[index+2]),
 100:                        page_element_text(page,&ea->items[index+6]));
 101:                    result=1;
 102:                    break;
 103:                }
 104:            }
 105:            // HTTP をクローズ
 106:            page_free(page);
 107:            http_close(http);

 本文ページの解析です。ウェブページからhtmlを取り出す一連の処理は、『asahi.com』エージェントのasahitop.rを参考にしています。getmessagesの時と違って、1処理でウェブページ全体を取得していますが、これはウェブページのhtmlソースのサイズが十分に小さいことが最初から分かっているからです(そういう意味では、目次ページも巨大になることは無さそうなので、『スタパライフ』のgetmessagesももっと簡単な処理で良いはずです)。
 本文ページが取得できたら、checkmessagesと同じような要領で、本文の書式にあてはまるかどうかチェックして、該当する場合はstoremessageを呼び出しています。airWebのページの解析がどのように行われているかは、後のChapterにて詳述します。


 ・日付の切り出し方(getmessagedate)

  19:int getmessagedate(struct tm *trec,char *datestr) {
  20:    char tmp[40];    
  21:
  22:    strcpy(tmp,datestr);
  23:    /* 01234567890123456789012345678901 */
  24:    /* 2000/12/10 20:20 */
  25:    tmp[4]='\0';
  26:    trec->tm_year=atoi(tmp);
  27:    tmp[7]='\0';
  28:    trec->tm_mon=atoi(tmp+5);
  29:    tmp[10]='\0';
  30:    trec->tm_mday=atoi(tmp+8);
  31:    tmp[13]='\0';
  32:    trec->tm_hour=atoi(tmp+11);
  33:    tmp[16]='\0';
  34:    trec->tm_min=atoi(tmp+14);
  35:    trec->tm_sec=0;
  36:    return 0;
  37:}

 storemessageから呼び出される日付の変換用の関数です。『スタパライフ』の本文ログの日付書式は『YYYY/MM/DD HH:MM』の形で固定なので、文字列中のそれぞれの部分を分割して取り出し、数値型に変換しています。秒数はログに記載が無いので、固定で0を設定しています。


 ・追記用変数へログを格納(storemessage)

  39:int storemessage(char *idstr,char *subject,char *date,char *text) {
  40:    MSGBUF *tmpmsg;
  41:    struct tm tmp;
  42:    char timestamp[128];
  43:    int result;
  44:    str tmptext;
  45:    
  46:    getmessagedate(&tmp,date);
  47:    strftime(timestamp,sizeof(timestamp),"%c",&tmp);
  48:
  49:    tmpmsg = (MSGBUF *)malloc(sizeof(MSGBUF));
  50:    tmptext = "From foo@bar " + timestamp + "\r\n";
  51:    tmptext = tmptext + "From: スタパ齋藤" + "\r\n";
  52:    tmptext = tmptext + "X-Number: " + idstr + "\r\n";
  53:    tmptext = tmptext + "Date: " + timestamp + "\r\n";
  54:    tmptext = tmptext + "Subject: " + subject + "\r\n";
  55:    tmptext = tmptext + "\r\n";
  56:    tmptext = tmptext + text;
  57:    tmptext = tmptext + "\r\n";
  58:    tmptext = tmptext + "\r\n";
  59:        
  60:    tmpmsg->msgtext = strcpy(malloc(strlen(tmptext)+1),tmptext);
  61:        
  62:    tmpmsg->next = MSGSTACK;
  63:    MSGSTACK = tmpmsg;
  64:    return result;
  65:}

 内容的には、tcupのものと大差ありません。ただし、取得済みログかどうかのチェックは既に行われていますので、ログデータベースの存在チェックは省いています


 ・ログデータベースの更新(main)

 246:        if(MSGSTACK) {
 247:            strcpy(filename,TGTFILE);
 248:            scan=strrchr(filename,'.');
 249:            if(!scan) scan=strend(filename);
 250:            strcpy(scan,".g00");
 251:            out=fopen(filename,"a+");
 252:            while(MSGSTACK) {
 253:                ofs++;
 254:                fwrite(MSGSTACK->msgtext,1,strlen(MSGSTACK->msgtext),out);
 255:                tmp = MSGSTACK->next;
 256:                free(MSGSTACK->msgtext);
 257:                free(MSGSTACK);
 258:                MSGSTACK = tmp;
 259:            }
 260:            fclose(out);
 261:            qd=qopen(TGTFILE);
 262:            qupdate(qd);
 263:            qclose(qd);
 264:        }

 必要なページを全て検査してループを終了したら、後はログデータベースの実更新を行います。この部分もtcupから変更ありません。データベースの更新時には、AirCraftと同じように巡回一時ファイル(拡張子が『g00』のテキストファイル)に追記分のログを全て書き出してから、ログデータベース更新用のqupdate関数を起動しています。


 ・ブラウザの起動(main)

 269:        if((ofs>0)&&autobrowseropen)
 270:            spawnl(P_NOWAIT, "stpl\\openstpl.rx", "stpl\\openstpl.rx",NULL);

 最後に、ブラウザの自動起動の設定に従い、必要時にはspawnl関数を使ってブラウザエージェントを起動して終了します。

 以上が、『スタパライフ』の自動巡回部分の全てです。やはり、ネックとなりそうなのが、ページ解析の行われ方の理解度ですので、いろいろなテストプログラムを作ってコツを飲み込んでください。サポートツール的なエージェントとして、ページ解析の結果を視覚的に分かりやすく変換する『HTMLアナライザ』エージェントも公開されていますので(実は筆者の作ですが)、これを利用するのもひとつの方法です。
 その他、Legistry操作系、ログデータベース系、通信処理系、ページ解析系の各関数の詳細については、それぞれのリファレンスを参照してください。工事中のものも多いですが、今後順次拡充されていく予定です。