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

574 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
LINE WORKS 掲示板 既読/未読チェック スクリプト
指定した掲示板の全投稿について、既読者・未読者を一覧化する。
使い方:
python lw_board_check.py 対話形式(掲示板一覧・対象を選択)
python lw_board_check.py --board <名前/番号/ID> 掲示板選択を省略して実行(対象は両方)
python lw_board_check.py --board <名前/番号/ID> --target must|normal|both
対象を指定して自動実行
出力:
board_readers_check/<掲示板名>/<YYYYMMDD_HHMM>_掲示板読了チェック.csv - サマリーExcel用
board_readers_check/<掲示板名>/<YYYYMMDD_HHMM>_掲示板読了チェック.json - 全レスポンスの生データ
board_readers_check/<掲示板名>/<YYYYMMDD_HHMM>_未読者集計.csv - メンバー別集計
board_readers_check/<掲示板名>/<YYYYMMDD_HHMM>_取得ログ.txt - 実行ログ(行ごとにタイムスタンプ付き)
必要ライブラリ:
requests, cryptography未インストールなら初回起動時に自動でpip installする
認証:
- 掲示板一覧・投稿一覧・通常投稿の既読/未読は Service AccountJWT Bearerで取得する。
- 必読投稿の既読/未読は投稿者本人でないと閲覧できない仕様のため、oauth/ フォルダに
保存したパスワード付きzipoauth_token_collector/ で各投稿者に発行してもらった
認証情報を自動で読み込み、順に試して200が返ったものだけ採用する。
どの認証情報とも一致しない必読投稿は対象外になる(投稿者名を取得ログに記録する)。
対象の絞り込み:
--target both既定/ must必読のみ/ normal通常のみで絞り込める。
掲示板を指定せず対話形式で実行した場合は選択メニューが出る。
事前設定Developer Console:
- Service Account を作成し、秘密鍵(.key)をダウンロード
- Server List に board / board.read スコープを許可
事前準備(必読投稿を扱う場合):
- oauth_token_collector/ のツールで各投稿者に認証情報zipを発行してもらい、
lw_board_check/oauth/ フォルダに保存しておく
zip解凍パスワードは oauth_token_collector/zip_password.txt を自動で使用する)
環境変数(未設定時は下記の既定値を使用。同じフォルダの .env があれば自動読込):
LW_CLIENT_ID
LW_CLIENT_SECRET
LW_SERVICE_ACCOUNT
LW_PRIVATE_KEY_FILE (鍵ファイルのパス)
LW_SCOPE 既定: board board.read
"""
import argparse
import base64
import csv
import json
import os
import re
import subprocess
import sys
import time
import zipfile
from datetime import datetime
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent
# ------------------------------------------------------------
# 必要モジュールの自動インストールrequests / cryptography が無ければ pip install
# 揃っていれば何もせずそのまま先に進む。
# ------------------------------------------------------------
def _ensure_modules():
required = {"requests": "requests", "cryptography": "cryptography"}
missing = []
for import_name, pip_name in required.items():
try:
__import__(import_name)
except ImportError:
missing.append(pip_name)
if missing:
print(f"必要なモジュールが見つかりません: {', '.join(missing)}")
print("インストールします...")
subprocess.check_call([sys.executable, "-m", "pip", "install", *missing])
print("インストール完了。\n")
_ensure_modules()
import requests
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
# ------------------------------------------------------------
# .env 読込(同じフォルダに .env があれば環境変数へ反映。無ければ何もしない)
# ------------------------------------------------------------
def _load_dotenv(path: Path = BASE_DIR / ".env"):
if not path.exists():
return
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
key, value = key.strip(), value.strip()
if key and key not in os.environ:
os.environ[key] = value
_load_dotenv()
# ============================================================
# ★ 設定(環境変数優先。無ければ既定値)
# ============================================================
CLIENT_ID = os.environ.get("LW_CLIENT_ID", "b6UHrJEXVKbcwL8AVdCO")
CLIENT_SECRET = os.environ.get("LW_CLIENT_SECRET", "i0PZwPmqdH")
SERVICE_ACCOUNT = os.environ.get("LW_SERVICE_ACCOUNT", "v4s3m.serviceaccount@next-hd.co.jp")
PRIVATE_KEY_FILE = os.environ.get("LW_PRIVATE_KEY_FILE", "./private_20260709122316.key")
SCOPE = os.environ.get("LW_SCOPE") or "board board.read"
# ============================================================
API_BASE = "https://www.worksapis.com/v1.0"
TOKEN_URL = "https://auth.worksmobile.com/oauth2/v2.0/token"
OAUTH_DIR = BASE_DIR / "oauth"
ZIP_PASSWORD_FILE = BASE_DIR / "oauth_token_collector" / "zip_password.txt"
# ------------------------------------------------------------
# 認証JWT Bearer / Service Account
# 掲示板一覧・投稿一覧・通常投稿の既読/未読はこちらで取得する。
# ------------------------------------------------------------
def _b64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def _create_jwt_assertion() -> str:
now = int(time.time())
header = {"alg": "RS256", "typ": "JWT"}
payload = {
"iss": CLIENT_ID,
"sub": SERVICE_ACCOUNT,
"aud": TOKEN_URL,
"iat": now,
"exp": now + 300,
}
signing_input = f"{_b64url(json.dumps(header).encode())}.{_b64url(json.dumps(payload).encode())}"
key_path = Path(PRIVATE_KEY_FILE)
if not key_path.is_absolute():
key_path = BASE_DIR / key_path
if not key_path.exists():
raise RuntimeError(f"秘密鍵が見つかりません: {key_path}")
private_key = serialization.load_pem_private_key(key_path.read_bytes(), password=None)
signature = private_key.sign(signing_input.encode(), padding.PKCS1v15(), hashes.SHA256())
return f"{signing_input}.{_b64url(signature)}"
def get_token() -> str:
assertion = _create_jwt_assertion()
resp = requests.post(TOKEN_URL, data={
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
"assertion": assertion,
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
"scope": SCOPE,
}, timeout=30)
resp.raise_for_status()
data = resp.json()
token = data.get("access_token")
if not token:
raise RuntimeError("アクセストークン取得失敗: access_tokenが返却されませんでした")
return token
# ------------------------------------------------------------
# 認証(本人ログイン / OAuth refresh_token
# 必読投稿の既読/未読は投稿者本人でないと閲覧できない仕様のため、
# oauth_token_collector/ で各投稿者に発行してもらった認証情報zipoauth/ フォルダに
# 保存済みを読み込み、refresh_tokenでaccess_tokenを更新して使う。
# ------------------------------------------------------------
def _refresh_user_token(refresh_token: str):
resp = requests.post(TOKEN_URL, data={
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
}, timeout=30)
if resp.status_code in (400, 401):
return None
resp.raise_for_status()
return resp.json()
def _zip_password() -> str:
if not ZIP_PASSWORD_FILE.exists():
return ""
return ZIP_PASSWORD_FILE.read_text(encoding="utf-8").strip()
def load_oauth_sessions() -> list:
"""oauth/ フォルダのパスワード付きzipから認証情報を読み込み、refresh_tokenで
access_tokenを更新したうえでSessionのリストを返す必読投稿の閲覧権限照合用
"""
if not OAUTH_DIR.exists():
return []
password = _zip_password()
sessions = []
for zip_path in sorted(OAUTH_DIR.glob("*.zip")):
try:
with zipfile.ZipFile(zip_path) as zf:
entry_names = zf.namelist()
if not entry_names:
continue
raw = zf.read(entry_names[0], pwd=password.encode("utf-8"))
info = json.loads(raw.decode("utf-8-sig"))
except Exception as e:
print(f"警告: {zip_path.name} の読み込みに失敗しました: {e}", file=sys.stderr)
continue
login_id = info.get("login_id", zip_path.stem)
refresh_token = info.get("refresh_token")
if not refresh_token:
print(f"警告: {login_id} の認証情報にrefresh_tokenがありません", file=sys.stderr)
continue
refreshed = _refresh_user_token(refresh_token)
if not refreshed:
print(f"警告: {login_id} のトークン更新に失敗しました(有効期限切れの可能性)", file=sys.stderr)
continue
session = requests.Session()
session.headers.update({
"Authorization": f"Bearer {refreshed['access_token']}",
"Accept": "application/json",
})
sessions.append({"login_id": login_id, "session": session})
return sessions
# ------------------------------------------------------------
# API ヘルパー
# ------------------------------------------------------------
def api_get(session, url, params=None):
"""GETして (status_code, body_dict) を返す"""
try:
resp = session.get(url, params=params, timeout=30)
try:
body = resp.json()
except Exception:
body = {"_raw": resp.text}
return resp.status_code, body
except Exception as e:
return 0, {"_error": str(e)}
def _next_cursor(body: dict, cursor: str) -> str:
nxt = (body.get("responseMetaData") or {}).get("nextCursor") or ""
return "" if nxt == cursor else nxt
def fetch_all_boards(session) -> list:
"""アクセス可能な全掲示板を取得(カーソルページング)"""
boards, cursor = [], ""
while True:
params = {"count": 100}
if cursor:
params["cursor"] = cursor
status, body = api_get(session, f"{API_BASE}/boards", params=params)
if status != 200:
raise RuntimeError(f"掲示板一覧取得失敗: HTTP {status} / {body}")
boards.extend(body.get("boards", []))
cursor = _next_cursor(body, cursor)
if not cursor:
break
return boards
def fetch_all_posts(session, board_id) -> list:
"""指定掲示板の全投稿を取得カーソルページング。count上限は40"""
posts, cursor = [], ""
while True:
params = {"count": 40}
if cursor:
params["cursor"] = cursor
status, body = api_get(session, f"{API_BASE}/boards/{board_id}/posts", params=params)
if status != 200:
raise RuntimeError(f"投稿一覧取得失敗: HTTP {status} / {body}")
posts.extend(body.get("posts", []))
cursor = _next_cursor(body, cursor)
if not cursor:
break
return posts
def fetch_all_readers(session, board_id, post_id):
"""指定投稿の既読者・未読者一覧を取得(カーソルページング)
戻り値: (status_code, readers_list, error_body_or_None)
"""
readers, cursor = [], ""
while True:
params = {"count": 100}
if cursor:
params["cursor"] = cursor
status, body = api_get(session, f"{API_BASE}/boards/{board_id}/posts/{post_id}/readers", params=params)
if status != 200:
return status, readers, body
readers.extend(body.get("readers", []))
cursor = _next_cursor(body, cursor)
if not cursor:
break
return 200, readers, None
def fetch_readers_for_post(sa_session, oauth_sessions, board_id, post_id, is_must_read):
"""必読フラグなしの投稿はService Accountで取得。必読フラグありは、oauth/ から
読み込んだ認証情報を順に試し、200が返ったものだけ採用する投稿者本人でないと
既読状況を閲覧できない仕様のため)。どれも一致しなければ最後の失敗結果を返す。
"""
if not is_must_read:
return fetch_all_readers(sa_session, board_id, post_id)
status, readers, error_body = 403, [], {"_error": "一致するOAuth認証情報がありません"}
for cred in oauth_sessions:
status, readers, error_body = fetch_all_readers(cred["session"], board_id, post_id)
if status == 200:
return status, readers, error_body
return status, readers, error_body
# ------------------------------------------------------------
# 掲示板選択
# ------------------------------------------------------------
def choose_board(boards: list) -> dict:
print(f"\n掲示板一覧({len(boards)}件):")
for i, b in enumerate(boards, start=1):
print(f" {i:>3}. {b['boardName']} ({b['boardId']})")
while True:
raw = input("\n対象の番号を入力してEnter: ").strip()
if raw.isdigit() and 1 <= int(raw) <= len(boards):
return boards[int(raw) - 1]
print(f"1〜{len(boards)}の番号を入力してください。")
def find_board(boards: list, spec: str) -> dict:
"""掲示板の指定は、一覧の表示番号1〜件数、実際の掲示板ID、または掲示板名完全一致
いずれでも検索できる。表示番号として解釈できる値を最優先し、次に掲示板ID、最後に掲示板名の順で照合する。
"""
spec = str(spec).strip()
if spec.isdigit() and 1 <= int(spec) <= len(boards):
return boards[int(spec) - 1]
for b in boards:
if str(b["boardId"]) == spec:
return b
for b in boards:
if b["boardName"] == spec:
return b
raise RuntimeError(f"指定された名前/番号/掲示板ID '{spec}' が見つかりません。")
def sanitize_filename(name: str) -> str:
return re.sub(r'[\\/:*?"<>|]', "_", name or "board").strip() or "board"
TARGET_LABELS = {"both": "両方", "must": "必読のみ", "normal": "通常のみ"}
def choose_target() -> str:
print("\n対象を選択:")
print(" 1. 両方(デフォルト)")
print(" 2. 必読のみ")
print(" 3. 通常のみ")
raw = input("番号を入力してEnter未入力でEnterなら両方: ").strip()
return {"1": "both", "2": "must", "3": "normal"}.get(raw, "both")
def parse_args():
parser = argparse.ArgumentParser(description="LINE WORKS 掲示板 既読/未読チェック")
parser.add_argument(
"--board", dest="board_id", default=None,
help="掲示板名完全一致、一覧の表示番号、または実際の掲示板ID。指定すると掲示板選択メニューを省略して自動実行する",
)
parser.add_argument(
"--target", choices=["both", "must", "normal"], default=None,
help="対象の絞り込みboth: 両方 / must: 必読のみ / normal: 通常のみ)。省略時は両方(対話形式の場合は選択メニューが出る)",
)
return parser.parse_args()
# ------------------------------------------------------------
# メイン
# ------------------------------------------------------------
OUT_DIR = BASE_DIR / "board_readers_check"
def main():
args = parse_args()
auto_run = bool(args.board_id)
log_lines = []
def log(msg=""):
print(msg)
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
log_lines.append(f"[{timestamp}] {msg}")
log("アクセストークンを取得中...")
try:
token = get_token()
except Exception as e:
print(f"エラー: {e}", file=sys.stderr)
sys.exit(1)
session = requests.Session()
session.headers.update({"Authorization": f"Bearer {token}", "Accept": "application/json"})
oauth_sessions = load_oauth_sessions()
if oauth_sessions:
names = "".join(c["login_id"] for c in oauth_sessions)
log(f"OAuth認証情報を{len(oauth_sessions)}件読み込みました(必読投稿の照合に使用): {names}")
else:
log("OAuth認証情報が見つかりません必読投稿はすべて対象外になります")
log("掲示板一覧を取得中...")
try:
boards = fetch_all_boards(session)
except Exception as e:
print(f"エラー: {e}", file=sys.stderr)
sys.exit(1)
if not boards:
print("アクセス可能な掲示板がありません。")
sys.exit(1)
if auto_run:
try:
board = find_board(boards, args.board_id)
except Exception as e:
print(f"エラー: {e}", file=sys.stderr)
sys.exit(1)
else:
board = choose_board(boards)
board_id, board_name = board["boardId"], board["boardName"]
log(f"対象掲示板: {board_name} ({board_id})")
target = args.target
if target is None:
target = "both" if auto_run else choose_target()
log(f"対象フィルタ: {TARGET_LABELS[target]}")
log(f"\n[{board_name}] の投稿一覧を取得中...")
try:
posts = fetch_all_posts(session, board_id)
except Exception as e:
print(f"エラー: {e}", file=sys.stderr)
sys.exit(1)
log(f"{len(posts)}\n")
csv_rows = []
json_posts = []
skipped_count = 0
processed_count = 0
filtered_count = 0
skipped_must_read = []
for i, post in enumerate(posts, start=1):
post_id = post["postId"]
title = post.get("title", "")
is_must = bool(post.get("isMustRead"))
author = post.get("userName") or post.get("userId") or ""
if (target == "must" and not is_must) or (target == "normal" and is_must):
filtered_count += 1
continue
log(f"[{i}/{len(posts)}] {title}")
time.sleep(1)
r_status, readers, r_error_body = fetch_readers_for_post(session, oauth_sessions, board_id, post_id, is_must)
if r_status != 200:
log(f" データを取得できないため対象外HTTP {r_status}")
skipped_count += 1
if is_must:
skipped_must_read.append({"title": title, "author": author, "http_status": r_status})
log(f" → 必読投稿・投稿者: {author}")
continue
processed_count += 1
base_row = {
"投稿タイトル": title,
"投稿日時": post.get("createdTime", ""),
"必読フラグ": post.get("isMustRead", ""),
}
read_count_n = sum(1 for r in readers if r.get("isRead"))
unread_count_n = len(readers) - read_count_n
log(f" 既読 {read_count_n}件 / 未読 {unread_count_n}")
# ユーザーごとに1行既読者はisRead=1/未読フラグ=0、未読者はその逆
for r in readers:
is_read = bool(r.get("isRead"))
csv_rows.append({
**base_row,
"既読者数": read_count_n, "未読者数": unread_count_n,
"メンバー名": r.get("userName") or r.get("userId"),
"既読": 1 if is_read else 0,
"未読": 0 if is_read else 1,
})
json_posts.append({"post": post, "readers": readers})
# メンバーごとの集計未読者集計CSV用。readers取得失敗行メンバー名が空は集計対象外。
member_stats = {}
for row in csv_rows:
name = row.get("メンバー名")
if not name:
continue
stat = member_stats.setdefault(name, {"total": 0, "read": 0, "unread": 0, "unread_titles": []})
stat["total"] += 1
if row.get("既読") == 1:
stat["read"] += 1
else:
stat["unread"] += 1
stat["unread_titles"].append(row.get("投稿タイトル", ""))
out_dir = Path(OUT_DIR) / sanitize_filename(board_name)
out_dir.mkdir(parents=True, exist_ok=True)
ts = datetime.now().strftime("%Y%m%d_%H%M")
base_name = sanitize_filename(board_name)
out_csv = out_dir / f"{base_name}_掲示板読了チェック_{ts}.csv"
out_json = out_dir / f"{base_name}_掲示板読了チェック_{ts}.json"
out_summary_csv = out_dir / f"{base_name}_未読者集計_{ts}.csv"
out_log = out_dir / f"{base_name}_取得ログ_{ts}.txt"
with open(out_csv, "w", newline="", encoding="utf-8-sig") as f:
w = csv.DictWriter(f, fieldnames=[
"投稿タイトル", "投稿日時", "必読フラグ", "既読者数", "未読者数", "メンバー名", "既読", "未読",
])
w.writeheader()
w.writerows(csv_rows)
with open(out_json, "w", encoding="utf-8") as f:
json.dump(
{
"board_id": board_id, "board_name": board_name, "target": target,
"posts": json_posts, "skipped_must_read": skipped_must_read,
},
f, ensure_ascii=False, indent=2,
)
with open(out_summary_csv, "w", newline="", encoding="utf-8-sig") as f:
w = csv.DictWriter(f, fieldnames=["メンバー名", "全記事数", "既読記事数", "未読記事数", "未読記事リスト"])
w.writeheader()
for name, stat in member_stats.items():
w.writerow({
"メンバー名": name,
"全記事数": stat["total"],
"既読記事数": stat["read"],
"未読記事数": stat["unread"],
"未読記事リスト": ";".join(stat["unread_titles"]),
})
if skipped_must_read:
log(f"\n必読投稿で対象外になったもの({len(skipped_must_read)}件・投稿者の認証情報が無いなど):")
for item in skipped_must_read:
log(f" - {item['title']}(投稿者: {item['author']}")
log(f"\n完了(対象 {processed_count}件 / 対象外 {skipped_count}件 / フィルタ対象外 {filtered_count}件)")
log(f" サマリー → {out_csv}")
log(f" 生データ → {out_json}")
log(f" 未読者集計 → {out_summary_csv}")
log(f" 取得ログ → {out_log}")
with open(out_log, "w", encoding="utf-8") as f:
f.write("\n".join(log_lines) + "\n")
if __name__ == "__main__":
main()