Fess で全文検索
オープンソースの全文検索サーバー Fess と組み合わせると、プリザンターの画面内に複数テーブル横断の全文検索ボックスを追加できます。 Fess の Web クローラーでプリザンターの画面をクロールしてインデックスを作り、一覧画面のサーバースクリプトから Fess の JSON 検索 API を呼び出して結果を表示します。Fess の呼び出しをサーバ側で行うため、Fess サーバのアドレスをクライアントに露出せずに済みます。
あわせて、Fess サーバ自体を Azure の PaaS 上に Google Workspace 認証付きで構築する方法と、Chatwork のログのようなプリザンター以外のデータを同じ Fess で検索できるようにする方法もまとめます。
全体構成
図を読み込み中…
- Fess の Web クローラーがプリザンターの画面をクロールしてインデックスを構築する
- ユーザーが一覧画面の検索ボックスにキーワードを入力すると、URL パラメータ
fq付きで画面を再表示する - 「画面表示の前」のサーバースクリプトが Fess の検索 API を呼び出し、結果を
hiddenで画面の隠し項目に書き出す - スクリプトが隠し項目を読み取って結果を表示する
前提条件
| 項目 | 内容 |
|---|---|
| プリザンター | v1.3 以降(サーバースクリプト対応版) |
| Fess | v14.x 以降 |
| ネットワーク | プリザンターサーバ ↔ Fess サーバ間で HTTP 通信が可能 |
Fess のインストール・初期設定は Fess 公式ドキュメント を参照してください。 Azure 上に構築する場合の例は「Fess サーバを Azure の PaaS に構築する」を参照してください。
Fess の設定
JSON レスポンスの有効化
Fess の既定では JSON API が無効です。
- Fess 管理画面(
http://<Fessサーバ>/admin/)にログインする - 「システム」→「全般」を開く
- 「JSON レスポンス」をオンにして保存する
これで http://<Fessサーバ>/json/?q=検索ワード で JSON を取得できます。
CORS の設定(別ドメインの場合)
プリザンターと Fess が異なるホストにある場合は、$FESS_HOME/app/WEB-INF/classes/fess_config.properties に次を追記します。
api.cors.allow.origin=https://<プリザンターのドメイン>
api.cors.allow.methods=GET
api.cors.allow.headers=Content-Type
api.cors.allow.credentials=falseWARNING
ワイルドカード(*)はセキュリティリスクがあるため、本番環境ではプリザンターのオリジンを明示的に指定することを推奨します。
Web クローラーの設定
管理画面の「クローラー」→「ウェブ」→「新規作成」で次を設定します。
| 設定項目 | 設定値 |
|---|---|
| 名前 | Pleasanter |
| URL | https://<プリザンターのURL>/items/ |
| 対象 URL パターン | https://<プリザンターのURL>/items/.* |
| 除外 URL パターン | .*\.(css|js|png|jpg|gif|ico) |
| 最大アクセス数 | 適切な値(例: 1000) |
| ユーザエージェント | Fessbot |
認証設定(ログインが必要な場合)
プリザンターがログイン必須の場合は、「クローラー」→「設定」→「ウェブ認証」→「新規作成」でフォーム認証を設定します。
| 設定項目 | 設定値 |
|---|---|
| ホスト名 | <プリザンターのホスト> |
| URL | https://<プリザンターのURL>/users/authenticate |
| 認証タイプ | フォーム |
| ユーザ名パラメータ | Users_LoginId |
| パスワードパラメータ | Users_Password |
| パラメータ | Users_LoginId=<クローラー用ユーザ名>&Users_Password=<パスワード> |
プリザンターのログインは /users/authenticate への POST で受け付け(UsersController.cs)、フォーム項目名は Users_LoginId と Users_Password です。Fess のフォーム認証がこの応答(JSON を返して Cookie を発行する)でセッションを保持できるかは、ソースからは確認できないため、クロール結果で確かめてください。
INFO
クローラー用に閲覧専用の専用ユーザを作成し、必要なテーブルのみ閲覧権限を付与することを推奨します。
クロールの実行
「システム」→「クローラー」から「今すぐ開始」を押してクロールを実行します。完了後、「Fess 検索」ページでキーワードを入力して結果が表示されることを確認します。
プリザンター側の実装
サーバースクリプト(検索の実行)
「テーブルの管理」→「サーバスクリプト」で次を設定します。
| 項目 | 設定値 |
|---|---|
| タイトル | Fess検索 |
| 条件 | 画面表示の前 |
| 関数化 | オン(スクリプト内で return を使うため) |
// --- 設定 ---
var fessUrl = 'http://<Fessサーバ>:8080/json/';
var maxResults = 10;
// --- 設定ここまで ---
// クエリパラメータから検索ワードを取得
var query = context.QueryStrings.Data('fq');
if (!query) {
hidden.Add('FessResults', '[]');
hidden.Add('FessTotal', '0');
return;
}
// Fess 検索APIを呼び出す
httpClient.RequestUri = fessUrl
+ '?q=' + encodeURIComponent(query)
+ '&num=' + maxResults;
var response = httpClient.Get();
if (!httpClient.IsSuccess) {
context.Log('Fess API エラー: ' + httpClient.StatusCode);
hidden.Add('FessResults', '[]');
hidden.Add('FessTotal', '0');
return;
}
var json = JSON.parse(response);
var results = json.response.result || [];
var total = json.response.record_count || 0;
hidden.Add('FessResults', JSON.stringify(results));
hidden.Add('FessTotal', String(total));サーバースクリプトの model には GetParam / SetParam が無いため、クエリ文字列は context.QueryStrings.Data()(ServerScriptModelContext.cs)で読み、結果は hidden.Add()(ServerScriptModelHidden.cs)で書き出すように修正しています。hidden.Add() で追加した値は、画面に id がキー名の隠し項目として出力されます(HtmlTemplates.cs)。同じキーを 2 回 Add すると例外になるので、1 回の実行で各キーを 1 回だけ追加します。
httpClient.ResponseHeaders は Get() などの呼び出しごとに内部でクリアされる(ServerScriptModelHttpClient.cs)ので、事前に Clear() する必要はありません。
スクリプト(検索ボックスと結果表示)
「テーブルの管理」→「スクリプト」で、条件「一覧」として次を設定します。
(function () {
'use strict';
// サーバースクリプトが隠し項目に書き出した検索結果を取得する
var resultsJson = $('#FessResults').val() || '[]';
var totalStr = $('#FessTotal').val() || '0';
var results = [];
try {
results = JSON.parse(resultsJson);
} catch (e) {
results = [];
}
var total = parseInt(totalStr, 10) || 0;
// 現在の検索ワードを URL パラメータから取得
var params = new URLSearchParams(window.location.search);
var currentQuery = params.get('fq') || '';
// 検索ボックスのHTML
var searchBoxHtml = [
'<div id="fess-search-box" style="margin:8px 0;display:flex;gap:8px;align-items:center;">',
' <input type="text" id="fess-query" placeholder="全文検索..." value="'
+ $('<div/>').text(currentQuery).html()
+ '" style="padding:4px 8px;border:1px solid #ccc;border-radius:4px;width:240px;">',
' <button id="fess-search-btn" style="padding:4px 12px;cursor:pointer;">検索</button>',
'</div>'
].join('');
// 検索ボックスを一覧上部に挿入
$('#ViewFilters').before(searchBoxHtml);
// 検索ボタンのクリックイベント
$('#fess-search-btn').on('click', function () {
var q = $('#fess-query').val().trim();
if (!q) return;
var url = new URL(window.location.href);
url.searchParams.set('fq', q);
window.location.href = url.toString();
});
// Enter キーでも検索実行
$('#fess-query').on('keydown', function (e) {
if (e.key === 'Enter') {
$('#fess-search-btn').trigger('click');
}
});
// 検索結果の表示
if (results.length === 0 && currentQuery) {
$('#MainForm').after(
'<div id="fess-results"><p>「'
+ $('<div/>').text(currentQuery).html()
+ '」に一致する結果が見つかりませんでした。</p></div>'
);
return;
}
if (results.length === 0) return;
var html = [
'<div id="fess-results" style="margin-top:12px;padding:12px;border:1px solid #ddd;border-radius:4px;">',
' <p style="margin:0 0 8px;font-weight:bold;">全文検索結果: '
+ $('<div/>').text(currentQuery).html()
+ ' (' + total + '件中 ' + results.length + '件表示)</p>',
' <ul style="margin:0;padding:0;list-style:none;">'
];
results.forEach(function (item) {
html.push(
'<li style="margin-bottom:8px;padding:8px;background:#f9f9f9;border-radius:4px;">',
' <a href="' + $('<div/>').text(item.url_link).html() + '" target="_blank" rel="noopener noreferrer"',
' style="font-weight:bold;color:#1a6496;text-decoration:none;">'
+ $('<div/>').text(item.title || item.url_link).html() + '</a>',
' <p style="margin:4px 0 0;font-size:0.85em;color:#555;">'
+ (item.content_description || item.digest || '') + '</p>',
'</li>'
);
});
html.push(' </ul>', '</div>');
$('#MainForm').after(html.join(''));
})();WARNING
スクリプト内で HTML を生成する際、ユーザー入力値は必ず $('<div/>').text(value).html() でエスケープしてください。XSS(クロスサイトスクリプティング)を防ぐために重要です。
動作確認
- 一覧画面を開き、検索ボックスが表示されることを確認する
- キーワードを入力して「検索」ボタンをクリックする
- 画面下部に Fess の検索結果が表示されることを確認する
応用: ラベルでテーブル単位に絞り込む
Fess には URL パターンをグループ化する「ラベル」機能があります。fields.label パラメータで特定ラベルのドキュメントだけに絞り込めるため、複数のテーブルをインデックスしている環境でも、現在のテーブルに関連するドキュメントだけを検索できます(Fess 側のラベル設定が必要です)。
// テーブルIDをラベルとして設定(Fessのラベル設定が必要)
var siteId = context.SiteId;
httpClient.RequestUri = fessUrl
+ '?q=' + encodeURIComponent(query)
+ '&fields.label=site_' + siteId
+ '&num=' + maxResults;Fess はファイルサーバ(SMB/NFS)や SharePoint、Confluence など多様なデータソースに対応しているため、プリザンター以外の情報資産も含めた横断検索の基盤にもできます。
Fess サーバを Azure の PaaS に構築する
Fess は社内ツールとして運用すると「誰でもアクセスできてしまう」という課題があり、機密情報をインデックス化するなら検索画面への認証が必須です。ここでは、次の構成で認証付きの Fess を構築する手順をまとめます。
| 役割 | 使うもの |
|---|---|
| Fess 本体 | Azure Container Apps(Docker コンテナ) |
| 永続ストレージ | Azure Files |
| コンテナイメージ | Azure Container Registry(ACR) |
| 認証 | Container Apps の Easy Auth + Google Workspace の OAuth / OIDC |
Easy Auth を使うので、Fess のコードや設定を変えずに Google Workspace アカウントでの認証を追加できます。
図を読み込み中…
INFO
Azure Container Apps の Easy Auth は OpenID Connect (OIDC) / OAuth 2.0 をネイティブにサポートしています。ここでは Google Workspace の OAuth 2.0 クライアントを使った OIDC 認証を使います。
| 前提 | 内容 |
|---|---|
| Azure サブスクリプション | 有効な Azure サブスクリプション |
| Google Workspace | 管理者権限がある Google Workspace 環境 |
| Azure CLI | 2.50 以降 |
| Docker | ローカルでビルドする場合のみ |
Fess はバックエンドの検索エンジンとして OpenSearch(または Elasticsearch)を使います。公式 Docker イメージ(codelibs/fess)には OpenSearch が内蔵されており、追加のセットアップなしで使えます。外部の OpenSearch / Elasticsearch クラスターに接続することもできますが、ここでは内蔵の OpenSearch を使います。
1. Azure リソースの作成
# 変数定義
RESOURCE_GROUP="rg-fess"
LOCATION="japaneast"
STORAGE_ACCOUNT="stfess$(date +%s | tail -c 6)"
SHARE_NAME="fess-data"
ACR_NAME="acrfess$(date +%s | tail -c 6)"
ENVIRONMENT_NAME="cae-fess"
APP_NAME="fess"
# リソースグループの作成
az group create --name $RESOURCE_GROUP --location $LOCATION
# Azure Container Registry の作成
az acr create \
--resource-group $RESOURCE_GROUP \
--name $ACR_NAME \
--sku Basic \
--admin-enabled true
# ストレージアカウントの作成
az storage account create \
--name $STORAGE_ACCOUNT \
--resource-group $RESOURCE_GROUP \
--location $LOCATION \
--sku Standard_LRS
# Azure Files 共有の作成(インデックス用)
az storage share create \
--account-name $STORAGE_ACCOUNT \
--name $SHARE_NAME \
--quota 1002. Fess コンテナイメージの準備
標準の codelibs/fess イメージをベースに、カスタム設定を追加したイメージを作って ACR にプッシュします。
FROM codelibs/fess:14.15.0
# CORS や認証設定を追加
COPY fess_config.properties \
/usr/share/fess/app/WEB-INF/classes/fess_config.properties# JSON API を有効化
api.json.enabled=true
# CORS 設定(Container Apps のドメインを指定)
api.cors.allow.origin=https://<fessアプリのドメイン>.azurecontainerapps.io
api.cors.allow.methods=GET,POST
api.cors.allow.headers=Content-Type,Authorization
api.cors.allow.credentials=false
# セッション設定
session.tracking.modes=COOKIEINFO
この構成では、JSON API の有効化を管理画面ではなく fess_config.properties の api.json.enabled=true で行っています。CORS の許可元も、前述の「CORS の設定」(プリザンターのドメイン、GET のみ)とは異なり、Container Apps のドメインと GET,POST を指定しています。
# ACR へログイン
az acr login --name $ACR_NAME
ACR_LOGIN_SERVER=$(az acr show \
--name $ACR_NAME \
--query loginServer \
--output tsv)
# イメージのビルドとプッシュ
docker build -t $ACR_LOGIN_SERVER/fess:latest .
docker push $ACR_LOGIN_SERVER/fess:latestローカルに Docker がない場合は、az acr build でクラウド上でビルドできます。
az acr build --registry $ACR_NAME --image fess:latest .3. Container Apps 環境とアプリの作成
# Container Apps 拡張機能のインストール(初回のみ)
az extension add --name containerapp --upgrade
# Container Apps 環境の作成
az containerapp env create \
--name $ENVIRONMENT_NAME \
--resource-group $RESOURCE_GROUP \
--location $LOCATION
# ストレージキーの取得
STORAGE_KEY=$(az storage account keys list \
--account-name $STORAGE_ACCOUNT \
--resource-group $RESOURCE_GROUP \
--query "[0].value" \
--output tsv)
# Container Apps 環境にストレージを登録
az containerapp env storage set \
--name $ENVIRONMENT_NAME \
--resource-group $RESOURCE_GROUP \
--storage-name fess-storage \
--azure-file-account-name $STORAGE_ACCOUNT \
--azure-file-account-key $STORAGE_KEY \
--azure-file-share-name $SHARE_NAME \
--access-mode ReadWrite# ACR の認証情報を取得
ACR_USERNAME=$(az acr credential show \
--name $ACR_NAME \
--query username \
--output tsv)
ACR_PASSWORD=$(az acr credential show \
--name $ACR_NAME \
--query "passwords[0].value" \
--output tsv)
# アプリの作成
az containerapp create \
--name $APP_NAME \
--resource-group $RESOURCE_GROUP \
--environment $ENVIRONMENT_NAME \
--image $ACR_LOGIN_SERVER/fess:latest \
--registry-server $ACR_LOGIN_SERVER \
--registry-username $ACR_USERNAME \
--registry-password $ACR_PASSWORD \
--ingress external \
--target-port 8080 \
--min-replicas 1 \
--max-replicas 1 \
--cpu 1.0 \
--memory 2.0Gi \
--volume-mount "volumeName=fess-vol,mountPath=/usr/share/fess/app/WEB-INF/data" \
--volumes "name=fess-vol,storageType=AzureFile,storageName=fess-storage"
# FQDN の確認
FESS_FQDN=$(az containerapp show \
--name $APP_NAME \
--resource-group $RESOURCE_GROUP \
--query "properties.configuration.ingress.fqdn" \
--output tsv)
echo "Fess URL: https://$FESS_FQDN"WARNING
/usr/share/fess/app/WEB-INF/data には次のデータが格納されます。Azure Files にマウントすることで、コンテナの再起動・再デプロイ後もこれらのデータが保持されます。
| データ | 説明 |
|---|---|
| OpenSearch インデックス | クロールしたドキュメントの全文検索インデックス |
| Fess 設定データ | クローラー設定・ラベル・ユーザー情報など |
| クロールキャッシュ | クロール済み URL の管理情報 |
4. Google Workspace で OAuth クライアントを作成する
Google Cloud Console の「API とサービス」→「認証情報」→「認証情報を作成」→「OAuth 2.0 クライアント ID」で、アプリケーションの種類「ウェブ アプリケーション」として次を設定し、クライアント ID とクライアントシークレットを控えます。
| 項目 | 設定値 |
|---|---|
| 名前 | Fess on Azure |
| 承認済みの JavaScript 生成元 | https://<FESS_FQDN> |
| 承認済みのリダイレクト URI | https://<FESS_FQDN>/.auth/login/google/callback |
続けて「API とサービス」→「OAuth 同意画面」で、ユーザーの種類を内部にし、アプリ名(例: Fess 社内検索)・サポートメール・開発者の連絡先を入力して保存します。
INFO
「内部」に設定すると、Google Workspace の組織アカウントを持つユーザーだけがアクセスでき、組織外のユーザーは拒否されます。
5. Container Apps の Easy Auth を設定する
# Easy Auth (Google OIDC) の設定
az containerapp auth google update \
--name $APP_NAME \
--resource-group $RESOURCE_GROUP \
--client-id "<GoogleクライアントID>" \
--client-secret "<Googleクライアントシークレット>" \
--allowed-audiences "https://$FESS_FQDN"
# 認証を有効化(未認証リクエストはリダイレクト)
az containerapp auth update \
--name $APP_NAME \
--resource-group $RESOURCE_GROUP \
--unauthenticated-client-action RedirectToLoginPage \
--redirect-provider Googleこれで https://<FESS_FQDN> にアクセスすると Google のログイン画面にリダイレクトされ、組織の Google Workspace アカウントでサインインしたユーザーだけが Fess を使えるようになります。
動作確認では、次を確かめます。
https://<FESS_FQDN>にアクセスすると Google アカウントのログイン画面が表示される- 組織の Google Workspace アカウントでログインすると Fess の検索画面が表示される
- 組織外の Google アカウントではアクセスが拒否される
INFO
Easy Auth は Fess の全ページへのアクセスを保護します。前述のようにプリザンターのサーバースクリプトからこの Fess の検索 API を呼び出す構成と組み合わせる場合の認証の扱いは、このページでは扱っていません。
Fess 管理画面の初期パスワード
Fess の管理画面(/admin/)は、Easy Auth とは別に Fess 独自の管理者ロールで保護されています。初期セットアップ時は、Container Apps の「コンソール」機能やシェルアクセスで管理者パスワードを設定するか、環境変数 FESS_ADMIN_PASSWORD で初期パスワードを設定します。
az containerapp update \
--name $APP_NAME \
--resource-group $RESOURCE_GROUP \
--set-env-vars "FESS_ADMIN_PASSWORD=<初期パスワード>"WARNING
初期パスワードはセットアップ後に変更してください。また、管理画面の URL に対する IP 制限を Container Apps のネットワーク設定で追加することを推奨します。
コストの目安
最小構成(開発・検証環境)の概算です。実際のコストは利用量により異なります。
| リソース | SKU | 概算コスト(月) |
|---|---|---|
| Azure Container Apps | 最小 1 レプリカ、1 CPU / 2 GB | 〜$30〜50 |
| Azure Files | Standard 100 GB | 〜$2〜5 |
| Azure Container Registry | Basic | 〜$5 |
| 合計 | 〜$37〜60 |
Container Apps は使用した CPU / メモリ時間に応じた従量課金で、1 レプリカ常時起動なら月 $30〜50 程度が目安です。
プリザンター以外のデータも検索する:Chatwork のログ
Fess はプリザンター以外のデータも同じインデックスで扱えます。例として、Chatwork のチャットログを Azure の PaaS で定期取得して CSV にし、Fess で複数ルームを横断して全文検索できるようにする仕組みをまとめます(Chatwork の検索機能はルーム単位の簡易的なもので、横断検索には向きません)。Fess は Azure の VM、Azure Container Apps、社内サーバなど任意の場所で稼働している前提です。
図を読み込み中…
- Azure Functions(タイマートリガー)が定期的に Chatwork API からメッセージを取得する
- 取得したメッセージを Azure Blob Storage に CSV 形式で保存する
- 前回の取得位置(メッセージ ID)を Azure Table Storage で管理する
- API トークンは Azure Key Vault で管理する
- Fess サーバが BlobFuse2 で Blob Storage をマウントし、CSV をクロールする
| サービス | 用途 | 料金目安 |
|---|---|---|
| Azure Functions(従量課金) | Chatwork API からのメッセージ取得 | 月 100 万実行まで無料 |
| Azure Blob Storage | CSV 形式のログ保存 | 数 GB ならほぼ無料 |
| Azure Table Storage | 取得位置の状態管理 | ほぼ無料 |
| Azure Key Vault | API トークンのシークレット管理 | 月 1 万操作まで無料枠あり |
Chatwork API の制約
| 項目 | 内容 |
|---|---|
| エンドポイント | GET /v2/rooms/{room_id}/messages |
| 認証 | ヘッダー x-chatworktoken に API トークンを指定 |
| 1 回の取得件数 | 最大 100 件 |
force パラメータ | 0: 未読のみ取得(差分) / 1: 最新 100 件を強制取得 |
| レート制限 | HTTP 429 返却時はリトライが必要 |
1 回あたり最新 100 件までしか取得できないため、過去ログを蓄積するには定期的に実行して差分を保存していく設計にします。
API トークンは、Chatwork の画面右上のアカウントメニュー →「サービス連携」→「API トークン」タブで発行します。対象ルームの ID は、ルーム URL(https://www.chatwork.com/#!rid123456789 の rid に続く数字部分)で確認できます。
Azure リソースの作成
# リソースグループの作成
az group create --name rg-chatwork-fess --location japaneast
# ストレージアカウントの作成
az storage account create \
--name stchatworkfess \
--resource-group rg-chatwork-fess \
--location japaneast \
--sku Standard_LRS
# Blob コンテナの作成
az storage container create \
--name chatwork-csv \
--account-name stchatworkfess
# テーブルの作成(状態管理用)
az storage table create \
--name ChatworkState \
--account-name stchatworkfess
# Key Vault の作成
az keyvault create \
--name kv-chatwork-fess \
--resource-group rg-chatwork-fess \
--location japaneast
# Key Vault に Chatwork API トークンを登録
az keyvault secret set \
--vault-name kv-chatwork-fess \
--name ChatworkApiToken \
--value "<APIトークン>"
# Function App の作成
az functionapp create \
--name func-chatwork-fess \
--resource-group rg-chatwork-fess \
--storage-account stchatworkfess \
--consumption-plan-location japaneast \
--runtime python \
--runtime-version 3.11 \
--functions-version 4 \
--os-type LinuxAzure Functions から Key Vault のシークレットを参照するため、マネージド ID を有効化してアクセス権を付与し、アプリケーション設定に Key Vault 参照とルーム ID を追加します。
# システム割り当てマネージド ID の有効化
az functionapp identity assign \
--name func-chatwork-fess \
--resource-group rg-chatwork-fess
# Key Vault へのアクセス権を付与
az keyvault set-policy \
--name kv-chatwork-fess \
--object-id <上のコマンドで表示されたprincipalId> \
--secret-permissions get
# Key Vault 参照で API トークンを設定
az functionapp config appsettings set \
--name func-chatwork-fess \
--resource-group rg-chatwork-fess \
--settings \
"CHATWORK_API_TOKEN=@Microsoft.KeyVault(VaultName=kv-chatwork-fess;SecretName=ChatworkApiToken)" \
"CHATWORK_ROOM_IDS=123456789,987654321"@Microsoft.KeyVault(...) 形式で参照するため、API トークンがコードやアプリ設定に平文で保存されることはありません。
Azure Functions の実装(Python)
chatwork-fess-func/
├── function_app.py # メイン関数
├── host.json # Functions ホスト設定
├── local.settings.json # ローカル開発用設定
└── requirements.txt # Python依存パッケージazure-functions
requests
azure-data-tables
azure-storage-blob{
"version": "2.0",
"logging": {
"applicationInsights": {
"samplingSettings": {
"isEnabled": true,
"excludedTypes": "Request"
}
}
}
}{
"IsEncrypted": false,
"Values": {
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"FUNCTIONS_WORKER_RUNTIME": "python",
"CHATWORK_API_TOKEN": "ローカル用のAPIトークン",
"CHATWORK_ROOM_IDS": "123456789,987654321"
}
}WARNING
local.settings.json はローカル開発専用です。.gitignore に追加して、リポジトリにコミットしないでください。
メイン関数は毎時 0 分に実行されます。ルームごとに Table Storage から前回の最終メッセージ ID を読み、初回(最終 ID が 0)は force=1、以降は force=0 で取得し、前回より新しいメッセージだけを CSV に追記して、最後に CSV 全体を Blob Storage へ上書きアップロードします。HTTP 429 のときは Retry-After の秒数だけ待ってリトライします。
"""Chatworkメッセージ取得 → Azure Blob Storage(CSV)出力"""
import csv
import io
import logging
import os
import time
from datetime import datetime, timezone, timedelta
import azure.functions as func
import requests
from azure.data.tables import TableServiceClient
from azure.storage.blob import BlobServiceClient
app = func.FunctionApp()
JST = timezone(timedelta(hours=9))
API_BASE = "https://api.chatwork.com/v2"
def get_headers():
"""Chatwork API用ヘッダーを生成する"""
return {
"Accept": "application/json",
"x-chatworktoken": os.environ["CHATWORK_API_TOKEN"],
}
def fetch_room_name(room_id):
"""ルーム名を取得する"""
resp = requests.get(f"{API_BASE}/rooms/{room_id}", headers=get_headers())
resp.raise_for_status()
return resp.json().get("name", f"Room {room_id}")
def fetch_messages(room_id, force=0):
"""指定ルームのメッセージを取得する"""
params = {"force": force}
resp = requests.get(
f"{API_BASE}/rooms/{room_id}/messages",
headers=get_headers(),
params=params,
)
if resp.status_code == 204:
return []
if resp.status_code == 429:
retry_after = int(resp.headers.get("Retry-After", "60"))
logging.warning("レート制限: %d秒待機します...", retry_after)
time.sleep(retry_after)
return fetch_messages(room_id, force)
resp.raise_for_status()
return resp.json()
def get_last_id(table_client, room_id):
"""Table Storageから前回の最終メッセージIDを取得する"""
try:
entity = table_client.get_entity(
partition_key="chatwork", row_key=str(room_id)
)
return int(entity["LastMessageId"])
except Exception:
return 0
def save_last_id(table_client, room_id, message_id):
"""Table Storageに最終メッセージIDを保存する"""
table_client.upsert_entity({
"PartitionKey": "chatwork",
"RowKey": str(room_id),
"LastMessageId": message_id,
})
def format_timestamp(unix_ts):
"""UNIXタイムスタンプをJST日時文字列に変換する"""
dt = datetime.fromtimestamp(unix_ts, tz=JST)
return dt.strftime("%Y-%m-%d %H:%M:%S")
@app.function_name(name="FetchChatworkMessages")
@app.timer_trigger(
schedule="0 0 * * * *",
arg_name="timer",
run_on_startup=False,
)
def fetch_chatwork_messages(timer: func.TimerRequest) -> None:
"""毎時0分にChatworkメッセージを取得してBlob StorageへCSV出力する"""
if timer.past_due:
logging.info("タイマーが遅延しています。スキップせず実行します。")
conn_str = os.environ["AzureWebJobsStorage"]
room_ids = os.environ["CHATWORK_ROOM_IDS"].split(",")
# Table Storageクライアント(状態管理)
table_service = TableServiceClient.from_connection_string(conn_str)
table_client = table_service.get_table_client("ChatworkState")
# Blob Storageクライアント(CSV出力)
blob_service = BlobServiceClient.from_connection_string(conn_str)
container_client = blob_service.get_container_client("chatwork-csv")
# 既存CSVがあれば読み込む
blob_name = "chatwork_messages.csv"
existing_rows = []
try:
blob_data = container_client.download_blob(blob_name)
reader = csv.reader(io.StringIO(blob_data.readall().decode("utf-8")))
existing_rows = list(reader)
except Exception:
existing_rows = []
new_count = 0
output = io.StringIO()
writer = csv.writer(output)
# ヘッダー行
if not existing_rows:
writer.writerow([
"message_id", "room_id", "room_name",
"account_name", "body", "send_datetime", "url",
])
else:
for row in existing_rows:
writer.writerow(row)
for room_id in room_ids:
room_id = room_id.strip()
logging.info("ルーム %s を取得中...", room_id)
room_name = fetch_room_name(room_id)
last_id = get_last_id(table_client, room_id)
force = 1 if last_id == 0 else 0
messages = fetch_messages(room_id, force=force)
max_id = last_id
for msg in messages:
msg_id = int(msg["message_id"])
if msg_id <= last_id:
continue
chatwork_url = (
f"https://www.chatwork.com/#!rid{room_id}-{msg_id}"
)
writer.writerow([
msg_id,
room_id,
room_name,
msg["account"]["name"],
msg["body"],
format_timestamp(msg["send_time"]),
chatwork_url,
])
new_count += 1
if msg_id > max_id:
max_id = msg_id
if max_id > last_id:
save_last_id(table_client, room_id, max_id)
time.sleep(2)
# CSVをBlob Storageにアップロード
container_client.upload_blob(
blob_name, output.getvalue().encode("utf-8"), overwrite=True
)
logging.info("完了: %d件の新規メッセージを追加しました", new_count)図を読み込み中…
デプロイは Azure Functions Core Tools で行います。
cd chatwork-fess-func
func azure functionapp publish func-chatwork-fessFess サーバに Blob Storage をマウントする(BlobFuse2)
Blob Storage 上の CSV を Fess からクロールするため、Fess が稼働する Linux サーバで BlobFuse2 を使い、Blob Storage をファイルシステムとしてマウントします。
# BlobFuse2のインストール(Ubuntu/Debian)
sudo apt-get install -y blobfuse2
# マウントポイントの作成
sudo mkdir -p /mnt/chatwork-csv
# 設定ファイルの作成
sudo tee /etc/blobfuse2-chatwork.yaml > /dev/null << 'EOF'
allow-other: true
logging:
type: syslog
level: log_warning
components:
- libfuse
- file_cache
- attr_cache
- azstorage
libfuse:
attribute-expiration-sec: 120
entry-expiration-sec: 120
file_cache:
path: /tmp/blobfuse2-cache
timeout-sec: 120
attr_cache:
timeout-sec: 3600
azstorage:
type: block
account-name: stchatworkfess
account-key: "<ストレージアカウントキー>"
container: chatwork-csv
endpoint: https://stchatworkfess.blob.core.windows.net
EOF
# マウント実行
sudo blobfuse2 mount /mnt/chatwork-csv --config-file=/etc/blobfuse2-chatwork.yamlaccount-key の代わりにマネージド ID や SAS トークンも使えます。サーバ再起動時にも自動でマウントされるよう、/etc/fstab に追加します。
blobfuse2 /mnt/chatwork-csv fuse _netdev,--config-file=/etc/blobfuse2-chatwork.yaml,allow_other 0 0Fess のデータストアクロール設定
Fess 管理画面の「クローラ」→「データストア」→「新規作成」で、名前を Chatworkログ、ハンドラ名を CsvDataStore にして、パラメータとスクリプトを設定します。
directories=/mnt/chatwork-csv
fileEncoding=UTF-8
separatorCharacter=,
skipLines=1| パラメータ | 説明 |
|---|---|
directories | BlobFuse2 でマウントしたパス |
fileEncoding | CSV のエンコーディング |
separatorCharacter | 区切り文字(カンマ) |
skipLines | ヘッダー行をスキップ |
スクリプトで CSV の各列を Fess の検索フィールドにマッピングします。
url=cell7
title="[" + cell3 + "] " + cell4 + " (" + cell6 + ")"
content=cell5
digest=cell5| フィールド | マッピング内容 | 説明 |
|---|---|---|
url | cell7(Chatwork URL) | 検索結果から Chatwork に直接遷移 |
title | ルーム名 + 発言者 + 日時 | 検索結果のタイトル表示 |
content | cell5(メッセージ本文) | 全文検索対象 |
digest | cell5(メッセージ本文) | 検索結果の要約表示 |
図を読み込み中…
Azure Functions は毎時 0 分に実行されるので、Fess のクロールを毎時 30 分にすると新しいメッセージが確実にインデックスされます。管理画面の「システム」→「スケジューラ」で「Default Crawler」のスケジュールを設定します。
図を読み込み中…
運用上の注意点
- 取りこぼし。 1 回あたり最新 100 件しか取得できないため、メッセージの多いルームでは取りこぼしが起こり得ます。タイマースケジュールを
0 */15 * * * *(15 分ごと)に変えるか、エンタープライズプランの公式エクスポート機能の利用を検討します - セキュリティ。 API トークンは Key Vault 参照で管理し、コードやアプリ設定に平文で保存しない。BlobFuse2 の
account-keyも Key Vault やマネージド ID で管理することを推奨します。Fess の管理画面パスワードは初回ログイン後に必ず変更します - コスト。 従量課金プランの Azure Functions は月 100 万実行まで無料、Blob Storage も数 GB 程度ならほぼ無料です。Azure Portal の「コスト管理」で定期的に確認します
- データ量。 メッセージが増えると CSV が肥大化します。月次でファイルを分割する(例:
chatwork_messages_202603.csvのようなファイル名で出力するよう関数を修正する)か、一定期間で古いデータをアーカイブする運用を検討します