1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Python】PlaywrightでManabaの課題を自動取得するアプローチ

1
Last updated at Posted at 2025-07-21

【Python】PlaywrightでManabaの課題を自動取得するアプローチ

導入

私の通っている大学ではManabaというサイトで課題の提出や授業情報の確認ができます。
しかし、このManabaがまあ使いにくい。特に外部連携が充実していないので、課題のリマインドができない。
そこで、このManabaの課題を自動で取得する方法を模索しました。

結論

PlaywrightとWebスクレイピング技術を活用し、課題情報をjsonファイルとして取得することができました。

経緯

HTTP Requestで出来ないか
→ 大学のようなサイトはSAML認証という一度限りの認証鍵でログインする方式により不可
→ Cookieを使う方法もあるが、できれば持続してログインできるような方式にしたい
別の方法を模索
→ Playwriteを使ってさも人間がログインしているのと変わらない環境で自動化しよう!

今後の展開

課題を取得できたので、これを元にTODOリストに追加する。その部分の自動化にはn8nを利用予定。
まずはMacbook上のローカル環境で動かすが、パソコンが余っていたらそれでサーバーを建て実装したいですね。

御礼

下し書きにはなってしまいましたが、自動化をするにあたって有用なツールだと思います。今後は金融系のツールもこれで作れればなと思っていますが、果たして…
以下作り方になりますが、参考までに!


作り方

🛠️ 開発環境・前提条件

# Python環境
Python 3.8以上

# 必要なライブラリ
pip install playwright
playwright install chromium

# その他標準ライブラリ
json, os, time, urllib.parse, datetime, csv

📋 システム構成

取得可能データ

  • 課題基本情報: タイトル、コース、締切日時
  • 課題詳細: 説明文、提出要件、評価基準
  • 提出方法: ファイルアップロード/テキスト入力/選択式
  • 添付ファイル: PDF、Word、Excel等のリンク
  • 統計情報: 課題タイプ別・コース別集計

出力形式

  • 詳細JSON: 全データの構造化保存
  • 統計JSON: 分析用サマリー情報
  • CSV: Excel等での分析用テーブル

🚀 実装コード

メインクラス定義

import json
import os
import time
from playwright.sync_api import sync_playwright
from urllib.parse import urljoin
from datetime import datetime

class TaskScraperFull:
    def __init__(self):
        self.tasks_data = {}
        self.output_dir = "/path/to/output/tasks_data"  # 出力ディレクトリ
        os.makedirs(self.output_dir, exist_ok=True)
        self.success_count = 0
        self.error_count = 0

1. manabaへの自動ログイン

def login_to_manaba(self, page):
    """manabaにログイン"""
    print("🔐 manabaにログインしています...")
    page.goto("https://room.chuo-u.ac.jp/")
    page.wait_for_timeout(3000)
    
    # ユーザーID・パスワード入力
    page.fill("input[name='username']", "YOUR_USER_ID")
    page.fill("input[name='password']", "YOUR_PASSWORD")
    page.click("#login_button")
    page.wait_for_timeout(5000)
    print("✅ ログイン完了")

ポイント:

  • page.wait_for_timeout()でページ読み込み完了を待機
  • セレクタは開発者ツールで事前に調査
  • エラーハンドリングでログイン失敗を検出可能

2. 課題一覧の取得

def extract_task_list(self, page):
    """課題一覧を取得"""
    print("📋 課題一覧ページにアクセスしています...")
    page.goto("https://room.chuo-u.ac.jp/ct/home_library_query")
    page.wait_for_timeout(5000)

    tasks = []
    table_rows = page.query_selector_all("table tr")
    headers = []
    
    for row_index, row in enumerate(table_rows):
        cells = row.query_selector_all("td, th")
        cell_texts = [cell.inner_text().strip() for cell in cells]
        
        # ヘッダー行検出
        header_keywords = ["Type", "タイプ", "Title", "タイトル"]
        if any(keyword in text for text in cell_texts for keyword in header_keywords):
            headers = cell_texts
            continue
            
        # データ行処理(6列の場合)
        task_types = ["レポート", "アンケート", "小テスト", "プロジェクト"]
        if len(cell_texts) == 6 and headers and cell_texts[0] in task_types:
            # 重要: タイトル部分のリンクを取得
            title_links = row.query_selector_all("a")
            task_link = None
            
            for link in title_links:
                href = link.get_attribute("href")
                link_text = link.inner_text().strip()
                
                # タイトルと完全一致するリンクを探す
                if href and link_text == cell_texts[1]:
                    task_link = href
                    break
            
            task_info = {
                "type": cell_texts[0],
                "title": cell_texts[1],
                "course": cell_texts[2],
                "start_time": cell_texts[3],
                "end_time": cell_texts[4],
                "period": cell_texts[5],
                "absolute_url": urljoin(page.url, task_link) if task_link else None
            }
            
            tasks.append(task_info)
    
    return tasks

重要な発見:
manabaでは各行に複数のリンクが存在します:

  • リンク0: タイプ部分 → 課題のリストページ
  • リンク1: タイトル部分 → 個別課題詳細ページ ✅

タイトル部分のリンクから遷移することで、正確な課題詳細データを取得できます。

3. 個別課題の詳細取得

def extract_task_details(self, page, task_info):
    """個別の課題詳細を取得"""
    try:
        print(f"📄 課題詳細取得中: {task_info['title']}")
        page.goto(task_info["absolute_url"], timeout=60000)
        page.wait_for_timeout(3000)

        # データ構造を準備
        task_details = {
            "basic_info": task_info,
            "page_title": page.title(),
            "description": "",
            "submission_info": [],
            "files": [],
            "deadline_info": {},
            "full_content": page.inner_text("body")
        }

        # 多段階フォールバック方式で説明文を取得
        description_selectors = [
            ".assignment-description", ".report-description", 
            ".content-body", ".task-content", ".pagebody",
            "main", ".main-content"
        ]
        
        for selector in description_selectors:
            element = page.query_selector(selector)
            if element:
                desc_text = element.inner_text().strip()
                if len(desc_text) > 50:  # 意味のある内容かチェック
                    task_details["description"] = desc_text
                    break

        # 提出方法の詳細分析
        submission_elements = page.query_selector_all(
            "input[type='file'], textarea, select, input[type='text']"
        )
        
        for element in submission_elements:
            tag_name = element.evaluate("el => el.tagName.toLowerCase()")
            element_info = {
                "tag": tag_name,
                "type": element.get_attribute("type") or "",
                "name": element.get_attribute("name") or "",
                "required": element.get_attribute("required") or False
            }
            
            # 提出形式を分類
            if tag_name == "input" and element.get_attribute("type") == "file":
                element_info["submission_type"] = "file_upload"
            elif tag_name == "textarea":
                element_info["submission_type"] = "text_area"
            elif tag_name == "select":
                element_info["submission_type"] = "selection"
            
            task_details["submission_info"].append(element_info)

        # 添付ファイルの取得
        file_selectors = [
            "a[href*='download']", "a[href*='.pdf']", 
            "a[href*='.doc']", "a[href*='.xlsx']"
        ]
        
        files = []
        for selector in file_selectors:
            file_links = page.query_selector_all(selector)
            for link in file_links:
                href = link.get_attribute("href")
                if href:
                    files.append({
                        "name": link.inner_text().strip(),
                        "url": urljoin(page.url, href),
                        "type": self.get_file_type(href)
                    })
        
        task_details["files"] = files
        self.success_count += 1
        return task_details

    except Exception as e:
        print(f"❌ エラー: {str(e)}")
        self.error_count += 1
        return {"error": str(e), "basic_info": task_info}

4. データ保存と統計生成

def save_data(self):
    """多形式でデータを保存"""
    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    
    # 1. 詳細JSONデータ
    main_file = os.path.join(self.output_dir, f"all_tasks_{timestamp}.json")
    with open(main_file, "w", encoding="utf-8") as f:
        json.dump(self.tasks_data, f, indent=2, ensure_ascii=False)
    
    # 2. 統計情報JSON
    stats = {
        "total_tasks": len(self.tasks_data),
        "success_count": self.success_count,
        "error_count": self.error_count,
        "success_rate": f"{(self.success_count / len(self.tasks_data) * 100):.1f}%",
        "task_types": {},
        "courses": {},
        "submission_methods": {}
    }
    
    # 統計データの生成
    for task_data in self.tasks_data.values():
        if "basic_info" in task_data:
            task_type = task_data["basic_info"].get("type", "Unknown")
            course = task_data["basic_info"].get("course", "Unknown")
            stats["task_types"][task_type] = stats["task_types"].get(task_type, 0) + 1
            stats["courses"][course] = stats["courses"].get(course, 0) + 1
    
    stats_file = os.path.join(self.output_dir, f"stats_{timestamp}.json")
    with open(stats_file, "w", encoding="utf-8") as f:
        json.dump(stats, f, indent=2, ensure_ascii=False)
    
    # 3. CSVサマリー
    import csv
    csv_file = os.path.join(self.output_dir, f"summary_{timestamp}.csv")
    with open(csv_file, "w", encoding="utf-8", newline="") as f:
        writer = csv.writer(f)
        writer.writerow([
            "タイプ", "タイトル", "コース", "締切日時", 
            "提出形式", "添付ファイル数", "説明文字数"
        ])
        
        for task_data in self.tasks_data.values():
            if "basic_info" in task_data:
                info = task_data["basic_info"]
                files_count = len(task_data.get("files", []))
                desc_length = len(task_data.get("description", ""))
                submission_types = [s.get("submission_type", "") 
                                  for s in task_data.get("submission_info", [])]
                
                writer.writerow([
                    info.get("type", ""),
                    info.get("title", ""),
                    info.get("course", ""),
                    info.get("end_time", ""),
                    ", ".join(submission_types),
                    files_count,
                    desc_length
                ])

5. メイン実行部分

def run(self):
    """メイン実行メソッド"""
    print("🚀 課題データ取得を開始します")
    
    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)  # 高速化のため非表示
        page = browser.new_page()

        try:
            # ログイン
            self.login_to_manaba(page)

            # 課題一覧取得
            tasks_list = self.extract_task_list(page)
            total_tasks = len(tasks_list)
            print(f"📋 発見された課題数: {total_tasks}")

            # 各課題の詳細取得
            for i, task_info in enumerate(tasks_list, 1):
                print(f"[{i}/{total_tasks}] {task_info['title']}")
                
                task_details = self.extract_task_details(page, task_info)
                task_id = f"{task_info['course']}_{task_info['title']}_{i}"
                self.tasks_data[task_id] = task_details
                
                # 進捗表示
                progress = (i / total_tasks) * 100
                print(f"進捗: {progress:.1f}% (成功:{self.success_count}, エラー:{self.error_count})")
                
                time.sleep(2)  # サーバー負荷軽減

        except Exception as e:
            print(f"エラーが発生しました: {str(e)}")
        finally:
            browser.close()
            if self.tasks_data:
                self.save_data()

def main():
    scraper = TaskScraperFull()
    scraper.run()

if __name__ == "__main__":
    main()

💡 技術的ポイント

1. 適切なセレクタ選択

# ❌ 間違った方法
link = row.query_selector("a")  # 最初のリンクを取得

# ✅ 正しい方法  
for link in title_links:
    if link.inner_text().strip() == cell_texts[1]:  # タイトル一致確認
        task_link = href

2. 多段階フォールバック

# 複数のセレクタで説明文を試行
description_selectors = [
    ".assignment-description",  # 最優先
    ".content-body",           # 次善
    ".pagebody"               # フォールバック
]

3. エラーハンドリング

try:
    page.goto(url, timeout=60000)  # 60秒タイムアウト
except Exception as e:
    self.error_count += 1
    return {"error": str(e)}

🔒 セキュリティ・注意事項

  1. 認証情報の管理

    # 環境変数での管理推奨
    import os
    username = os.getenv('MANABA_USERNAME')
    password = os.getenv('MANABA_PASSWORD')
    
  2. サーバー負荷への配慮

    time.sleep(2)  # 適切な間隔でアクセス
    
  3. 利用規約の遵守

    • 大学の情報システム利用規約に従う
    • 個人利用目的での使用に留める
    • 過度な負荷をかけない

📈 応用・拡張可能性

データ分析への活用

  • 締切管理: 優先度順の課題リスト生成
  • 学習計画: コース別・期限別の学習スケジュール
  • 統計分析: 課題提出状況・成績分析

この記事が同じような課題を抱える学生や開発者の方の参考になれば幸いです。質問やご意見がございましたら、コメントでお聞かせください!

🏷️ タグ

Python Playwright WebScraping 自動化 データ分析 manaba 中央大学

1
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?