2018/08/20

POSTメソッドで送信されるフォームの送信処理を自動化する

Webブラウザでアクセスする形で使える家庭用ルータの設定ページなどにおいて、特定の内容の一連のフォーム送信処理を自動化したい場面に遭遇することがある。

そうしたときにPythonのスクリプトを記述して実行することで、(Webブラウザを用いずに)設定変更を自動化するために役立てることができる。これを応用すると、スクリプトの記述内容や実行のさせ方によっては決まった時間帯に特定の設定項目を変更するような使い方もできる。

本記事で扱う方法では、フォームがPOSTメソッドであっても、WebブラウザでアクセスするページでBasic認証が必要であっても自動でフォーム内容を送信でき、サンプルコードの本体とは別に、Pythonで記述され、Basic認証の必要なHTTPデーモンのコードとその作成メモも扱う。

  1. Pythonでフォームを送信するための準備と方法
    1. フォームの送信に必要な情報の収集
    2. 送信処理
    3. Basic認証のための記述
  2. サンプル
    1. Basic認証に対応したフォーム送信テスト用HTTPD
    2. テスト用HTTPDのフォームを送信する例
    3. 関連:フォーム送信テスト用HTTPDの作成についてのメモ
      1. リクエストハンドラの実装
      2. HTTPヘッダの扱い
      3. Basic認証への対応
      4. URLエンコードされた文字列の解析

Pythonでフォームを送信するための準備と方法

フォームの送信に必要な情報の収集

実際に用途に合わせたスクリプトを記述する際には

  • フォームを送信するURL(form要素のaction属性から決まる)
  • フォームの送信内容(inputselectといった要素の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認証が必要なページにフォームがある場合は以下のようにする。

  1. HTTPPasswordMgrWithDefaultRealmのようなHTTPPasswordMgr系クラスのオブジェクトを作成
  2. add_password()で平文のユーザ名とパスワードを指定して認証のための情報を渡す
  3. 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モジュールからBaseHTTPRequestHandlerHTTPServerのクラスをインポートし、前者のクラスを継承したクラスを定義して

  • 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-AuthenticateBasic 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