574 lines
23 KiB
Python
574 lines
23 KiB
Python
#!/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 Account(JWT Bearer)で取得する。
|
||
- 必読投稿の既読/未読は投稿者本人でないと閲覧できない仕様のため、oauth/ フォルダに
|
||
保存したパスワード付きzip(oauth_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/ で各投稿者に発行してもらった認証情報zip(oauth/ フォルダに
|
||
# 保存済み)を読み込み、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()
|