Webブラウザでアクセスする形で使える家庭用ルータの設定ページなどにおいて、特定の内容の一連のフォーム送信処理を自動化したい場面に遭遇することがある。
そうしたときにPythonのスクリプトを記述して実行することで、(Webブラウザを用いずに)設定変更を自動化するために役立てることができる。これを応用すると、スクリプトの記述内容や実行のさせ方によっては決まった時間帯に特定の設定項目を変更するような使い方もできる。
本記事で扱う方法では、フォームがPOSTメソッドであっても、WebブラウザでアクセスするページでBasic認証が必要であっても自動でフォーム内容を送信でき、サンプルコードの本体とは別に、Pythonで記述され、Basic認証の必要なHTTPデーモンのコードとその作成メモも扱う。
Pythonでフォームを送信するための準備と方法
フォームの送信に必要な情報の収集
実際に用途に合わせたスクリプトを記述する際には
- フォームを送信するURL(
form
要素のaction
属性から決まる) - フォームの送信内容(
input
やselect
といった要素のname
属性と、それに対応した値のペアの集まり)
の情報が必要で、ページがフレームで分かれている場合にはWebブラウザ上でフレーム内のページのURLを調べた上でフォームのあるページのHTMLソースを別途参照する必要がある。
送信処理
基本的にはurllib
系のモジュールを用いてWebページの内容を取得する流れと似ており
も参考になる。
フォームに送信するデータというのは “名前と値のペアの集合” の形をとり、Pythonのコードとしては名前と値の文字列から成る2要素タプルの集まりで、全体をタプル型とすると(([名前1], [値1]), ([名前2], [値2]), ... )
のように表現される。
これをurlencode()
関数に渡すとフォームへの送信内容がURLエンコードされたものが得られるので、Webページの内容を取得するのと同様にURLを開く際に2番目の(data
という)引数としてその結果を渡せばよい。ただし、ascii
によるエンコードも必要。引数には辞書型を用いることもできるのだが、その性質上、同一の名前を持つペアを複数持つことはできない。
urlencode()
はPython 3ではurllib.parse
に、Python 2ではurllib
からインポートする。
Basic認証のための記述
Basic認証が必要なページにフォームがある場合は以下のようにする。
HTTPPasswordMgrWithDefaultRealm
のようなHTTPPasswordMgr
系クラスのオブジェクトを作成add_password()
で平文のユーザ名とパスワードを指定して認証のための情報を渡すbuild_opener()
で、前述のオブジェクトを引数にして作成したHTTPBasicAuthHandler
クラスのオブジェクトを渡してOpenerDirector
クラスのオブジェクトを得る
もう一つの方法としては、codecs.encode()
でBase64エンコードができることを利用して手動でAuthorization
ヘッダの文字列を組み立ててOpenerDirector
クラスのオブジェクトのメンバaddheaders
に代入することなどによってヘッダを渡すこともできる。
サンプル
フォームを送信するスクリプトだけがあっても送信先のサーバがないといけないので、PythonのHTTPサーバ機能を利用してフォームの表示や送信がローカルホスト上で行えるようにするスクリプトを別に用意した。
HTTPヘッダなど実際の通信内容を細かく確かめたい場合はWiresharkが便利で、キャプチャを行うインターフェースにはループバックインターフェースのlo
を指定する。
Basic認証に対応したフォーム送信テスト用HTTPD
フォームを送信するサンプルよりもこちらのほうが作るのに手間と時間がかかったのは明らかだが、そんなの関係(略)
httpd-form-test.py
エンコーディング:UTF-8 ライセンス:MIT#! /usr/bin/python
# -*- coding: utf-8 -*-
# HTTPD sample for HTML form test
# (C) 2018 kakurasan
# Licensed under MIT
import codecs
try:
import http.server as httpserver
from urllib.parse import parse_qs
use_python2 = False
except ImportError:
import BaseHTTPServer as httpserver
from urlparse import parse_qs
use_python2 = True
class Config:
port = 18418
username = 'admin'
password = 'formtest'
class MyHTTPRequestHandler (httpserver.BaseHTTPRequestHandler):
def _send_headers (self, html, response_code, additional_headers = ()):
self.send_response (response_code)
self._encoded_html = html if use_python2 else html.encode ('utf-8')
self.send_header ('Content-type', 'text/html; charset=utf-8')
self.send_header ('Content-Length', str (len (self._encoded_html)))
for name, value in additional_headers:
self.send_header (name, value)
self.end_headers ()
def _get_header (self, name):
return self.headers.getheader (name) if use_python2 else self.headers.get (name)
def _send_401 (self):
html = '''<!doctype html>
<html>
<head><title>401 Unauthorized</title></head>
<body><h1>401 Unauthorized</h1></body>
</html>'''
realm = 'Form test (user={}, pass={})'.format (Config.username, Config.password)
additional_headers = (('WWW-Authenticate', 'Basic realm="{}"'.format (realm)),)
self._send_headers (html, 401, additional_headers)
def _send_404 (self):
html = '''<!doctype html>
<html>
<head><title>404 Not found</title></head>
<body><h1>404 Not found</h1></body>
</html>'''
self._send_headers (html, 404)
def _send_405 (self):
html = '''<!doctype html>
<html>
<head><title>405 Method Not Allowed</title></head>
<body><h1>405 Method Not Allowed</h1></body>
</html>'''
self._send_headers (html, 405)
def _send_411 (self):
html = '''<!doctype html>
<html>
<head><title>411 Length Required</title></head>
<body><h1>411 Length Required</h1></body>
</html>'''
self._send_headers (html, 411)
def _check_auth_header (self):
auth_header = self._get_header ('Authorization')
if not auth_header:
return False
user_and_pass = '{}:{}'.format (Config.username, Config.password)
expected = b'Basic '
expected += codecs.encode (user_and_pass.encode ('ascii'), 'base64').rstrip ()
return auth_header == expected.decode ('ascii')
def _handle_path (self):
# / -> OK (Form page) [200]
# /submit -> Method Not Allowed [405]
# (other) -> Not found [404]
if self.path == '/':
html = '''<!doctype html>
<html>
<head><title>フォーム (Form)</title></head>
<body>
<h1>フォーム (Form)</h1>
<form method="post" action="/submit">
<p><label>名前 (Name): <input type="text" name="name"></label></p>
<fieldset><legend>性別 (Gender)</legend>
<label><input type="radio" name="gender" value="male" checked="checked">男性 (Male)</label>
<label><input type="radio" name="gender" value="female">女性 (Female)</label>
</fieldset>
<fieldset><legend>OS</legend>
<label><input type="checkbox" name="os" value="linux">GNU/Linux</label>
<label><input type="checkbox" name="os" value="bsd">BSD</label>
<label><input type="checkbox" name="os" value="win">Windows</label>
<label><input type="checkbox" name="os" value="mac">macOS</label>
<label><input type="checkbox" name="os" value="other">その他 (Other)</label>
</fieldset>
<p>場所 (Location): <select name="location">
<option value="asia">アジア (Asia)</option>
<option value="africa">アフリカ (Africa)</option>
<option value="europa">ヨーロッパ (Europa)</option>
<option value="north_america">北アメリカ (North America)</option>
<option value="south_america">南アメリカ (South America)</option>
<option value="oceania">オセアニア (Oceania)</option>
</select></p>
<input type="submit" value="送信 (Submit)">
</form>
</body>
</html>'''
self._send_headers (html, 200)
elif self.path == '/submit':
self._send_405 ()
else:
self._send_404 ()
def do_HEAD (self):
if self._check_auth_header ():
self._handle_path ()
else:
self._send_401 ()
def do_GET (self):
self.do_HEAD ()
self.wfile.write (self._encoded_html)
def do_POST (self):
if not self._check_auth_header ():
self._send_401 ()
self.wfile.write (self._encoded_html)
return
elif self.path != '/submit':
self._send_405 ()
self.wfile.write (self._encoded_html)
return
content_length = self._get_header ('Content-Length')
if not content_length:
self._send_411 ()
self.wfile.write (self._encoded_html)
return
query_str = self.rfile.read (int (content_length))
if not use_python2:
query_str = query_str.decode ('ascii')
query = parse_qs (query_str)
try:
name = query['name'][0]
except KeyError:
name = '無し (None)'
try:
gender = query['gender'][0]
except KeyError:
gender = '無し (None)'
try:
os = ', '.join (query['os'])
except KeyError:
os = '無し (None)'
try:
location = query['location'][0]
except KeyError:
location = '無し (None)'
html = '''<!doctype html>
<html>
<head><title>フォーム入力内容 (Form Input)</title></head>
<body>
<h1>フォーム入力内容 (Form Input)</h1>
<ul>
<li>クエリ (Query): <em>{}</em></li>
<li>名前 (Name): <em>{}</em></li>
<li>性別 (Gender): <em>{}</em></li>
<li>OS: <em>{}</em></li>
<li>場所 (Location): <em>{}</em></li>
</ul>
</body>
</html>'''.format (query_str, name, gender, os, location)
self._send_headers (html, 200)
self.wfile.write (self._encoded_html)
def main ():
httpd = httpserver.HTTPServer (('', Config.port), MyHTTPRequestHandler)
httpd.serve_forever ()
if __name__ == '__main__':
main ()
これを改変せずに実行するとCtrl-cで止めるまでhttp://127.0.0.1:18418/
にWebブラウザでアクセスできるようになり、認証ウィンドウが表示される。これは
- ユーザ名:
admin
- パスワード:
formtest
で認証することができ、正しく入力するとフォームが表示される。入力項目は
- 名前 (入力欄)
- 性別 (ラジオボタン)
- OS (チェックボックス)
- 場所 (選択)
となる。
テスト用HTTPDのフォームを送信する例
上のスクリプト(HTTPD)の動作中に下のスクリプトを実行すると、認証を行った上でフォーム内容を送信し、入力された内容を示すHTMLページ(2つ)のコードが出力される。
フォーム側が想定していないフォーム内容が送信できることを示すため、query_form_unexpected()
という関数を意図的に入れている。
submit-form-post.py
エンコーディング:UTF-8 ライセンス:MIT#! /usr/bin/python
# -*- coding: utf-8 -*-
# Non-interactive HTTP form submitting sample
# (Basic Authentication + POST method)
# (C) 2018 kakurasan
# Licensed under MIT
from __future__ import print_function
try: # Python 3
from urllib.request import build_opener, HTTPBasicAuthHandler
from urllib.request import HTTPPasswordMgrWithDefaultRealm
from urllib.parse import urlparse, urlencode
except ImportError: # Python 2
from urllib2 import build_opener, HTTPBasicAuthHandler
from urllib2 import HTTPPasswordMgrWithDefaultRealm
from urlparse import urlparse
from urllib import urlencode
class Config:
username = 'admin'
password = 'formtest'
ua = 'Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.135 Safari/537.36 Edge/12.10240'
def post_query (url, form):
data = urlencode (form).encode ('ascii')
pass_mgr = HTTPPasswordMgrWithDefaultRealm ()
pass_mgr.add_password (None, urlparse (url).netloc,
Config.username, Config.password)
opener_director = build_opener (HTTPBasicAuthHandler (pass_mgr))
opener_director.addheaders = (('User-agent', Config.ua),)
f = opener_director.open (url, data)
html = f.read ()
try:
html = str (html, 'utf-8')
except: # Python 2
pass
print (html)
f.close ()
def query_form ():
url = 'http://127.0.0.1:18418/submit'
form = (('name', '五木 鰤子'),
('gender', 'female'),
('os', 'win'),
('os', 'other'),
('location', 'asia'))
post_query (url, form)
def query_form_unexpected ():
url = 'http://127.0.0.1:18418/submit'
form = (('gender', 'none'),
('os', 'chokanji'),
('os', 'msdos'),
('location', 'hell'))
post_query (url, form)
def main ():
query_form ()
print ('-' * 80)
query_form_unexpected ()
if __name__ == '__main__':
main ()
出力結果<!doctype html> <html> <head><title>フォーム入力内容 (Form Input)</title></head> <body> <h1>フォーム入力内容 (Form Input)</h1> <ul> <li>クエリ (Query): <em>name=%E4%BA%94%E6%9C%A8+%E9%B0%A4%E5%AD%90&gender=female&os=win&os=other&location=asia</em></li> <li>名前 (Name): <em>五木 鰤子</em></li> <li>性別 (Gender): <em>female</em></li> <li>OS: <em>win, other</em></li> <li>場所 (Location): <em>asia</em></li> </ul> </body> </html> -------------------------------------------------------------------------------- <!doctype html> <html> <head><title>フォーム入力内容 (Form Input)</title></head> <body> <h1>フォーム入力内容 (Form Input)</h1> <ul> <li>クエリ (Query): <em>gender=none&os=chokanji&os=msdos&location=hell</em></li> <li>名前 (Name): <em>無し (None)</em></li> <li>性別 (Gender): <em>none</em></li> <li>OS: <em>chokanji, msdos</em></li> <li>場所 (Location): <em>hell</em></li> </ul> </body> </html>
関連:フォーム送信テスト用HTTPDの作成についてのメモ
以下に示した以外にはHTTPServer
クラスのオブジェクトによるHTTPサーバの開始処理を通常通りに行うのみとなる。
リクエストハンドラの実装
Python 3ではhttp.server
、Python 2ではBaseHTTPServer
モジュールからBaseHTTPRequestHandler
とHTTPServer
のクラスをインポートし、前者のクラスを継承したクラスを定義して
do_HEAD()
: HEADメソッドのリクエストに対する処理do_GET()
: GETメソッドの(略)do_POST()
: POST(略)
のメンバ関数を実装する。実装されたメソッドはそのクラスのオブジェクトによってサポートされ、例えばメンバ関数do_POST()
が定義されていなければPOSTメソッドを受け付けない。要求されるパス名はメンバpath
で参照し、HTTPクライアントに渡すデータ(今回はUTF-8エンコーディングのHTMLコード)はメンバwfile
に書き込み、フォームから送信されたデータはメンバrfile
から読み込む。ただし、送信されたデータの読み込みにはContent-Length
ヘッダから取得した値の指定が必要で、別途ヘッダを読み込んでおく必要がある。同ヘッダがない場合はレスポンス411
を返す。
HTTPヘッダの扱い
クライアントから送信されたヘッダの読み込みにはメンバheaders
のメンバ関数を用い
- Python 2では
getheader()
- Python 3では
get()
を呼び出す。引数にはヘッダ名の文字列を渡す。
HTTPクライアントに渡すヘッダはメンバ関数send_header()
によって処理し、end_headers()
でヘッダ送信を完了する。レスポンスコードを送信するにはsend_response()
を呼び出す。
Basic認証への対応
Authorization
ヘッダがクライアントから送信されなかった場合: レスポンス401
を返し、かつヘッダWWW-Authenticate
にBasic realm="[文字列]"
を指定してクライアントに渡す- 同ヘッダが送信された場合: ユーザ名とパスワードを
:
で挟んで組み合わせてascii
でエンコードしたものをcodecs.encode()
でbase64
にエンコードかつrstrip()
などで末尾の改行を取り除き、文字列Basic
と半角スペース1つの後ろに付けたものとヘッダとして渡されたものとを比較(ascii
でのデコードが必要なところがある)し、一致すればレスポンス200
を返しつつ(パス名の処理をした上で)HTMLコードを組み立てて出力し、一致しなければレスポンス401
を返す
とした。
URLエンコードされた文字列の解析
rfile
から読み込んだ内容はURLエンコードされた文字列で、Pythonではparse_qs()
という関数を用いると、名前とそれに対応する値の集まりが辞書として得られる。辞書型の性質上、その名前の項目がなければ例外KeyError
が発生する。
parse_qs()
は、Python 3ではurllib.parse
、Python 2ではurlparse
モジュールからインポートする。
- Python 2.7.15rc1, 3.6.5