ken_nogi/lineworks-sync/lw_board_check.spec.md
Kenichiro NOGI ed33892f08 chore: 作業中の変更を整理しコミット(複数プロジェクト分)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 11:09:50 +09:00

125 lines
14 KiB
Markdown

# lw_board_check.py 仕様書
## 1. 概要
LINE WORKS の掲示板一覧を取得し、番号で指定した掲示板1件について、全投稿の既読者・未読者を一覧化する診断スクリプト。
主目的は、特定掲示板の投稿ごとに「誰が読んで誰が読んでいないか」を洗い出すこと。`readers` API のレスポンスに含まれる `isRead` フラグで既読/未読を判定する(既読者・未読者は同一APIから取得でき、専用の未読者APIは存在しない)。
認証方式は起動時に選択する2択(6節)。基本は [lineworks-anythingllm.js](lineworks-anythingllm.js) と同じ JWT Bearer(Service Account)方式だが、**必読投稿の readers は Service Account では 403 ACCESS_DENIED になることを確認済み**(8節)。投稿者本人でないと既読状況を閲覧できない仕様と推測されるため、本人がブラウザでログインする authorization_code 方式を代替手段として用意している。
`lw_board_check/` フォルダに本体・専用 `.env`・秘密鍵・利用者向けマニュアルを一式まとめ、単体で配布できる形にしている(2節・9節)。依存モジュールが無い環境でも初回起動時に自動インストールされ、掲示板IDを引数指定すれば対話なしで自動実行できる(引数無しなら従来通り対話形式)。
## 2. 実行環境・依存パッケージ
- Python 3.9 以上
- 追加ライブラリ: `requests` / `cryptography`
- `requests`: LINE WORKS API 呼び出し
- `cryptography`: JWTアサーション(RS256)の自前署名。Node.js版は標準 `crypto` モジュールで完結するが、Python 標準ライブラリには RSA 署名機能が無いため追加が必要。
- **未インストールでも起動時に自動でインストールされる**(`_ensure_modules`。`import`を試して失敗したものだけ `pip install` する。配布先PCに事前準備を求めない設計)。
- 実行例: `python lw_board_check.py`(`py`ランチャー経由だと環境によって即終了する不具合を確認済み。`python`コマンドを使うこと)
- 引数無しは対話形式、`python lw_board_check.py <掲示板ID>` で認証方式選択・掲示板選択を省略した自動実行になる(6節)。
## 3. 認証・トークン管理
起動時に `choose_auth_mode` でどちらかを選ぶ。
### 3.1 方式1: JWT Bearer(Service Account)
- [lineworks-anythingllm.js](lineworks-anythingllm.js) の `createJwtAssertion` / `getAccessToken` を移植したもの。
- JWTアサーション(RS256)を `cryptography``private_key.sign()` で自前署名し、`urn:ietf:params:oauth:grant-type:jwt-bearer` グラントでトークンエンドポイントからアクセストークンを取得する(`get_token` / `_create_jwt_assertion`)。
- 短時間の一括処理のみのため、js版のような45分ごとのトークン自動更新の仕組みは持たない。起動時に1回取得したトークンをそのまま使い切る。
- トークンファイルへの永続化は無い(JWT Bearer方式は毎回アサーションを作り直すだけで完結するため)。
### 3.2 方式2: 本人ログイン(authorization_code)
- 投稿者本人がブラウザでLINE WORKSにログインして同意することで、その人の権限でAPIを叩く(`get_user_token` / `_authorize_with_browser`)。
- 初回のみブラウザが自動で開き、ログイン後のリダイレクト先URLを標準入力に貼り付ける(`state`パラメータでCSRF対策)。取得した `access_token`/`refresh_token` を `lw_user_token.json`(スクリプトと同じフォルダ)に保存する。
- 2回目以降は `lw_user_token.json``refresh_token` で自動更新を試み(`_refresh_user_token`)、失敗(400/401)した場合のみ再度ブラウザログインを行う。
- `lw_user_token.json` はアクセストークン・リフレッシュトークンを含む機密ファイル。第三者と共有しないこと(9節・注意も参照)。
## 4. 環境変数
同じフォルダの `.env` があれば自動読込する(`_load_dotenv`。簡易パーサーで `KEY=VALUE` 形式のみ対応、既に環境変数にセットされている場合は上書きしない)。
| 変数名 | 既定値 | 用途 |
|---|---|---|
| `LW_CLIENT_ID` | (コード内既定値あり) | LINE WORKS API クライアントID |
| `LW_CLIENT_SECRET` | (コード内既定値あり) | クライアントシークレット |
| `LW_SERVICE_ACCOUNT` | (コード内既定値あり) | Service Account のメールアドレス |
| `LW_PRIVATE_KEY_FILE` | `./private_20260709122316.key` | 秘密鍵ファイルのパス(相対パスはスクリプトと同じフォルダ基準)。方式1でのみ使用 |
| `LW_SCOPE` | `board board.read` | 取得スコープ。両方式共通 |
| `LW_REDIRECT_URI` | `https://developers.worksmobile.com/jp/redirect` | 方式2で使うOAuth Redirect URI。Developer Console側で許可されている必要あり |
既定値はリポジトリ直下の `.env` / `lineworks-anythingllm.js` と同じ Service Account の値を採用している。別の Service Account を使う場合は `.env` 側の値を変更すればよい(コード変更不要)。
## 5. 事前設定(Developer Console)
- Service Account を作成し、秘密鍵(.key)をダウンロードして `LW_PRIVATE_KEY_FILE` の場所に配置する(方式1用)。
- Server List(API権限設定)で `board` / `board.read` スコープを許可する。
- 方式2(本人ログイン)を使う場合は、OAuth Client の Redirect URI に `LW_REDIRECT_URI` の値が登録されていること。既定値の `https://developers.worksmobile.com/jp/redirect` はLINE WORKS公式のテスト用リダイレクトページで、通常は追加設定なしで使える。
## 6. 処理フロー
0. コマンドライン引数を解析(`parse_args`)。`board_id`(位置引数、省略可)と `--auth`(`1`/`2`、省略可)を受け付ける。
1. 認証方式を決定。`--auth` を指定していればその値、未指定なら `board_id` の有無に関わらず毎回対話で選択させる(`choose_auth_mode`)。`board_id` はあくまで掲示板選択メニューだけを省略するオプションで、認証方式の選択は独立している。
2. アクセストークンを取得(3節)。取得失敗時はエラーメッセージを表示して終了コード1で終了。方式2で初回実行時はブラウザが開き、ログイン後のリダイレクトURLの貼り付けを求められる。
3. `GET /boards` で全掲示板を取得(`fetch_all_boards`。カーソルページング、1ページ100件)。
4. 掲示板を決定。`board_id` 未指定なら「番号. 掲示板名」の一覧を表示し標準入力で1件選ばせる(`choose_board`。範囲外・非数値は再入力を促す)。`board_id` 指定時は一覧から一致するものを検索する(`find_board`)。`find_board` は「一覧の表示番号(1〜掲示板件数)」と「実際の掲示板ID」の両方を受け付け、1〜件数の範囲に収まる値は表示番号として優先的に解釈する(実際の掲示板IDは19桁程度でこの範囲と衝突しないため)。見つからなければエラー終了。処理対象は常に1掲示板のみ。
5. 選択した掲示板の全投稿を `GET /boards/{boardId}/posts` で取得(`fetch_all_posts`。カーソルページング、1ページ40件 = API仕様上のcount上限)。
6. 投稿ごとに以下を実行:
1. 1秒待機後、`GET /boards/{boardId}/posts/{postId}/readers` で既読者・未読者を全件取得(`fetch_all_readers`。カーソルページング、1ページ100件)。
2. レスポンス中の各ユーザーの `isRead` フラグで既読者・未読者に振り分ける。
3. **投稿ごとに、既読者・未読者1人につきCSV1行を出力する**(ロング形式)。1投稿の行数 = readers件数。
4. readers取得が非200の場合はメンバー名・既読・未読を空欄にした1行のみ出力する(投稿の存在自体は記録に残す)。
6.5. 各投稿は認証方式に応じて事前にフィルタする(6.1節)。
7. 全投稿処理後、CSVとJSONを出力して終了。
### 6.1 投稿の対象フィルタ
必読投稿(`isMustRead: true`)はService Accountでは403確定(8節)なので、認証方式ごとに処理対象を絞り込む。
| 認証方式 | 対象 | 除外条件 |
|---|---|---|
| 1(Service Account) | `isMustRead: false` の投稿のみ | `isMustRead: true` の投稿はreaders取得すら行わずスキップ(事前フィルタ) |
| 2(本人ログイン) | 必読/通常を問わず、実際にreaders取得できた投稿すべて | readers取得が403なら「自分の投稿ではない」とみなしスキップ(事前フィルタは無く、都度APIを叩いて判定) |
方式2は「必読投稿だけ」に絞る事前フィルタを持たない。全投稿に対して実際にreaders取得を試み、**200/403の結果だけで採否を判定する**(`main`内、`auth_mode == "2" and r_status != 200` の分岐)。これにより通常投稿・自分の必読投稿の両方が同じ実行で取得できる。ユーザー情報APIとの突き合わせは行わない — LINE WORKS側の「投稿者本人でないと必読投稿の既読状況は閲覧できない」という実機確認済みの制約(8節)を逆手に取った判定方法。
対象外になった投稿は完了メッセージの「対象外」件数にのみ計上され、CSV/JSONには一切出力されない。
## 7. 出力ファイル
`board_readers_check/<掲示板名(サニタイズ済み)>/` フォルダ配下に、`<掲示板名>_<種別>_<YYYYMMDD_HHMM>` をファイル名として以下3ファイルを出力する(`sanitize_filename` でファイル名に使えない文字 `\ / : * ? " < > |``_` に置換)。掲示板ごとにフォルダが分かれるため、同じ掲示板を複数回実行した結果は時刻違いのファイルとして同フォルダに蓄積される。
| ファイル | 内容 |
|---|---|
| `<掲示板名>_掲示板読了チェック_<日時>.csv` | **ユーザー単位のロング形式**。列: 投稿タイトル・投稿日時・必読フラグ・既読者数・未読者数・メンバー名・既読・未読(Excel用、UTF-8 BOM付き) |
| `<掲示板名>_掲示板読了チェック_<日時>.json` | 選択した掲示板の `board_id` / `board_name` と、投稿ごとの `post` オブジェクト・`readers` 生データ(調査用) |
| `<掲示板名>_未読者集計_<日時>.csv` | **メンバー単位の集計**。列: メンバー名・全記事数・既読記事数・未読記事数・未読記事リスト(Excel用、UTF-8 BOM付き) |
CSV(掲示板読了チェック)の「既読」「未読」列は排他的な0/1フラグ(`readers` の `isRead` から算出)。既読者の行は 既読=1・未読=0、未読者の行はその逆になる。「既読者数」「未読者数」は投稿単位の集計値で、同じ投稿の全行に同じ値が入る(Excelでのフィルタ・ピボット集計を想定した冗長化)。
未読者集計CSVは、掲示板読了チェックCSVの行(`csv_rows`)をメンバー名でグループ化して作成する(`member_stats`)。メンバー名が空の行(readers取得失敗による1行)は集計対象から除外する。「未読記事リスト」は未読(`未読=1`)の投稿タイトルを `;` で連結したもの。
## 8. 制限事項・注意点
- 一度の実行で処理できる掲示板は1件のみ(複数選択・全件一括処理は非対応)。
- **必読投稿(`isMustRead: true`)のreadersはService Account(方式1)では例外なく403 ACCESS_DENIED になることを実機検証で確認済み。** 掲示板マスター権限をService Accountに追加しても解消しなかった。投稿者本人でないと既読状況を閲覧できない仕様と推測される。この場合は方式2(本人ログイン)を使うこと。
- 429(Rate Limit)時の自動リトライは無い。投稿ごとにreaders取得前に1秒待機するのみ。
- 未読者は `readers` API が返す一覧のうち `isRead: false` のユーザー。掲示板の閲覧対象者(宛先)自体を取得するAPIではないため、そもそも閲覧対象外のユーザーは一覧に含まれない。
- `lw_user_token.json`(方式2のトークン保存先)は機密情報。Gitにコミットしない・第三者と共有しないこと。
## 9. 関連ファイル
本体は配布用フォルダ `lw_board_check/` にまとめてある(利用ユーザーへはこのフォルダごと配布する)。
- [lw_board_check/lw_board_check.py](lw_board_check/lw_board_check.py) — 本体
- [lw_board_check/.env](lw_board_check/.env) — 専用の認証情報(`LW_*`)。リポジトリ直下の `.env` から必要項目のみ転記したもの
- `lw_board_check/private_20260709122316.key` — 認証用秘密鍵(リポジトリ直下のものと同一内容をコピー。元ファイルは他スクリプトも参照しているため移動ではなくコピー)
- [lw_board_check/マニュアル.md](lw_board_check/マニュアル.md) — 利用ユーザー向けマニュアル(本仕様書とは別に、実行方法・トラブルシューティングをまとめたもの)
- `lw_board_check/lw_user_token.json` — 方式2(本人ログイン)のトークン保存先。機密ファイル、実行時に自動生成
- `lw_board_check/board_readers_check/` — 出力先フォルダ(実行時に無ければ自動作成、掲示板名ごとにサブフォルダ)
- [lineworks-anythingllm.js](lineworks-anythingllm.js) — 認証方式(JWT Bearer)の移植元
- [lineworks-anythingllm.spec.md](lineworks-anythingllm.spec.md) — 移植元スクリプトの仕様書