PythonからWin32apiを使ってWindowsの低レベルのマウスの入力イベントを監視する方法を紹介します。同様のことはpyhookというモジュールを使ってもできるようです。
環境
- Windows11
- Python 3.11.0
- pywin32 306
メッセージフックとは
マイクロソフトが公開しているwin32apiのリファレンスには、フックの概要として次のように説明されています。
フックは、アプリケーションがメッセージ、マウスアクション、キーストロークなどのイベントをインターセプトできるメカニズムです。特定の種類のイベントをインターセプトする関数は、 フック プロシージャと呼ばれます。 フック プロシージャは、受け取る各イベントに対して動作し、イベントを変更または破棄できます。
ここで紹介する低レベルのマウスイベントのフックは、マウスアクションをWindowsのシステムが処理する前に、何らかの処理を割り込ませて情報を取得しようというものです。
注意
この処理はWindowsのシステムの文脈で行われるので、この処理の記述がまずいと最悪の場合システムにマウスアクションが正しく伝わらない事態もあり得ます。
メッセージフックにはwin32apiの三つの関数を使います。
-
SetWindowsHookExA()
フックプロシージャをインストールする -
CallNextHookEx()
本来メッセージを処理するはずだったフックプロシージャにフック情報を渡す -
UnhookWindowsHookEx()
フックプロシージャをアンインストールする
これらの関数はすべてUser32.dll
から利用できます。Pythonからはctypesモジュールを使えば利用することができます。
ctypesの基本を簡単に
ctypes は Python のための外部関数ライブラリです。ctypesモジュールを使えば、Pythonからの利用を想定されていないようなDLL(動的リンクライブラリ)を利用できます。
ctypesの詳細については手に余るので、ここではメッセージフックに必要な最低限と思われることに絞って紹介します。
DLLのロード
ctypesを使ってDLLにアクセスする方法はいくつかありますが、ここではCDLL系クラスのインスタンスを利用する方法を紹介します。
CDLL系のクラスはコンストラクタに「.dll」ファイルをパスで指定するだけで、DLLをインスタンスとしてロードします。このインスタンスを介して、インポートしたモジュールのようにDLLを利用できるようになります。
CDLL系クラスは、対象になるDLLのスタイルに応じたものを選ぶ必要があります。win32apiのDLLにはWinDLL
クラスを使います。
import ctypes
user32 = ctypes.WinDLL('user32', use_last_error=True)
print(user32.SetWindowsHookExA)
<_FuncPtr object at 0x0000021825D79A50>
一見パスを指定しているようには見えませんが、これはWindowsの環境変数の都合でファイル名だけの記述でパスとして通じています。二番目の引数はエラーメッセージに関するもので、エラーメッセージを利用する気がないのならあえてTrue
にセットする必要はないと思います。
DLL関数に渡す引数
DLLの関数に渡す値は、ctypesモジュールで定義された互換型を利用します。基本的にはctypesのマニュアルにある基本データ型の一覧表から、対応するものを選ぶ形になります。
win32apiのDLLを扱う場合ではctypes.wintypes
モジュールが便利です。wintypes
にはwin32apiで利用される様々な型の互換型が、まったく同じ名前で定義されています。win32apiのDLL関数に渡す値は、apiのリファレンスで指定されているものと同じ名前の互換型で与えれば間違いないでしょう。
DLL関数からの戻り値
DLL関数から得られる戻り値は、互換型ではなくPythonの基本型として得られます。この点で注意が必要なのは、DLL関数の出力をすぐに別のDLL関数に渡したい、という時でも互換型への変換が必要になるということです。
構造体と関数の互換型
メッセージフックを行う上で必要になる、構造体とコールバック関数の互換型について少し詳しく書きます。
構造体
構造体はc言語などにあるクラスに似た仕組みです。構造体はいくつかの変数のまとまりからなる型として、自由に定義できるものです。マウスアクションのフックで取得できるデータはtagMSLLHOOKSTRUCT
という構造体として得られます。次のように定義されています。
typedef struct tagMSLLHOOKSTRUCT {
POINT pt;
DWORD mouseData;
DWORD flags;
DWORD time;
ULONG_PTR dwExtraInfo;
} MSLLHOOKSTRUCT, *LPMSLLHOOKSTRUCT, *PMSLLHOOKSTRUCT;
この構造体は「POINT型の変数」を一つ、「DWORD型の変数」を三つ、「ULONG_PTR型の変数」を一つ、計5つの変数からなるひとつの型になります。
Pythonでこのような構造体のデータを受け取るためには、ctypesのStructure
クラスの継承クラスを利用します。
import ctypes
import ctypes.wintypes as win
class MsStructuer(ctypes.Structure):#ctypes.Structureクラスの継承
_fields_ = [('point', win.POINT),
('mouseDate', win.DWORD),
('flags', win.DWORD),
('time', win.DWORD),
('dwExtraInfo', ctypes.c_ulong)]
このように_fields_
属性に、対象になる構造体の要素を「名前(任意)」と「型」からなる「タプル」のリストで指定することで、特定の構造体に対するPythonの互換クラスを作成できます。
ここで指定する要素の名前は、各要素にアクセスするためのPythonクラスの属性の名前になります。また、型は互換型で指定しますが、POINT
もDWORD
もctypes.wintypes
にあるものをそのまま利用できます。
コールバック関数
SetWindowsHookExA
関数にはコールバック関数として、Pythonで記述したフックプロシージャを渡すことになります。しかし、他の変数同様にPythonの関数オブジェクトをそのままSet~
関数に渡すことはできません。互換性のある方に変換して渡す必要があります。
マウスアクションのフックでSetWindowsHookExA
関数が期待しているコールバック関数は次のようなものです。
LRESULT CALLBACK LowLevelMouseProc(
_In_ int nCode,
_In_ WPARAM wParam,
_In_ LPARAM lParam
);
これは「LRESULT型」を返す「int型」と「WPARAM型」と「LPARAM型」を引数にとる関数の定義です。Pythonで記述されるフックプロシージャはまず、三つの引数を受け取って一つの値を返す、という要件を満たしている必要があります。
Pythonの関数オブジェクトを上のような関数と互換性のある形に変換するには、まずctypes.WINFUNCTYPE
関数を使ってWinFunctionType
クラスを作成します。
HOOKPROC = ctypes.WINFUNCTYPE(ctypes.c_long, ctypes.c_int, win.WPARAM, win.LPARAM)
ctypes.WINFUNCTYPE
関数には、目的のコールバック関数の「戻り値の型」と「引数の型」を指定します。戻り値である「LRESULT型」は、win32apiによくある「long型の整数値」によるなんらかのメッセージの表現です。wintypes
に「LRESULT型」がなかったのでc_long
で代用しました。
この関数の戻り値として得られたWinFunctionType
クラスに、Pythonの関数オブジェクトを渡してインスタンスを作ると、ようやくコールバック関数の互換オブジェクトが得られます。
def func(nCode, wParam, lParam):
return 0
HOOKPROC = ctypes.WINFUNCTYPE(ctypes.c_long, ctypes.c_int, win.WPARAM, win.LPARAM)
callback = HOOKPROC(func)
このcallback
であればSetWindowsHookExA
関数は受け取ってくれます。
Set~、Call~、Unhook~関数の詳細
あとはSet~、Call~、Unhook~の使い方と、フックプロシージャの詳細を詰めれば、マウスフックのPythonスクリプトに進むことができます。もうちょっとです。
SetWindowsHookExA
は以下のように定義されています。
HHOOK SetWindowsHookExA(
[in] int idHook,
[in] HOOKPROC lpfn,
[in] HINSTANCE hmod,
[in] DWORD dwThreadId
);
引数の意味するところはだいたい以下のような感じです。
- idHook
整数値の識別子でもって、フックの種類を指定 - lpfn
フックプロシージャのポインタを指定。上で作ったcallback
を渡す部分です - hmod
フックプロシージャを含むDLLのハンドルを指定。フックプロシージャがSet~
関数の呼び出し元と同じコード内に記述されている場合はnull
、つまりPythonではNone
を指定します - dwThreadId
フックプロシージャを関連付けるスレッドの整数値による識別子。0を指定することですべてのスレッドが対象になります - 戻り値:HHOOK
関数が成功した場合「HHOOK型」によるフックプロシージャへのハンドルを、失敗した場合はnull
を返す。この「HHOOK型」はUnhookWindowsHookEx
の引数として利用するので、大切に保管しましょう
つづいてCallNextHookEx
の定義です。
LRESULT CallNextHookEx(
[in, optional] HHOOK hhk,
[in] int nCode,
[in] WPARAM wParam,
[in] LPARAM lParam
);
- hhk
リファレンスによると、この引数は無視されます、とのことなのでなんでもいいです - nCode, wParam, lParam
これらの引数にはフックプロシージャが受け取る引数をそのまま渡してやります - 戻り値:LRESULT
フックプロシージャはこの戻り値を返す必要がある
つまりCallNextHookEx
は、フックプロシージャの中で呼び出す必要があるということです。フックプロシージャのreturn
の部分でCallNextHookEx
を呼び出すのがセオリーのようです。
UnhookWindowsHookEx
です。
BOOL UnhookWindowsHookEx(
[in] HHOOK hhk
);
- hhk
Set~
の返した値を渡すことで、Set~
がインストールしたフックプロシージャがアンインストールされます - 戻り値:BOOL
フックプロシージャのアンインストールの成否を表すものです
最後にフックプロシージャの受け取る三つの引数についてです。
-
nCode
整数値による識別子。wParamとlParamがマウスメッセージに関する情報を持っていれば「0」。「0」はHC_ACTIONという定数として定義されている -
wParam
WPARAM型によるマウスメッセージの識別子。WM_LBUTTONDOWN、WM_LBUTTONUP、WM_MOUSEMOVE、WM_MOUSEWHEEL、WM_RBUTTONDOWN、M_RBUTTONUPというWPARAM型の定数として定義されている -
lParam
MSLLHOOKSTRUCT 構造体へのポインタ。構造体そのものではなく、その位置だけが示される形なので、lParamから構造体の情報を取得するにはStructure
クラスのfrom_address
関数を使います
PythonからnCodeとwParam用の定数として定義された値は、pywin32に含まれるwin32conというモジュールから利用できます。SetWindowsHookExA
のidHook
で指定するフック識別子も同様です。
Pythonによるマウスフックスクリプト
以上のことを押さえておけば、次のPythonスクリプトがおおむね理解できるかと思います。マウスの左クリックに応じて、アクションの発生した時間と画面上での座標が、逐次コンソールウィンドウにprint
されるというものです。
import win32con
import ctypes
import ctypes.wintypes as win
user32 = ctypes.WinDLL('user32', use_last_error=True)
class MsStructuer(ctypes.Structure):
_fields_ = [('point', win.POINT),
('mouseDate', win.DWORD),
('flags', win.DWORD),
('time', win.DWORD),
('dwExtraInfo', ctypes.c_ulong)]
class KeyLogger:
def __init__(self):
self.lUser32 = user32
self.hooked = None
def installHookProc(self,pointer):
self.hooked = self.lUser32.SetWindowsHookExA(win32con.WH_MOUSE_LL, pointer, None, 0)
if not self.hooked:
return False
return True
def uninstalHookProc(self):
if self.hooked is None:
return
self.lUser32.UnhookWindowsHookEx(self.hooked)
self.hooked = None
def hookProc(nCode, wParam, lParam):
if nCode == win32con.HC_ACTION and wParam == win32con.WM_LBUTTONDOWN:
ms = MsStructuer.from_address(lParam)
print(f'{ms.time}:{ms.point.x}, {ms.point.y}')#timeはミリ秒単位かな
if nCode == win32con.HC_ACTION and wParam == win32con.WM_RBUTTONDOWN:
print('右クリックが押されました。フックを解除します')
kl.uninstalHookProc()
return user32.CallNextHookEx(0, nCode, win.WPARAM(wParam), win.LPARAM(lParam))
return user32.CallNextHookEx(0, nCode, win.WPARAM(wParam), win.LPARAM(lParam))
HOOKPROC = ctypes.WINFUNCTYPE(ctypes.c_long, ctypes.c_int, win.WPARAM, win.LPARAM)
callback = HOOKPROC(hookProc)
kl = KeyLogger()
bool = kl.installHookProc(callback)
msg = win.MSG()
user32.GetMessageA(ctypes.byref(msg), 0, 0, 0)
最後の二行、win32apiのGetMessageA
関数を呼び出すための記述は、Pythonスクリプトを終了させないための、とりあえずのものです。なぜそのような措置が必要なのかは、メッセージフックにおいてPythonスクリプトが果たしている役割について注目するとわかります。
このPythonスクリプトがやっているのは、フックプロシージャや各クラスの定義以外の部分では、KeyLoggerクラスのインスタンスを作成して、フックプロシージャをインストールしているだけです。フックプロシージャを実行するのはWindowsのシステムなので、Pythonスクリプトの役割はこれ以上なにもないからです。
しかし、やることがないからといってスクリプトに終了してもらうわけにはいきません。スクリプトが終了してしまえば、フックプロシージャの記述が失われてしまい、自動的にフックが解除されてしまうからです。
GetMessageA
関数は、指定したウィンドウにメッセージが届くのを「待機」する関数です。この場合は、届くはずのないメッセージを待ち続ける形で、Pythonスクリプトの終了を防いでいます。しかし、これではPythonインタプリタを停止させる以外スクリプトを止めるすべがないので、あくまでも「とりあえず」の手段です。
終わりに
あまりに長くなりすぎたので、フックスクリプトが完成したところで、とりあえず、この記事を前半ということにしてまとめることにします。
最後に示したスクリプトでは、行儀よく終了させる手段がないとか、フックプロシージャが取得した情報を活用する手段がないなど、いろいろと問題があります。続きの記事でこれらの問題をクリアした例と、低レベルのキーボードメッセージへの応用例を紹介したいと思います。
参考
-
https://stackoverflow.com/questions/53732628/python-using-winapi-setwindowshookexa-on-windows-10
最後のスクリプトはこの質問への回答を参考にしています -
https://stackoverflow.com/questions/38557655/python-windows-mouse-hook-crash
DLLのロードについて、この質問への回答に準じました -
http://www.wisdomsoft.jp/
windowsapiについて検索すると、こちらの解説が結構な頻度でヒットします。公式のリファレンスで詰まった時には大変頼りになります