面倒くさいことでも、
AIよりPythonにやらせたほうがいいこともある
目次
- 1. はじめに
- 2. 自動化フローの設計
- 3. Power Automate
- 4. fetch calendar
- 5. sort tasks
- 6. task converter
- 7. playwrightで転記する
- 8. 今回学んだこと
- 9. おわりに
1. はじめに
AIがブラウザーを操作してくれるようになってきたようです。
それ自体はとても素晴らしいものの、会社がそういう高性能AIを契約していないだとか、トークンの消費がバカにならないとか、もしそういうAIを使えるとしても、細々としたルールを守りながら正確にミスなく転記できるかとか、実運用上の課題はあるわけです。
AIのほうが柔軟性は高いし自然言語で頼めるから楽だとは認めつつ、反復的な処理で正確性が求められる割に、やたら細かくルールが多いような作業にはAIより(今のところはまだ?)プログラムによる実装のほうが向いているのではないかなと思う今日この頃であります。
AIは加点式なら評価が高いが、減点式なら低くなるというやつです。
ということで、最近 Playwrightで転記作業を自動化することがあったので、忘備録がてらまとめておこうと思います。
背景
勤務がおわったときにタイムレポートを一々一々、会社の内部サイトに登録する必要があり、とても面倒なことこの上なかったです。
ただ、日々の作業内容はすでにTeams/Outlookのカレンダー(以降 MSカレンダー)へ記録していました。
そのため、毎回行っていた作業は実質的に次のような転記でした。
- 前日のカレンダーを開く
- 何をしていたか確認する
- 各予定をタイムレポート上の項目へ分類する
- 作業時間を入力する
- 勤務時間との差分を雑務として補完する
これを毎日、あるいは数日分まとめて作業するというのは地味に時間がかかる割に、なんら生産に寄与しない記録作業でした。
こういうのに時間を使うのが一番嘆かわしい。
と、いうわけで、自動化するフローを組んだわけです。
Power Automate
↓
前日のカレンダー予定をJSONで返す
↓
Python
├─ JSON取得
├─ 重複区間の整理
├─ 業務分類・時間計算
├─ CalendarTaskへ変換
└─ TimeReportTaskへ変換
↓
Playwright
↓
社内タイムレポート画面へ登録
この記事では、完成コードの丸ごと公開ではなく、設計、詰まった点、実装上の判断を中心にまとめます。
注意
ブラウザー操作や勤怠・工数登録の自動化を行う場合は、所属組織の規程、対象サービスの利用条件、情報セキュリティルールを確認してください。
また、誤登録を避けるため、確認処理・一時保存・ログ出力などの安全策を設けることをおすすめします。
2. 自動化フローの設計
今回、Microsoft Graph APIをPythonから直接呼ぶ方法は採用しませんでした。
会社管理のMicrosoftアカウントでは、アプリ登録、アクセス許可、管理者承認、トークン取得などが必要になる可能性があります。
実際、私の環境ではAPIの利用ができませんでした。
そこで、Microsoft 365側の認証はPower AutomateのOutlookコネクタに任せ、Pythonは業務ロジックに集中させました。
役割分担は次のとおりです。
Power Automate
- HTTPリクエストを受け取る
- 指定日のカレンダーを取得する
- JSON配列をレスポンスとして返す
Python
- Power Automateを呼び出す
- 必要な項目だけをモデルへ変換する
- 予定をタイムレポート用の分類へ変換する
- 時間の重複を整理する
- Playwrightへ登録内容を渡す
Playwright
- 既存のEdgeプロファイルを利用する
- 社内ポータルを開く
- タイムレポート画面へ遷移する
- 各項目を入力する
- 残時間を確認する
- 一時保存する
パッケージ構成
最終的には、処理の責務ごとにファイルを分けました。
time_report/
├── __init__.py
├── __main__.py
├── fetch_calendar.py
├── tasks_converter.py
├── sort_tasks.py
├── register_tasks.py
└── models/
├── __init__.py
├── calendar_task.py
└── time_report_task.py
実行はパッケージの親ディレクトリから行います。
python -m time_report 1
1は「今日から何日前を対象にするか」です。
python -m time_reportを実行すると、time_report/__main__.pyがエントリーポイントになります。
from .fetch_calendar import fetch_calendar
from .register_tasks import register_tasks
from .sort_tasks import sort_tasks
from .tasks_converter import task_converter
import sys
def main() -> None:
input_day = int(sys.argv[1])
calendar_tasks = fetch_calendar(input_day)
sorted_tasks = sort_tasks(calendar_tasks)
report_tasks = task_converter(sorted_tasks)
register_tasks(
days=input_day,
tasks=report_tasks
)
if __name__ == "__main__":
main()
処理の流れが次の4行で読めるようにした点は、今回うまくいった設計の一つです。
取得 → 重複整理 → 分類変換 → 登録
3. Power Automate
Power AutomateにはHTTPリクエストのトリガーを用意し、対象日をJSONで渡します。
{
"days_before_today": 1
}
curlで試す場合は、Content-Type: application/jsonが必要です。
curl \
-X POST \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{"days_before_today": 1}' \
"POWER_AUTOMATE_ENDPOINT"
例えURIにアクセスしてPower Automateのフローを実行させるだけ(エンドポイントへのアクセスをトリガーとして関数を実行するようなもの)にしても、HTTPメソッドは必ずPOSTでないといけないことは忘れやすいので注意したほうがいいと思います。
この日時指定をjsonのparserを使って数値として取り出し、後続のカレンダーの日時指定に使っています。
Power Automateでは、Outlookコネクタの、 Get calendar view of events (V3)というものを使っています。
意外に、日本時間に変換して一日分の予定を取得する、というのが厄介でした。
開始時間と終了時間には、関数を組み上げてn日前の始めと終わりの時間を指定しています。
ConvertTimeZone(
concat(
formatDateTime(
addDays(
convertTimeZone(
utcNow(),
'UTC',
'Tokyo Standard Time'
),
mul(
triggerBody()?['days_before_today'],
-1
)
),
'yyyy-MM-dd'
),
'T00:00:00'
),
'Tokyo Standard Time',
'UTC',
'yyyy-MM-ddTHH:mm:ssZ'
)
イベントが取得できれば、あとはループで値を取得して配列に追加し、レスポンスとしてPythonに返すだけで完了です。
4. fetch calendar
ここからはpython側での処理に入っていきます。
Pythonからは、requests.post()のjson引数を使用しました。
import requests
def fetch_calendar(days: int) -> list[dict]:
response = requests.post(
"POWER_AUTOMATE_ENDPOINT",
json={
"days_before_today": days
},
timeout=30
)
response.raise_for_status()
return response.json()
実際には、レスポンスをそのまま後段へ流さず、必要な項目だけをCalendarTaskへ変換しています。
dictのまま流さず、dataclassへ変換する
Power AutomateのレスポンスはJSONなので、Pythonではdictとして受け取れます。
しかし、分類ロジックまでdictのまま扱うと、次のような問題が起こりやすくなります。
- キー名のタイプミスに気づきにくい
- エディターのプロパティ補完が効きにくい
- どの段階のデータなのか分かりにくい
- 日時を文字列のまま扱ってしまう
そこで、外部データとの境界でdataclassへ変換しました。
from dataclasses import dataclass
from datetime import datetime
@dataclass
class CalendarTask:
subject: str
start: datetime
end: datetime
is_all_day: bool
location: str
organizer: str
show_as: str
def get_duration(self) -> tuple[str, str]:
total_seconds = int(
(self.end - self.start).total_seconds()
)
hours = total_seconds // 3600
minutes = (total_seconds % 3600) // 60
return str(hours), str(minutes)
タイムレポート登録用のモデルは別にしました。
from dataclasses import dataclass
@dataclass
class TimeReportTask:
subject: str
activity_name: str
task_type_name: str
task_class_name: str
duration_hours: str
duration_minutes: str
これにより、
CalendarTask
↓ 分類・変換
TimeReportTask
というデータの境界が明確になりました。
5. sort tasks
CalendarTaskの配列をソートして重複を消して奇麗に整えた後に、後続のTimeReportTaskに変換するtask_converterに値を渡します。
最初から除外する予定
すべての予定が工数になるわけではないため、早い段階で除外します。
例:
- 終日予定
※具体的な作業時間と一致しないため。代わりに、自分で稼働時間をTeamsに記録するような運用になります - 昼食・休憩
- 仮の予定(
showAs == "tentative") - 重複を許容する私用メモ
if task.is_all_day:
continue
if task.show_as == "tentative":
continue
if "休憩" in task.subject or "昼食" in task.subject:
continue
分類前に除外することで、後段の重複整理や時間計算を単純にできます。
重複区間を整理する
予定を開始時刻で昇順に並べておくと、隣接する2区間について考えればよくなります。
重複していない条件は、次の1つです。
prev.end <= current.start
それ以外は重複です。
先に始まった予定を優先する場合、基本パターンは3つに整理できます。
1. 重複なし
A: 10:00 - 11:00
B: 11:00 - 12:00
Aを確定し、Bを次の比較対象にします。
2. 完全包含
A: 10:00 - 12:00
B: 10:30 - 11:30
BはAに含まれるため、Bを除外し、Aを次の比較にも残します。
3. 部分重複
A: 10:00 - 11:00
B: 10:30 - 12:00
Aを優先し、Bの開始を11:00へ切り詰めます。
実装例は次のとおりです。
from dataclasses import replace
from .models.calendar_task import CalendarTask
def sort_tasks(
tasks: list[CalendarTask]
) -> list[CalendarTask]:
if not tasks:
return []
copied_tasks = [
replace(task)
for task in tasks
]
sorted_tasks = sorted(
copied_tasks,
key=lambda task: task.start
)
non_overlay_tasks: list[CalendarTask] = []
long_task: CalendarTask | None = None
for i in range(1, len(sorted_tasks)):
if long_task is not None:
prev_task = long_task
else:
prev_task = sorted_tasks[i - 1]
current_task = sorted_tasks[i]
if prev_task.end <= current_task.start:
non_overlay_tasks.append(prev_task)
long_task = None
continue
if prev_task.end >= current_task.end:
long_task = prev_task
continue
non_overlay_tasks.append(prev_task)
current_task.start = prev_task.end
long_task = None
if long_task is not None:
non_overlay_tasks.append(long_task)
else:
non_overlay_tasks.append(sorted_tasks[-1])
return non_overlay_tasks
将来的に「会議を作業より優先する」などの優先順位を導入すると、先行タスクを前後に分割する必要があります。
今回はまず、直列に近い予定と「先に始まった予定を優先する」というルールで完成させました。
6. task converter
ここで、タスクの分類と変換を行っていきます。
この分類は、JavaScriptのswitchに相当する、match構文を使って、activity_nameやtask_type_name、そしてtask_class_nameの値を決定し、CalendarTaskからTimeReportTaskに変換していきます
ここが「ルールベース」の条件分岐処理であり、専らその条件はハードコードしています。
予定タイトルと開催者で業務を分類する
自分で登録する作業予定には、次のようなプレフィックスを付ける運用にしました。
【開発】調査・実装
【運用】問い合わせ対応
【研修】勉強会
【雑務】週次報告
プレフィックスは正規表現で取得できます。
import re
matched = re.search(
r"【([^【】]+?)】",
task.subject
)
task_type = matched.group(1) if matched else None
一方、他の人が登録するミーティングには、自分でプレフィックスを付けられません。
即ち、それはイベントの所有者(organizer)は必ず自分以外の誰かと言うことでもあります。
そして、イベントを登録した人がどういう人かで、イベントの種別もある程度絞り込むことができるわけです。
開発メンバー主催 → 開発ミーティング
人事担当者主催 → 研修
全社会議主催者 → 全社・雑務
該当なし → 雑務として扱う・警告を出す
この判定はtasks_converter.pyへ閉じ込め、Playwright側は分類方法を一切知らない構成にしています。
7. playwrightで転記する
ここではplaywrightの基本は省略して、詰まったポイントとその解消方法などを整理しておこうと思います。
まぁ、page.goto(URI)して、page.locator("a#time-report").click()したら新しいタブが開くので、report_page = page.context.pages[-1]して遷移して、頑張ってHTML要素を特定してもらうようにガチャガチャやっただけなので、コード本体はそんなに見せるほどのことのもないかなと思います。
サイトによっても要素を取得する具体的なコードも変わってきますし、メソッドの一覧は他のところでもたくさん解説されていると思うので。
Playwrightで既存のEdgeプロファイルを使う
対象システムは、会社管理のMicrosoft認証や社内ポータルと連携しており、新規ブラウザーコンテキストでは再認証が困難でした。
そこで、普段使っているEdgeのユーザーデータをlaunch_persistent_context()へ渡しました。
from playwright.sync_api import sync_playwright
USER_DATA_PATH = (
"/Users/USER_NAME/Library/Application Support/"
"Microsoft Edge/Default"
)
with sync_playwright() as p:
context = p.chromium.launch_persistent_context(
user_data_dir=USER_DATA_PATH,
headless=False,
channel="msedge"
)
同じプロファイルを複数プロセスで同時利用できないため、このスクリプトの実行前には通常のEdgeを終了させています。
普通にEdgeのウィンドウを立ち上げたままこのスクリプトを実行できました。ただ、副作用の有無や環境による違いは判らないので、念のため閉じておいてもいいと思います。
実際のプロファイルを自動操作で利用する場合は、破損や認証状態への影響に備え、バックアップや別プロファイルの利用も検討してください。
遅延ロードとCookie生成
社内ポータルでは、タイムレポートのアイコンが画面内に表示されたタイミングで、遷移先ドメイン向けのCookieが生成されるような挙動がありました。
単にリンクがDOMへ追加されるまで待つだけでは、クリック後にCookieエラーが発生してしまいます。
そこで、リンクを取得して画面内までスクロールし、描画後に遷移しています。
time_report_link = page.locator(
'a[href="TIME_REPORT_URL"]'
)
time_report_link.wait_for(state="attached")
time_report_link.scroll_into_view_if_needed() # <-これが本当に賢い!
page.wait_for_timeout(3000)
time_report_link.wait_for(state="visible")
time_report_link.click()
wait_for_selector()はDOM上の存在を保証しますが、対象サービス側のセッション確立まで保証するわけではありません。
今回は、実際の画面とCookieの生成タイミングを観察することで原因を特定できました。
ExtJSの動的IDを信用しない
タイムレポート画面では、次のようなIDが動的に生成されていました。
button-1109
button-1110
boundlist-1039-listEl
画面を開く順番によって値が変わるため、ID指定では安定しませんでした。
表示中の選択肢だけをクリックする
閉じたドロップダウンもDOM上に残っていたため、:visibleで表示中の候補だけに限定しました。
def select_visible_option(
page,
input_selector: str,
option_name: str
) -> None:
page.locator(input_selector).click()
page.locator(
'li[role="option"]:visible'
).filter(
has_text=option_name
).click()
「設定」ボタンの全角スペース
画面上は「設定」に見えましたが、実際のテキストは次の形式でした。
"設\u3000定"
さらに、画面全体には「システム設定」も存在します。
そのため、表示中の最後のダイアログへ探索範囲を限定し、空白を許容した完全一致を使用しました。
import re
visible_dialog = report_page.locator(
"div.x-window:visible"
).last
setting_button = visible_dialog.get_by_role(
"button",
name=re.compile(r"^設\s*定$")
)
setting_button.click()
この方法なら、動的IDに依存せず、「システム設定」への誤マッチも避けられます。
すでに登録済みの日は処理しない
誤登録の修正が大変なため、再実行への対策を入れました。
対象日を選択した直後に、勤務時間と入力済み時間との差分を読み取ります。
remaining_hours, remaining_minutes = read_remaining_time(
report_page
)
if remaining_hours == 0 and remaining_minutes == 0:
print("This day has already been completed.")
return
これは厳密には「このプログラムが登録したか」ではなく、次の状態を判定しています。
勤務時間 = タイムレポート入力済み時間
手入力で完了した日も安全にスキップできるため、実用上はこちらの方が都合がよいと判断しました。
残時間を雑務として補完する
カレンダーへ記録されていない細かな作業時間は、勤務時間との差分として残ります。
そこで、すべての予定を登録した後に残時間を確認し、一定以下なら雑務として追加します。
残時間 0分 → 何もしない
残時間が負数 → 超過として手動確認
残時間が3時間超 → 入力漏れの可能性があるため手動確認
それ以外 → 雑務として自動追加
重要なのは、大きな差分まで無条件で埋めないことです。
入力漏れや分類ミスがあっても、すべて雑務に吸収されると異常に気づけなくなります。
最後は「登録」ではなく「一時保存」に留め、自分で確認できるようにしました。
エラー発生時にスクリーンショットを残す
ブラウザー自動操作では、次のような原因で失敗します。
- 選択肢が表示されなかった
- 対象日が見つからなかった
- ダイアログが開かなかった
- セッションが切れた
- 画面のHTML構造が変更された
例外メッセージだけでは状況が分からないため、失敗箇所ごとにスクリーンショットを保存するようにしました。
if option_locator.count() == 0:
page.screenshot(
path="error_option_not_found.png"
)
raise RuntimeError(
f"Option not found: {option_name}"
)
これにより、次回の修正時に「何が表示されていたか」を確認できます。
8. 今回学んだこと
PlaywrightはSeleniumより待機処理を書きやすい
全体的な書き心地も優れていて、Seleniumでなぜかスクレイピングに対して苦手意識を持っていたのですが、さくさくと画面操作部分を記述できたのでそこが驚きでした。
PlaywrightのLocatorは、要素が操作可能になるまで自動的に待機します。
そのため、Seleniumでありがちだった大量の固定待機を減らせました。
ただし、外部サービス側のセッション確立や独自の遅延ロードまでは自動で理解してくれません。
DOM、通信、Cookie、表示状態を観察して、何を待つべきか判断する必要があります。
自動化では業務ロジックの分離が重要
今回効果が大きかった構成は、次の分離です。
fetch_calendar.py
外部データ取得
tasks_converter.py
分類と変換
sort_tasks.py
時間区間の整理
register_tasks.py
画面操作だけ
分類方法を変更してもPlaywright側は触らず、画面構造が変わっても分類ロジックはそのまま保てます。
関数の設計の重要性がよくわかりました。関数名と型という抽象的観点だけから何をしているか予測できるようにして、実際それ以上のスコープは持たせないし、できるだけ一つの役割に集中させるという方針です。
当たり前ですが、切り出し方で迷うこともあるので、経験を積み重ねたいと思いました。
9. おわりに
最初は「ブラウザーでボタンを押すだけ」の小さな自動化でした。
そこから、Power Automate、HTTP、JSON、Pythonパッケージ、dataclass、型、日時計算、区間処理、Playwright、ExtJSの動的DOMまで扱う実践的なプログラムへ発展しました。
しばらくの間コーディングをしていなかったこともあり、完成した自動化そのものだけでなく、次の感覚を取り戻せたことが大きな成果でした。
- 責務で関数とファイルを分ける
- 外部データをモデルへ変換する
- 型を使ってデータの意味を明確にする
- 実際の挙動を観察して原因を切り分ける
- まず基本ロジックを完成させ、必要な例外だけ追加する
AIに補助してもらいながらも、中心となるロジックは自分で考えて実装できました。
決定論的に処理できる部分はPythonに任せ、例外の検討やレビューではAIを活用し、最後は人間が確認する。
今回の自動化を通じて、AIがブラウザーを操作できる時代でも、ルールを正確にコードへ落とし込む価値は十分にあると感じました。
Happy Hacking!🚀