5
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

PythonAdvent Calendar 2024

Day 6

Windows環境のPython術 単純作業を自動化する方法

Posted at

はじめに

仕事で使用する端末がWindows環境の場合、グループウェアはMicrosoft 365を採用している組織が多いと思います。

日々仕事を行うにあたり、以下の様なルーチンワークを繰り返し行なっている場合は、自動化できる可能性があります。

  • ファイルの加工
  • Outlookでメールの送信
  • ブラウザ操作

本記事では、上記の様な単純作業についてPythonで自動化するためのヒントについて記載しています。

animal_chara_computer_azarashi.png

Pythonのインストール

Windows環境におけるPythonのインストール方法は公式ドキュメントの「4. Windows で Python を使う」をご参照ください。

インストール時のポイントとして「Add Python 3.x to PATH」にチェックすることで、環境変数 PATHにインストールディレクトリが追加されます。

ファイル操作

まずはファイルをオブジェクトとして扱う方法を知りましょう。

ファイルの基本操作

Pythonでファイルを操作する場合、pathlibやosモジュールを使用することで、基本的な操作が可能です。

なお、pathlibやos以外にもいくつかの方法や外部ライブラリが存在します。

以下はpathlibやosモジュール用いて、ファイルの存在確認を行う例です。

  • pathlib
from pathlib import Path

# ファイルパスを定義
image_path = Path('./images') / 'python.png'

# ファイルの存在を確認
if image_path.exists():
    print(f'{image_path} exists.')
else:
    print(f'{image_path} does not exist.')
  • os
import os

def check_file(file: str) -> Path:
    if not os.path.exists(file):
        raise FileNotFoundError(f'{file} not found')
    return Path(file)

pathlibを使用するにあたって/を使ってパスを結合しても問題ありません。Windowsではバックスラッシュである\が通常のパス区切り文字ですが、pathlibが適切に処理します。

参考:pathlib --- オブジェクト指向のファイルシステムパス

CSVファイルの読み込み

csvモジュールのcsv.DictReaderクラスを使用することで、各行を辞書として読み込むことができます。

import csv

def load_csv(csv_file: str) -> list[dict[str, str]]:
    department = []
    with open(csv_file, mode='r', encoding='utf-8') as file:
        reader = csv.DictReader(file)
        for row in reader:
            department.append(row)

    return department

CSVファイルのデータ集計

Jupyter Notebookとpandasを使用することで、Excelを使用しなくても簡単にデータ集計を行うことができます。

以下はsample.csvファイルのカテゴリ列をグループ化して、優先度の列を集計する例です。

import pandas as pd

csv_file = 'sample.csv'
df = pd.read_csv(csv_file, encoding='CP932')
result = df.groupby('カテゴリ', dropna=False)['優先度'].value_counts().unstack(fill_value=0)

Windows環境で日本語を含むCSVファイルを扱うにあたり、ファイルがCP932でエンコードされている場合は、encoding='CP932'を指定する必要があります。

ファイルの加工

システム運用では、元ファイルを加工してデータを取り込み、システムにインポートすることがよくあります。

以下はpandasを用いてCSVファイルを加工し、新しいファイルを生成する例です。pandasを活用することで、特定の列を削除したり、列名を変更するなどの作業を自動化できます。

import argparse
import datetime
import shutil

import pandas as pd


def copy_file(source_path: str, destination_path: str):
    try:
        shutil.copy(source_path, destination_path)
        print(f'File {source_path} successfully copied to {destination_path}')
    except Exception as e:
        print(f'Error copying file: {e}')


def get_args() -> str:
    parser = argparse.ArgumentParser()
    parser.add_argument('-f', type=str, help='CSVファイル名', required=True)
    return parser.parse_args()


def get_day() -> str:
    today = datetime.date.today().strftime('%Y%m%d')
    return today


def remove_columns_by_index(df, start: int, end: int):
    return df.drop(df.columns[start:end], axis=1)


def rename_columns(df, columns: str, new_columns: str):
    return df.rename(columns={columns:new_columns})


if __name__ == "__main__": 
    args = get_args()
    if args.f:
        source_file = args.f
        day = get_day()
         destination_file = f'output{day}.csv'
      copy_file(source_file, destination_file)
    df = pd.read_csv(destination_file)
    print(f'変更前:{df.head()}')
    # A~G列を削除
    df = remove_columns_by_index(df, 0, 7)
    # カラムの名称を「foo」→「bar」に変更
    df = rename_columns(df, 'foo', 'bar')
    # D~E列を削除
    df = remove_columns_by_index(df, 3, 5)
    print(f'変更後:{df.head()}')
    df.to_csv(destination_file, encoding="utf-8_sig", index=False)

オプションにencoding="utf-8_sig"を指定することで、ファイルの先頭にBOMが付与されます。CSVファイルをExcelで開く際の文字化けを防ぐことができます。

フォルダ作成、コピー、文字列置換、圧縮、削除

例えば、グループごとにフォルダを作成し、テンプレートとなるファイルをコピーして中身を編集し、最後に圧縮及び削除などの作業を繰り返す場合、非常に手間がかかります。

Windows環境におけるPython術-ページ2.png

以下は上記の様なフォルダ及びファイルに関する手作業を自動化する例です。

def process_department_templates(department_list: list[dict[str, str]],
                 project_name: str,
                 templates_path: str,
                 ) -> None:

    if not os.path.exists('work'):
        work = Path('work')
        work.mkdir(exist_ok=True)
    if project_name == 'A':
        batch_file = BATCH_FILE_A
        line_number = LINE_NUMBER_A
        tag = TAG_A
    elif project_name == 'B':
        batch_file = BATCH_FILE_B
        line_number = LINE_NUMBER_B
        tag = TAG_B

    os.chdir('work')

    for _ in department_list:
        work_folder = Path.cwd()
        # フォルダ作成
        make_folder(work_folder, _['Department'])
        project_folder = Path(f'{work_folder}/{_['Department']}/{project_name}')

        # ファイルのコピー
        shutil.copytree(f'../{templates_path}', project_folder, dirs_exist_ok=True)

        # 文字列置換処理
        with open(f'../{templates_path}/{batch_file}', 'r') as templates:
            lines = templates.readlines()
        if line_number <= len(lines):
            lines[line_number - 1] = lines[line_number - 1].replace(tag, _['Tag'])
        with open(project_folder/batch_file, 'w') as edited_file:
            edited_file.writelines(lines)

        # zipファイルに圧縮
        os.chdir(f'{work_folder}/{_['Department']}')
        shutil.make_archive(templates_path, 'zip', root_dir=project_folder)

        # フォルダ削除
        shutil.rmtree(project_name)
        os.chdir(work_folder)

shutilモジュールは、ファイルやフォルダに関する高水準の操作方法を提供します。

参考:shutil --- 高水準のファイル操作

Outlook

pywin32は、PythonからWindowsのAPIを呼び出すことができるライブラリです。

Component Object Model(COM)オブジェクトを操作することができるため、OutlookなどMicrosoft 365のアプリケーションに関する操作が可能です。(※すべてのMicrosoft 365アプリケーションを操作できるわけではありません)

定型文を使用した複数のメール送信

社内などで定型文を使用して複数の部署宛にメール送信を行う場合、適宜宛先を用意して、本文も編集することで、非常に手間がかかります。

以下はメール送信先のメールアドレスや部署名が書かれたCSVファイルを基に、繰り返しメールを送信する例です。共通の設定は.iniファイルを使用し、configparserモジュールを用いて読み込んでいます。

Windows環境におけるPython術.png

  • config.ini
[DEFAULT]
sender_email_address = foo@email.com
cc = bar@email.com
subject = 件名
text = 本文
sign = 署名
  • 送信プログラム
import argparse
import configparser
import csv
import os
from pprint import pprint
from string import Template

import win32com.client

config = configparser.ConfigParser()
config.read('config.ini', encoding='utf-8')
config_default = config['DEFAULT']


def get_args() -> str:
    parser = argparse.ArgumentParser()
    parser.add_argument('-a', type=str, help='添付ファイル名')  
    parser.add_argument('-f', type=str, help='CSVファイル名', required=True)  
    return parser.parse_args()


def load_recipients(csv_file: str) -> list:
    recipients = []
    with open(csv_file, mode='r', encoding='utf-8') as file:
        reader = csv.DictReader(file)
        for row in reader:
            recipients.append(row)
    return recipients

    
def send_emails(recipients: list[dict[str, str]]):
    outlook_app = win32com.client.Dispatch('Outlook.Application')
    template = Template("$body $sign")

    for recipient in recipients:
        # 0で新規作成
        mail_item = outlook_app.CreateItem(0)
        mail_item.SentOnBehalfOfName = config_default.get('sender_email_address')
        mail_item.to = f"{recipient['to_email_address1']};{recipient['to_email_address2']};{recipient['to_email_address3']};"
        mail_item.cc = f"{recipient['cc_email_address1']};{recipient['cc_email_address2']};{recipient['cc_email_address3']};{config_default.get('cc')}"
        mail_item.Subject = config_default.get('subject')
        body = f"{recipient['department']}\n{config_default.get('text')}"
        mail_item.Body = template.substitute(body=body, sign=config_default.get('sign'))
        if attached is True:
            file_path = rf"{attachments}"
            mail_item.Attachments.Add(file_path)

        # 確認
        mail_item.Display(True)


if __name__ == "__main__":
    args = get_args()
    attached = False
    if args.f:
        csv_file_path = args.f
        print(f'処理対象CSVファイル:{csv_file_path}')

    if args.a:
        attached = True
        attachments = os.path.abspath(args.a)
        print(f'添付ファイル:{os.path.abspath(args.a)}')
    recipients = load_recipients(csv_file_path)
    pprint(recipients)

    send_emails(recipients)

メール送信を行う場合はSend()メソッドを使用します。Display(True)メソッドを使用することで、メール送信をする前に確認ができます。

特定のメールを抽出して返信

メールアイテムの中から、件名に特定の文字列が含まれているメールを抽出し、そのメールに返信する処理を行うこともできます。

以下は送信済みアイテムを基にキーワードで検索を行い特定のメールを抽出し、添付ファイルを付けて返信する例です。

import argparse
import configparser
import csv
from datetime import date, datetime
import os

import win32com.client

from  ansi_escape_sequences import ColorText


def get_args() -> str:
    parser = argparse.ArgumentParser()
    parser.add_argument('-a', action='store_true', help='添付ファイル')
    parser.add_argument('-f', type=str, help='設定ファイル')
    parser.add_argument('-n', type=str, help='抽出する数', required=True)
    parser.add_argument('-w', type=str, help='検索する件名')
    return parser.parse_args()


def applying_configfile(conf_file: str):
    config = configparser.ConfigParser()
    config.read(conf_file, encoding='utf-8')
    config_default = config['DEFAULT']

    return config_default


def cehack_accounts() -> object:
    outlook_app = win32com.client.Dispatch('Outlook.Application').GetNamespace("MAPI")
    accounts = outlook_app.Folders
    print("root (アカウント数=%d)" % accounts.Count)
    
    for account in accounts:
        print("",account)
        folders = account.Folders
        for folder in folders:
            print("",folder)


        return accounts["hoge"]

def get_input_result(word: str) -> str:
    ColorText.print_colored(f'{word} datetime', 'red')
    datetime_str = input('Enter the date in YYYYMMDDHHMM format: ')

    return datetime_str

def received_emails(account: str) -> list[str]:
    folders = account.Folders
    received_emails = folders["送信済みアイテム"]
    # 全件取得して降順にソート
    all_item = received_emails.Items
    all_item.Sort("[ReceivedTime]", True)
    # 日付でフィルターする
    start_datetime_str = get_input_result('start')
    start_datetime = datetime.strptime(start_datetime_str, "%Y%m%d%H%M")
    end_datetime_str = get_input_result('end')
    end_datetime = datetime.strptime(end_datetime_str, "%Y%m%d%H%M")
    print(f"抽出期間:{start_datetime}:{end_datetime}\n")
    restriction = f"[ReceivedTime] >= '{start_datetime.strftime('%Y-%m-%d %H:%M')}' AND [ReceivedTime] <= '{end_datetime.strftime('%Y-%m-%d %H:%M')}'"
    all_item = all_item.Restrict(restriction)

    # -wオプションの引数がある場合、キーワードを基に検索 ※大文字小文字を区別しない場合は、ci_phrasematchを設定
    if search_title:
        all_item = all_item.Restrict(f"@SQL=\"urn:schemas:httpmail:subject\" like '%{search_title}%'")

    num = 0
    result_list = []
    for index, item in zip(range(num, int(max_num)), all_item):
        try:
            print(f"[No]:{index + 1} [Received time]:{item.receivedtime} [SenderName]{item.sendername} [Subject]:{item.subject}")
            result_list.append([index + 1, str(item.receivedtime), item.sendername, item.subject, item.body])
            # 全員に返信
            replay_mail = item.ReplyAll()
            # オリジナルメッセージを残す
            original_body = replay_mail.body
            try:
                body = config_default.get('text')
            except NameError:
                body = ''
            replay_mail.Body = f"{body}{original_body}"
            # 添付ファイル
            if attached is True:
                current_folder = os.getcwd()
                for filename in os.listdir():
                    file_path = os.path.join(current_folder, filename)
                    if os.path.isfile(file_path):
                        replay_mail.Attachments.Add(file_path)
            # 確認
            replay_mail.Display(True) 

        except AttributeError as e:
            print(f"[No]:{index + 1} is {e}")
            continue
        except Exception as e:
            raise BatchError('受信メール取得エラー')

    return result_list


if __name__ == "__main__":
    args = get_args()
    attached = False
    max_num = args.n
    if args.a:
        attached = True
    if args.f:
        conf_file = args.f
        config_default = applying_configfile(conf_file)
    if args.w:
        search_title = args.w
    target_account = cehack_accounts()
    print(f"処理対象アカウント:{target_account}")

    result_list = received_emails(target_account)

ReplyAll()は元のメッセージから元のすべての受信者に対する返信を作成します。

予定表の抽出

Outlookの予定表を抽出したいと思ったことはありませんでしょうか。

以下はOutlookの予定表を抽出して出力する例です。オプションを指定すると、任意の日付の予定を抽出します。


import argparse
from datetime import date, datetime, timedelta
import os

import win32com.client

from  ansi_escape_sequences import ColorText

def get_args() -> str:
    parser = argparse.ArgumentParser()
    parser.add_argument('-d', action='store_true', help='日付モード')  

    return parser.parse_args()


def get_today() -> datetime:
    today = datetime.now()
    start_date_yyyy, start_date_mm, start_date_dd = map(int, (today.strftime('%Y'), today.strftime('%m'), today.strftime('%d')))
    today = today.today() + timedelta(days=1)
    end_date_yyyy, end_date_mm, end_date_dd = map(int, (today.strftime('%Y'), today.strftime('%m'), today.strftime('%d')))

    start_date = date(start_date_yyyy, start_date_mm, start_date_dd) 
    end_date = date(end_date_yyyy, end_date_mm, end_date_dd) 

    return start_date, end_date 


def get_input_result() -> int:
    ColorText.print_colored('start_date: YYYY MM DD', 'red')
    start_date_yyyy, start_date_mm, start_date_dd = map(int, input().split())
    ColorText.print_colored('end_date: YYYY MM DD', 'red')
    end_date_yyyy, end_date_mm, end_date_dd = map(int, input().split())

    start_date = date(start_date_yyyy, start_date_mm, start_date_dd) 
    end_date = date(end_date_yyyy, end_date_mm, end_date_dd) 

    return start_date, end_date


def get_meetinsStatus(OlMeetingStatus: int) -> str:
    if OlMeetingStatus == 1:
        status = 'olMeeting'
    elif OlMeetingStatus == 3:
        status = 'olMeetingReceived'
    elif OlMeetingStatus == 5:
        status = 'olMeetingCanceled'
    elif OlMeetingStatus == 7:
        status = 'olMeetingReceivedAndCanceled'
    elif OlMeetingStatus == 0:
        status = 'olNonMeeting'

    return status


def get_calendar() -> str:
    outlook = win32com.client.Dispatch("Outlook.Application").GetNamespace("MAPI")
    calender = outlook.GetDefaultFolder(9)
    items = calender.Items
    items.Sort("[Start]")
    items.IncludeRecurrences = True
    restriction = f"[Start] >= '{start_date:%Y-%m-%d}' AND [End] <= '{end_date:%Y-%m-%d}'"
    
    select_items = []
    select_items = items.Restrict(restriction) 

    for select_item in select_items:
        ColorText.print_colored(f'タイトル:{select_item.subject}', 'underline')
        print('場所  :', select_item.location)
        print('開始時刻:', select_item.start)
        print('終了時刻:', select_item.end)
        print('状態:', get_meetinsStatus(select_item.MeetingStatus))
        print("本文:", select_item.body)
        print('')
        
if __name__ == "__main__":
    os.system('CLS')
    args = get_args()
    if args.d:
        start_date, end_date = get_input_result()
    else:
        start_date, end_date = get_today()
        
    get_calendar()

会議の状態は、OlMeetingStatusプロパティで確認できます。

予定の追加

予定の追加もできます。

以下は参加者のメールアドレスが記載されたテキストファイルを基に、一予定を追加する例です。

import argparse
from datetime import date, datetime, timedelta

import win32com.client


def get_args() -> str:
    parser = argparse.ArgumentParser()
    parser.add_argument('-f', type=str, help='ファイル名')  

    return parser.parse_args()


def get_input_result(word: str) -> str:
    ColorText.print_colored(f'{word} datetime', 'red')
    datetime_str = input('Enter the date in YYYYMMDDHHMM format: ')

    return datetime_str

def get_input_title() -> str:
    ColorText.print_colored(f'title', 'red')
    title = input('Enter the title: ')

    return title


def add_recipients(appointment: object, filename: str):
    with open(filename, 'r') as file:
        for line in file:
            email_address = line.strip()
            if email_address:
                recipient = appointment.Recipients.Add(email_address)
                recipient.Type = 1


def add_calendar() -> str:
    outlook = win32com.client.Dispatch("Outlook.Application").GetNamespace("MAPI")
    calender = outlook.GetDefaultFolder(9)
    items = calender.Items
    appointment = items.Add() 

    start_datetime_str = get_input_result('start')
    start_datetime = datetime.strptime(start_datetime_str, "%Y%m%d%H%M")
    end_datetime_str = get_input_result('end')
    end_datetime = datetime.strptime(end_datetime_str, "%Y%m%d%H%M")

    # 件名
    appointment.Subject = get_input_title()
    # 日時
    appointment.Start = start_datetime + timedelta(hours=9)
    appointment.End = end_datetime + timedelta(hours=9)

    # 参加者
    if file_path:
        add_recipients(appointment, file_path)

    if not appointment.Recipients.ResolveAll():
        print("error")
        return

    # 保存
    appointment.Save()


if __name__ == "__main__":
    args = get_args()
    if args.f:
        file_path = args.f
    add_calendar()

その他

SeleniumでEdgeを操作する

SeleniumでEdgeを操作するために、ブラウザオブジェクトを作成する例について以下に記載します。

また、webdriver_managerのライブラリを用いてドライバーのバージョンアップを自動化します。webdriver_managerについては以前書いた「Seleniumで使用するドライバのバージョンアップを自動化する」をご参照ください。

from selenium import webdriver
from selenium.webdriver.edge.service import Service as EdgeService
from webdriver_manager.microsoft import EdgeChromiumDriverManager

driver = EdgeChromiumDriverManager().install()
service = EdgeService(executable_path=driver)
options = webdriver.EdgeOptions()
browser = webdriver.Edge(service=service, options=options)
browser.maximize_window()

Pyinstallerによるexe化

日々行っているルーチンワークをツール化して他の人にも使用してもらうためには、作成したプログラムを配布する必要があります。

そのため、プログラムを利用する側の環境が整ってない場合、プログラムの実行がハードルになりやすいです。

従って環境依存を防ぎ、簡単にプログラムを実行させたい場合、Windows環境におけるexe化は一つの解決策です。

以下Pyinstallerというライブラリを使用することで、実現できます。

実行方法も簡単です。exe化したいプログラムを引数にして実行するだけです。

> pyinstaller /path/to/yourscript.py

exe化してもargparseライブラリの引数は同様に使用できます。

batファイルからPythonを実行

作成したPythonのプログラムをWindowsのbatファイルから呼び出す場合、ダブルクリックで実行できるため、ちょっと動かしたいときなどは便利だと思います。

以下はcallコマンドを用いて、仮想環境を有効化するコマンドが書かれたバッチファイルを呼び出した後に、プログラムを実行する例です。また、バッチファイルに引数を付与して、argparseの処理を制御しています。

  • activate.bat
@echo off
rem 仮想環境のアクティベート

C:\Users\hoge\AppData\Local\Programs\Python\.venv\Scripts\activate
  • sample.bat
echo processing begins.
set option=-w 

call activate.bat
cd ..\sample

if "%1"=="" (
    echo No options.
    python sample.py
    goto end
)

if %1==%option% (
    echo Select options.
    python sample.py -w
    goto end
) else (
    echo [error]Problem with setting options.
)

:end
echo processing completed.

テキストの装飾

ANSIエスケープシーケンスを使用することで、ターミナルのテキストに装飾を施すことができます。

しかし、Windowsでは、コマンドプロンプトやパワーシェルがデフォルトでANSIエスケープシーケンスに対応していないため、装飾を行うにはWindows環境でこれらのシーケンスを有効にする必要があります。

以下はモジュール化する例です。

import ctypes
import os

class ColorText:
    colors = {
        'reset': '\033[0m', #リセット
        'black': '\033[30m',
        'red': '\033[31m',
        'green': '\033[32m',
        'yellow': '\033[33m',
        'bule': '\033[34m',
        'magenta': '\033[35m',
        'cyan': '\033[36m',
        'white': '\033[37m',
        'bold': '\033[1m', #大文字
        'underline': '\033[4m', #アンダーライン
    }

    ansi_escape_sequences = False

    @classmethod
    def _enabled_virtual_terminal_processing(cls):
        if os.name == 'nt' and not cls.ansi_escape_sequences:
            ENABLE_PROCESSED_OUTPUT = 0x0001
            ENABLE_WRAP_AT_EOL_OUTPUT = 0x0002
            ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004
            MODE = ENABLE_PROCESSED_OUTPUT + ENABLE_WRAP_AT_EOL_OUTPUT + ENABLE_VIRTUAL_TERMINAL_PROCESSING

            kernel32 = ctypes.windll.kernel32
            handle = kernel32.GetStdHandle(-11)
            kernel32.SetConsoleMode(handle, MODE)
            cls.ansi_escape_sequences = True

    @classmethod
    def color_text(cls, text, color):
        cls._enabled_virtual_terminal_processing()
        color_code = cls.colors.get(color)
        if color_code:
            return f'{color_code}{text}{cls.colors['reset']}'
        else:
            return text


    @classmethod
    def print_colored(cls, text, color):
        print(cls.color_text(text, color))


if __name__ == '__main__':
    ColorText.print_colored('This is black text', 'black')
    ColorText.print_colored('This is red text', 'red')
    ColorText.print_colored('This is green text', 'green')
    ColorText.print_colored('This is yellow text', 'yellow')
    ColorText.print_colored('This is bule text', 'bule')
    ColorText.print_colored('This is magenta text', 'magenta')
    ColorText.print_colored('This is cyan text', 'cyan')
    ColorText.print_colored('This is white text', 'white')
    ColorText.print_colored('This is bold text', 'bold')
    ColorText.print_colored('This is underlined text', 'underline')

おわりに

最近のWindows環境では、Power Automateを活用することで、プログラミング知識がなくてもルーティンワークなどの自動化が容易になっています。

しかし、より高度で細かな処理を実現するためには、プログラミングが必要です。

5
6
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
5
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?