ネットショップの価格は、気づかないうちに上下します。今回はrequestsとBeautifulSoup4で練習用の商品ページから商品名と価格を取り出し、取得時刻を添えてSQLiteへ積み上げるCLIツールを作りました。
データベース初期化・複数回の価格取得・履歴一覧・価格変動サマリーという4つのコマンドを、外部通信なしの練習用ページで一通り動かしています。
BeautifulSoup4の基本概念、要件定義、実装、動作確認までを順番に学べる構成です。動画は目次から確認したい場面へ移動でき、本文だけでも手順と考え方が完結します。
動画の内容をテキストで確認する
オープニング。BeautifulSoup4とrequestsとSQLiteを使って価格履歴記録CLIを作るカリキュラムを始めます。概要紹介。
BeautifulSoup4とrequestsとSQLiteの役割と使い方を学ぶ 価格履歴記録CLIの要件を整理する 完成コードと実行結果を確認する 最後に実コマンドとファイル状態で完成挙動を確かめる 具体的にやること。
initコマンドでprice_historyテーブルとインデックスを作成する 初期化後にカラム一覧と現在の履歴件数を表示するfetchコマンドで練習用の商品ページを取得する クラス名pnameとpriceから商品名と価格表示を取り出す 円記号と桁区切りを除いて価格を整数へ変換する 実装環境・必須アプリ。
OS:Windows 11 Pro Python:3.13.3シェル:PowerShell 5.1必須アプリ:コードエディター、ターミナル、エクスプローラー パッケージ:pip、requests、beautifulsoup4、pysqlite3-binary BeautifulSoup4とrequestsとは。
BeautifulSoup4:BeautifulSoup4は、HTMLの文字列を解析してタグや属性から必要な値を取り出せる形へ整えるPythonのHTML解析ライブラリrequests:requestsは、PythonからHTTPリクエストを送り、返ってきた本文やステータスコードを扱えるHTTP通信ライブラリ BeautifulSoup4で作る価格履歴記録CLIの要点。
CREATE TABLE IF NOT EXISTSで再実行しても安全product_idとidの複合インデックスで検索を補助 取得時刻は年月日と時分秒の文字列として保存 価格履歴記録CLIの要件定義。
init実行後にprice_historyのカラム一覧が表示されるinitを二度実行しても既存の履歴件数が保たれるfetch実行で商品名・価格・取得時刻が取得ごとに表示される 取得回数ぶんの保存完了メッセージが表示される 同じ商品を続けて取得すると価格が上下するlist実行で取得時刻・商品ID・商品名・価格の表が古い順に並ぶ INTRO: Monaco Editorで価格履歴記録CLIを実装。
コードを1行ずつ入力し、補完と自動インデントを使いながら実行結果を確認します。LINE 002: モジュールの説明文。このファイル全体の役割を説明するドキュメント文字列です。
商品名と価格を取得し取得時刻付きでSQLiteへ保存するCLIであることを示しています。LINE 004: 型ヒント機能の有効化。将来のPython構文で型注釈を柔軟に書けるようにするための宣言です。
ファイルの先頭で指定することで、型ヒントの評価方法が新しい方式になります。LINE 006: argparseの読み込み。コマンドライン引数を解析するための標準ライブラリを読み込んでいます。
initやfetchなどのサブコマンドを扱う準備をしています。LINE 007: osの読み込み。ファイルパスの操作などを行うための標準ライブラリを読み込んでいます。
後段でデータベースファイルの絶対パスを表示する際に使われます。LINE 008: reの読み込み。正規表現を扱うための標準ライブラリを読み込んでいます。
価格文字列から数字だけを取り出す処理で利用します。LINE 009: sqlite3の読み込み。SQLiteデータベースを操作するための標準ライブラリを読み込んでいます。
価格履歴の保存や読み出しに使われます。LINE 010: timeの読み込み。時間に関する処理を行うための標準ライブラリを読み込んでいます。
連続取得時の待機時間を作る際に使われます。LINE 011: unicodedataの読み込み。文字の種類を判定するための標準ライブラリを読み込んでいます。
全角文字を考慮した表示幅の計算に使われます。LINE 012: datetimeの読み込み。日時を扱うためのクラスを読み込んでいます。
データ取得時刻の文字列を作成する際に利用します。LINE 013: HTTPサーバ関連の読み込み。練習用ページを配信する簡易HTTPサーバを作るためのクラスを読み込んでいます。
リクエストの処理とサーバ本体の両方を扱えるようにしています。LINE 014: スレッドクラスの読み込み。別スレッドで処理を動かすためのクラスを読み込んでいます。
練習用サーバをバックグラウンドで起動するために使われます。LINE 015: URL解析関数の読み込み。URLやクエリ文字列を分解するための関数を読み込んでいます。
リクエストされたパスや取得回数の取り出しに使われます。LINE 017: requestsの読み込み。HTTP通信を行うための外部ライブラリを読み込んでいます。
練習用ページへアクセスして商品情報を取得する際に使われます。LINE 018: BeautifulSoupの読み込み。HTMLを解析するための外部ライブラリを読み込んでいます。
取得したページから商品名や価格の要素を抜き出す際に利用します。LINE 020: 既定のDBファイル名。データベースファイルの既定のパスを定数として定義しています。
コマンド実行時に--dbが指定されなかった場合に使われます。LINE 022: テーブル定義SQLの開始。データベースの構造を定義するSQL文の複数行文字列を開始しています。
テーブルとインデックスの作成内容がこの中にまとめられます。LINE 023: テーブル作成文。price_historyという名前のテーブルをまだ存在しない場合に作成するSQL文です。
価格履歴を保存する器を用意します。LINE 024: ID列の定義。各レコードを一意に識別するid列を定義しています。
整数の主キーとして自動的に連番が振られます。LINE 025: 商品ID列の定義。商品を識別するためのproduct_id列を定義しています。
テキスト型で必須項目として設定されています。LINE 026: 商品名列の定義。取得した商品名を保存するproduct_name列を定義しています。
テキスト型で必須項目として設定されています。LINE 027: 価格列の定義。取得した価格を保存するprice列を定義しています。
整数型で必須項目として設定されています。LINE 028: 取得元URL列の定義。商品情報を取得したページのURLを保存するsource_url列を定義しています。
テキスト型で必須項目として設定されています。LINE 029: 取得時刻列の定義。データを取得した日時を保存するfetched_at列を定義しています。
テキスト型で必須項目として設定されています。LINE 030: テーブル定義の終了。price_historyテーブルの列定義を閉じるかっこです。
ここまでの内容でテーブル構造が確定します。LINE 031: インデックス作成文の開始。検索を高速化するためのインデックスをまだ存在しない場合に作成するSQL文の始まりです。
idx_price_history_productという名前で定義されます。LINE 032: インデックス対象列の指定。インデックスの対象をproduct_id列とid列に設定しています。
商品ごとの履歴検索を効率化する役割があります。LINE 033: テーブル定義SQLの終了。SCHEMA_SQLという複数行文字列を閉じています。
ここまでの内容がデータベース初期化時に一括で実行されます。LINE 038: 練習用商品データの定義開始。練習用サーバが返す商品情報をまとめた辞書を定義し始めています。
商品IDをキーにして商品の詳細情報を保持します。LINE 039: コーヒー商品の情報。coffee-001という商品IDに対応する商品名、カテゴリ、基本価格を設定しています。
練習用ページの表示内容として使われます。LINE 040: お茶商品の情報。tea-002という商品IDに対応する商品名、カテゴリ、基本価格を設定しています。
練習用ページの表示内容として使われます。LINE 041: 商品辞書の終了。PRACTICE_PRODUCTSという辞書の定義を閉じています。
これでコーヒーとお茶の2商品分のデータが用意されました。LINE 043: 価格変動パターンの定義。取得回数に応じて基本価格に加算する値のタプルを定義しています。
何度も取得すると価格が決まった順序で上下する仕組みを作ります。RUN 1/9: 練習用商品と価格の増減パターンを確認する。練習用ページで扱う商品IDと基準価格、取得回数ごとに価格を動かす増減パターンを定数として書き終えた状態です。
登録された商品が2件あることと、5段階の増減が用意されていることを表示して確かめます。CHECK 1/9: 途中実行に成功。
練習用商品数: 2件 coffee-001深煎りドリップコーヒー200g基準価格1280 tea-002有機ほうじ茶 ティーバッグ50個 基準価格980 価格の増減パターン: (0, -130, 80, -60, 150) RETURN 01: エディターへ戻りました。入力済みのコードを維持したまま、次の実装へ進みます。
LINE 046: 練習用HTML生成関数の定義。商品IDと取得回数を受け取り、商品ページのHTML文字列を作る関数を定義しています。戻り値は文字列型であることが示されています。
LINE 047: HTML生成関数の説明文。この関数が取得回数に応じて価格が変動するページを組み立てることを説明したドキュメント文字列です。LINE 048: 商品データの取得。
辞書から指定された商品IDに対応する商品情報を取り出しています。以降の処理でこの情報を使ってHTMLを組み立てます。LINE 049: 表示価格の計算。
基本価格に取得回数に応じた変動値を加算して、その回の表示価格を計算しています。剰余演算により変動パターンが繰り返し使われます。LINE 050: HTML文字列の返却開始。
組み立てたHTML文字列を関数の戻り値として返す処理の始まりです。複数の文字列を連結して1つのページを構成しています。LINE 051: HTML宣言の追加。
HTML文書の先頭に必要なdoctype宣言を追加しています。ブラウザに文書形式を伝える役割があります。LINE 052: htmlタグとheadタグの開始。
日本語ページであることと文字コードを指定するhtmlタグとheadタグを組み立てています。LINE 053: タイトルタグの挿入。ページのタイトルに商品名を埋め込んでいます。
ブラウザのタブなどに表示される部分です。LINE 054: 本文とmain要素の開始。ページ本文を開始し、商品情報を囲むmain要素を用意しています。
class名でproductを指定しています。LINE 055: カテゴリの表示。商品のカテゴリ名をp要素として埋め込んでいます。
class名cat属性の要素として後で解析しやすくなっています。LINE 056: 商品名の表示。商品名をh1要素として埋め込んでいます。
class名pnameが付いており、取得プログラム側でこの要素を探して商品名を読み取ります。LINE 057: 価格の表示。計算した価格をカンマ区切りの数値としてp要素に埋め込んでいます。
class名priceが付いており、取得プログラム側で価格を読み取る対象になります。LINE 058: SKU情報の表示。商品IDをSKUとしてp要素に埋め込んでいます。
class名skuが付いています。LINE 059: main要素と本文の終了。main要素とbody要素、html要素を閉じてHTML文書を完成させています。
LINE 060: 文字列連結の終了。複数行の文字列を連結する処理を閉じるかっこです。ここまでの各行が結合されて1つのHTML文字列として返されます。
RUN 2/9: 取得回数ごとに変わる商品ページを組み立てる。build_practice_htmlが返すHTMLから、価格を表示している行だけを抜き出して比べます。取得回数が変わると価格が決まった順序で上下することを、解析処理を書く前に確認できます。
CHECK 2/9: 途中実行に成功。visit 1 <p class="price">¥1,280</p> visit 2 <p class="price">¥1,150</p> visit 3 <p class="price">¥1,360</p> RETURN 02: エディターへ戻りました。入力済みのコードを維持したまま、次の実装へ進みます。
LINE 063: リクエストハンドラクラスの定義。練習用ページへのHTTPリクエストを処理するクラスを定義しています。標準ライブラリのハンドラクラスを継承しています。
LINE 064: ハンドラクラスの説明文。このクラスが商品IDと取得回数をURLから受け取り練習用ページを返すことを説明したドキュメント文字列です。LINE 066: GETリクエスト処理の定義。
ブラウザやプログラムからのGETリクエストを受け取ったときに実行される処理を定義しています。LINE 067: リクエストURLの解析。リクエストされたパス文字列を解析し、パス部分やクエリ部分に分解しています。
以降の処理でこの結果を利用します。LINE 068: 商品IDの取り出し。URLパスの末尾部分を商品IDとして取り出しています。
スラッシュで区切られた最後の要素を使っています。LINE 069: 取得回数パラメータの取り出し。クエリ文字列からvisitパラメータの値を取り出しています。
指定がない場合は既定値として1が使われます。LINE 070: 取得回数の数値変換。取り出した文字列が正の整数として有効な場合に整数へ変換し、そうでない場合は1を使うようにしています。
不正な値が渡されても安全に処理できるようにする役割があります。LINE 071: 商品IDの存在チェック。指定された商品IDが練習用商品の一覧に含まれているかどうかを確認しています。
LINE 072: 404エラーの応答。存在しない商品IDが指定された場合に404エラーを返しています。商品が見つからないことをクライアントに知らせます。
LINE 073: 処理の終了。404エラーを返した後、以降のページ生成処理を行わずにこの関数を終了しています。LINE 074: HTML本文の生成。
商品IDと取得回数をもとに練習用ページのHTMLを生成し、バイト列にエンコードしています。この後のレスポンス送信で使われます。LINE 075: ステータスコードの送信。
HTTPステータスコード200を送信し、リクエストが正常に処理されたことをクライアントに伝えています。LINE 076: Content-Typeヘッダの送信。レスポンスの内容がHTML文書であり文字コードがUTF-8であることを示すヘッダを送信しています。
LINE 077: Content-Lengthヘッダの送信。送信する本文のバイト数をヘッダとして送信しています。クライアントが本文の長さを把握できるようにしています。
LINE 078: ヘッダ送信の終了。ここまでに設定したヘッダの送信を終了し、以降で本文を送信できる状態にしています。LINE 079: HTML本文の送信。
生成したHTMLのバイト列をクライアントへ実際に書き込んで送信しています。これによりページが表示されます。LINE 081: ログ出力の抑制。
サーバへのアクセスログを標準のコンソールへ出力しないようにするためのメソッドです。何も処理せず終了するようにして、練習実行時の余計な出力を抑えています。LINE 082: ログ出力を無効化する。
この行はサーバーへのアクセスログを画面に表示しないようにするための処理です。何も出力せずに関数を終了させることで、練習用サーバーの動作ログが表示されないようにしています。LINE 085: 練習用サーバー起動関数の定義。
練習用の商品ページを配信するローカルサーバーを起動する関数を定義しています。戻り値としてサーバー本体とアクセス用のベースURLの組を返す関数です。LINE 086: 関数の説明文。
この関数がどのようなポートでサーバーを起動し、何を返すのかを説明したドキュメント文字列です。後から読んだ人が処理内容をすぐ理解できるようにしています。LINE 087: サーバーインスタンスの作成。
127.0.0.1のアドレスとポート番号0を指定して、空いているポートを自動で使うローカルサーバーを作成しています。リクエストの処理はPracticePageHandlerが担当します。LINE 088: 別スレッドでサーバーを起動。
サーバーの待ち受け処理を別スレッドで実行し、メインの処理をブロックしないようにしています。daemon指定によりメインプログラム終了時に自動でスレッドも終了します。LINE 089: ホスト名とポート番号の取得。
起動したサーバーの実際のアドレスから、ホスト名とポート番号を取り出しています。ポート番号は自動割り当てのため、ここで実際の値を確認しています。LINE 090: サーバーとURLを返す。
起動したサーバーオブジェクトと、アクセス先となるベースURLの文字列をまとめて返しています。呼び出し側はこのURLを使って商品ページへリクエストを送ります。RUN 3/9: 練習用サーバを起動してHTMLを受け取る。
空きポートで練習用サーバを立ち上げ、requestsのセッションで商品ページを取得します。ステータスコードと、商品名や価格を含む行が返ることを確かめてからサーバを停止します。CHECK 3/9: 途中実行に成功。
ベースURL: http://127.0.0.1:37339 ステータス: 200 <h1 class="pname">有機ほうじ茶 ティーバッグ50個</h1> <p class="price">¥850</p> RETURN 03: エディターへ戻りました。入力済みのコードを維持したまま、次の実装へ進みます。
LINE 095: データベース接続関数の定義。指定したパスのSQLiteデータベースに接続するための関数を定義しています。他の関数から共通で呼び出せるように処理をまとめています。
LINE 096: SQLiteへ接続。指定されたファイルパスのSQLiteデータベースファイルに接続し、接続オブジェクトを作成しています。ファイルが存在しない場合は新しく作成されます。
LINE 097: 行の取得形式を設定。取得した行を辞書のようにカラム名で参照できるように、row_factoryにsqlite3.Rowを設定しています。これにより後続の処理でrow['列名']という書き方ができるようになります。
LINE 098: 接続オブジェクトを返す。作成したデータベース接続オブジェクトを呼び出し元へ返しています。この接続を使って以降のSQL実行が行われます。
LINE 101: データベース初期化関数の定義。価格履歴テーブルを作成し、テーブル情報と件数を返す関数を定義しています。initコマンドから呼び出される中心的な処理です。
LINE 102: 関数の説明文。この関数がテーブルを作成し、カラム一覧と現在の履歴件数を返すことを説明したドキュメント文字列です。LINE 103: データベースへの接続。
先ほど定義したconnect関数を使って、指定したデータベースファイルへの接続を作成しています。この接続はこの後のSQL実行に使われます。LINE 104: 例外処理の開始。
この後の処理で例外が発生しても、必ず接続を閉じられるようにtryブロックを開始しています。LINE 105: テーブル作成SQLの実行。あらかじめ定義しておいたSCHEMA_SQLを実行し、price_historyテーブルとインデックスを作成しています。
すでに存在する場合は何も変更されません。LINE 106: 変更内容の確定。テーブル作成などの変更内容をデータベースファイルへ確定して保存しています。
commitを呼ばないと変更が反映されない場合があります。LINE 107: カラム一覧の取得。PRAGMA table_infoを使ってprice_historyテーブルのカラム名一覧をリストとして取得しています。
initコマンドの結果表示に使われます。LINE 108: 現在の件数を取得。price_historyテーブルに保存されている行数をCOUNT関数で数え、その値を取得しています。
初期化直後は多くの場合0件になります。LINE 109: 後始末処理の開始。例外が発生した場合でも必ず実行される後始末処理を記述するためのfinallyブロックです。
ここで接続のクローズ処理を行います。LINE 110: データベース接続を閉じる。処理が終わったデータベース接続を閉じて、リソースを解放しています。
LINE 111: 結果をまとめて返す。取得したカラム名の一覧と現在の履歴件数をタプルとして呼び出し元に返しています。RUN 4/9: データベースを初期化してカラムを確認する。
一時ディレクトリにデータベースを作り、price_historyテーブルのカラム一覧と履歴件数を表示します。二度目の初期化でも同じ結果が返るため、繰り返し実行できる作りだと分かります。CHECK 4/9: 途中実行に成功。
カラム: ['id', 'product_id', 'product_name', 'price', 'source_url', 'fetched_at'] 履歴件数: 0件 2回目の初期化: (['id', 'product_id', 'product_name', 'price', 'source_url', 'fetched_at'], 0) RETURN 04: エディターへ戻りました。
入力済みのコードを維持したまま、次の実装へ進みます。LINE 114: スキーマ確認関数の定義。データベースにprice_historyテーブルが存在するかを確認する関数を定義しています。
テーブルが無ければ初期化を促すために使われます。LINE 115: テーブル存在確認クエリの開始。sqlite_masterというシステムテーブルを検索して、price_historyというテーブルが存在するかを調べるSQLの実行を開始しています。
LINE 116: 検索条件のSQL文。テーブルの種類がtableであり、名前がprice_historyであるものを条件に検索するSQL文字列です。LINE 117: 検索結果を1件取得。
条件に一致する行を1件だけ取り出しています。該当するテーブルがなければ結果はNoneになります。LINE 118: 未初期化かどうかの判定。
検索結果がNoneかどうかを確認し、price_historyテーブルが存在するかどうかを判定しています。LINE 119: 未初期化エラーの発生。テーブルが存在しない場合に、初期化コマンドの実行を促すエラーメッセージとともにプログラムを終了させています。
LINE 122: 件数取得関数の定義。指定した商品IDの履歴が何件保存されているかを数える関数を定義しています。fetchコマンドで取得回数を決めるために使われます。
LINE 123: 件数取得クエリの開始。price_historyテーブルから指定した商品IDに一致する行数を数えるSQLの実行を開始しています。LINE 124: 商品IDによる絞り込み条件。
product_idカラムが引数で渡された値と一致する行だけを対象に件数を数える条件を指定しています。LINE 125: 結果を1件取得。件数を数えたクエリの結果を1行だけ取得しています。
LINE 126: 件数を整数に変換して返す。取得した件数の値を整数に変換し、呼び出し元へ返しています。LINE 131: 価格文字列変換関数の定義。
「¥1,280」のような表示用文字列から数値としての価格を取り出す関数を定義しています。LINE 132: 関数の説明文。この関数がどのような文字列を数値に変換するのかを説明したドキュメント文字列です。
LINE 133: 数字以外の文字を除去。正規表現を使って文字列から数字以外の文字をすべて取り除き、数字だけの文字列にしています。円マークやカンマがここで取り除かれます。
LINE 134: 数字が取れたかの確認。取り除いた結果、数字が1つも残っていないかどうかを確認しています。LINE 135: 変換失敗時のエラー。
数字が取り出せなかった場合に、元の文字列を含めたエラーメッセージとともに例外を発生させています。LINE 136: 文字列を整数へ変換。数字だけになった文字列を整数型に変換し、価格として呼び出し元へ返しています。
RUN 5/9: 価格の表記を整数へ変換する。円記号と桁区切りを含む文字列から数字だけを取り出せるかを確かめます。数字が1文字も含まれない場合にValueErrorが送出されることも合わせて確認します。
CHECK 5/9: 途中実行に成功。¥1,280 -> 1280 ¥980 -> 980 1,360円 -> 1360 例外: 価格を数値に変換できません: '価格未定' RETURN 05: エディターへ戻りました。入力済みのコードを維持したまま、次の実装へ進みます。
LINE 139: 商品情報取得関数の定義。指定したURLへアクセスして商品名と価格を取得する関数を定義しています。requestsとBeautifulSoupを組み合わせて処理を行います。
LINE 140: 商品ページへのリクエスト送信。指定したURLに対してGETリクエストを送信し、レスポンスを取得しています。指定した秒数を超えると通信をタイムアウトさせます。
LINE 141: エラー応答のチェック。レスポンスのステータスコードがエラーを示す場合に、例外を発生させて処理を止めています。LINE 142: HTMLの解析準備。
取得したHTML文字列をBeautifulSoupで解析し、要素を検索できる形に変換しています。LINE 143: 商品名要素の取得。class属性がpnameである要素を1つ検索し、商品名が書かれた部分を取得しています。
LINE 144: 価格要素の取得。class属性がpriceである要素を1つ検索し、価格が書かれた部分を取得しています。LINE 145: 要素が見つかったかの確認。
商品名または価格の要素が見つからなかった場合を判定するための条件です。LINE 146: 要素不足時のエラー。商品名や価格の要素が見つからなかった場合に、対象URLを含めたエラーメッセージとともに例外を発生させています。
LINE 147: 価格表示文字列の取得。価格要素から表示用の文字列テキストを取り出しています。「¥1,280」のような形式のまま保持しています。
LINE 148: 結果辞書の作成開始。商品名や価格などの情報をまとめた辞書を作成し、返す準備をしています。LINE 149: 商品名を辞書に格納。
商品名要素から取り出したテキストを辞書のnameキーに格納しています。LINE 150: 価格を数値に変換して格納。先ほど定義したparse_price関数を使って価格文字列を整数に変換し、priceキーに格納しています。
LINE 151: 元の価格表示文字列を格納。画面に表示されていた元の価格文字列を、そのままprice_textキーに格納しています。LINE 152: 辞書の作成を終える。
商品名、価格、価格表示文字列をまとめた辞書のリテラルを閉じ、呼び出し元へ返します。RUN 6/9: 商品名と価格をHTMLから取り出す。練習用サーバを起動し、取得回数を変えながらfetch_productを呼び出します。
商品名、整数に変換した価格、ページ表示のままの価格文字列が辞書で返ることを確認できます。CHECK 6/9: 途中実行に成功。
1回目: 深煎りドリップコーヒー200g 1280 ¥1,280 2回目: 深煎りドリップコーヒー200g 1150 ¥1,150 3回目: 深煎りドリップコーヒー200g 1360 ¥1,360 RETURN 06: エディターへ戻りました。入力済みのコードを維持したまま、次の実装へ進みます。LINE 157: 履歴保存関数の定義開始。
取得した商品情報を価格履歴として保存する関数の定義を開始しています。データベース接続や各種項目を引数として受け取ります。LINE 158: データベース接続の引数。
保存処理に使うデータベース接続オブジェクトを受け取る引数を定義しています。LINE 159: 商品IDの引数。保存対象の商品を識別するための商品IDを受け取る引数を定義しています。
LINE 160: 商品名の引数。取得した商品名の文字列を受け取る引数を定義しています。LINE 161: 価格の引数。
取得した価格の整数値を受け取る引数を定義しています。LINE 162: 取得元URLの引数。商品情報を取得した際のURLを受け取る引数を定義しています。
LINE 163: 取得時刻の引数。商品情報を取得した時刻の文字列を受け取る引数を定義しています。LINE 164: 戻り値の型指定。
この関数が値を返さないことを示す型注釈とともに、引数定義を締めくくっています。LINE 165: INSERT文の実行開始。受け取った情報を価格履歴テーブルへ挿入するためのSQL実行を開始しています。
LINE 166: 挿入先テーブルの指定。挿入先のテーブル名としてprice_historyを指定するSQL文の一部です。LINE 167: 挿入するカラムの指定。
商品ID、商品名、価格、取得元URL、取得時刻を挿入対象のカラムとして指定するSQL文の一部です。LINE 168: INSERT文の値プレースホルダ。5つのカラムに対応する5つのプレースホルダを指定している行です。
実際の値は次の行で渡され、SQLインジェクションを防ぐ安全な書き方になっています。LINE 169: バインドするパラメータのタプル。商品ID、商品名、価格、取得元URL、取得時刻の5つの値をタプルとしてまとめています。
前の行のプレースホルダに順番どおり対応づけられます。LINE 170: execute呼び出しの終わり。conn.executeの呼び出しを閉じる括弧です。
ここまでの内容でINSERT文が組み立てられ、実行されます。LINE 171: 変更内容の確定。commitを呼び出して、直前のINSERT処理をデータベースファイルへ確定保存しています。
これを行わないと変更が保存されません。LINE 176: 履歴取得関数の定義。データベースパスと表示件数を受け取り、保存済みの価格履歴を新しい順で取得する関数を定義しています。
戻り値はSQLiteの行データのリストです。LINE 177: データベースへの接続。指定されたデータベースファイルに接続し、以降の処理で使う接続オブジェクトを取得しています。
LINE 178: try文の開始。データベース接続を使った処理をtry内に置き、処理後に必ず接続をクローズできるようにしています。LINE 179: テーブル存在チェック。
price_historyテーブルが作成済みかどうかを確認しています。未初期化の場合はここで例外が発生し、処理が中断されます。LINE 180: 履歴取得SQLの実行開始。
価格履歴を取得するSELECT文の実行を開始している行です。取得結果は後でfetchallによってまとめて受け取ります。LINE 181: 取得するカラムの指定。
商品ID、商品名、価格、取得時刻の4つのカラムを取得対象として指定しています。表示に必要な項目だけを絞り込んでいます。LINE 182: 並び順と件数上限の指定。
id列の降順、つまり新しい記録から順に並べ、LIMITで取得件数の上限を指定しています。LINE 183: LIMITへのパラメータ渡し。呼び出し時に指定されたlimit値をLIMIT句のプレースホルダにバインドしています。
LINE 184: 結果をすべて取得。fetchallを呼び出して、SELECT文の実行結果をすべてリストとして受け取っています。LINE 185: finally文の開始。
try内の処理が正常終了しても例外が発生しても、必ず実行される後処理をここから記述しています。LINE 186: データベース接続のクローズ。使い終わったデータベース接続を閉じて、リソースを解放しています。
LINE 187: 表示順を古い順に反転して返却。新しい順で取得した結果をreversedで反転し、古い順のリストにして呼び出し元へ返しています。RUN 7/9: 保存した履歴を古い順に読み出す。
初期化したデータベースへ価格を3件保存し、load_historyで読み出します。並び順が古い順になっていること、取得時刻と価格が保存したとおりに残っていることを確かめます。CHECK 7/9: 途中実行に成功。
2026-08-14 10:01:00 coffee-001 1280 2026-08-14 10:02:00 coffee-001 1150 2026-08-14 10:03:00 coffee-001 1360 RETURN 07: エディターへ戻りました。入力済みのコードを維持したまま、次の実装へ進みます。LINE 192: 要約関数の定義。
データベースパスを受け取り、商品ごとの価格変動を要約した辞書のリストを作成する関数を定義しています。LINE 193: データベースへの接続。要約処理のためにデータベースファイルへ接続し、接続オブジェクトを取得しています。
LINE 194: try文の開始。以降の集計処理をtryブロック内で行い、最後に確実に接続を閉じられるようにしています。LINE 195: テーブル存在チェック。
price_historyテーブルが存在するかを確認し、未初期化のデータベースであれば例外を発生させています。LINE 196: 商品ごとの集計SQL実行開始。商品ごとに件数や価格の統計値を集計するSELECT文の実行を開始しています。
LINE 197: 件数と最小・最大価格の集計項目。商品IDと商品名ごとにレコード件数、最安値、最高値を集計する列を指定しています。LINE 198: 平均価格の集計項目。
AVGで平均価格を求め、ROUNDで四捨五入して扱いやすい整数に近い値にしています。LINE 199: 初回・最新の取得時刻の集計項目。MINとMAXを使って、その商品を最初に取得した時刻と最後に取得した時刻を求めています。
LINE 200: 集計対象テーブルとグルーピング指定。price_historyテーブルを商品IDと商品名でグルーピングし、商品ID順に並べて集計結果を出す指定です。LINE 201: 集計結果の全件取得。
組み立てたSELECT文をfetchallで実行し、商品ごとの集計結果をすべて取得しています。LINE 202: 要約結果を格納するリストの準備。これから作成する商品ごとの要約辞書を格納するための空リストを用意しています。
LINE 203: 商品ごとのループ開始。先ほど取得した商品ごとの集計行を1件ずつ処理するためのforループを開始しています。LINE 204: 価格履歴の全件取得開始。
現在処理中の商品について、記録順(id順)で価格の値だけをすべて取得する処理を開始しています。LINE 205: 価格を整数に変換。取得した各行の価格値をint型に変換し、後の計算で扱いやすくしています。
LINE 206: 内包表記でのSELECT実行。商品IDを条件に価格履歴をid順で取得するSELECT文を実行し、その結果を1件ずつ取り出しています。LINE 207: 商品IDをパラメータに渡す。
現在処理中の商品のIDをWHERE条件のプレースホルダにバインドしています。LINE 208: リスト内包表記の終わり。価格を整数のリストとして組み立てるリスト内包表記を閉じている行です。
LINE 209: prices変数への代入。この商品の全価格を記録順に並べたリストをprices変数として確定させています。LINE 210: 初回価格と最新価格の取り出し。
pricesリストの先頭を初回価格、末尾を最新価格として変数に取り出しています。LINE 211: 価格差の計算。最新価格から初回価格を引くことで、価格の増減額をdiffとして求めています。
LINE 212: 要約辞書の追加開始。この商品についての要約情報をまとめた辞書を、summariesリストへ追加する処理を開始しています。LINE 213: 商品IDの格納。
集計行から商品IDを取り出して、要約辞書のproduct_idキーに設定しています。LINE 214: 商品名の格納。集計行から商品名を取り出して、要約辞書のproduct_nameキーに設定しています。
LINE 215: 記録件数の格納。集計されたレコード件数を整数に変換し、要約辞書のrecordsキーに設定しています。LINE 216: 初回取得時刻の格納。
この商品を最初に記録した時刻を要約辞書のfirst_atキーに設定しています。LINE 217: 最新取得時刻の格納。この商品を最後に記録した時刻を要約辞書のlast_atキーに設定しています。
LINE 218: 初回価格の格納。先に取り出したfirst_price変数の値を要約辞書のfirst_priceキーに設定しています。LINE 219: 最新価格の格納。
先に取り出したlast_price変数の値を要約辞書のlast_priceキーに設定しています。LINE 220: 直前価格の格納。記録が2件以上ある場合は末尾から2番目の価格を直前価格として設定し、1件しかない場合はNoneを設定しています。
LINE 221: 最安値の格納。集計行から取得した最安値を整数に変換し、要約辞書のmin_priceキーに設定しています。LINE 222: 最高値の格納。
集計行から取得した最高値を整数に変換し、要約辞書のmax_priceキーに設定しています。LINE 223: 平均価格の格納。集計行から取得した平均価格を整数に変換し、要約辞書のavg_priceキーに設定しています。
LINE 224: 価格差の格納。先に計算したdiff変数の値を要約辞書のdiffキーに設定しています。LINE 225: 変化率の計算と格納。
初回価格が0でない場合に、価格差を初回価格で割ってパーセントに変換したrate値を設定し、0円の場合は0.0を設定しています。LINE 226: 辞書リテラルの終わり。この商品の要約情報をまとめた辞書の記述を閉じている行です。
LINE 227: append呼び出しの終わり。作成した要約辞書をsummariesリストへ追加するappend呼び出しを閉じている行です。LINE 228: finally文の開始。
forループの処理が終わった後、例外の有無にかかわらず必ず実行される後処理の開始位置です。LINE 229: データベース接続のクローズ。要約処理で使ったデータベース接続を閉じて、リソースを解放しています。
LINE 230: 要約結果の返却。作成した商品ごとの要約情報のリストを、呼び出し元へ戻り値として返しています。LINE 231: 表示幅計算関数の定義。
文字列を受け取り、その見た目の表示幅を計算する関数を定義しています。全角文字と半角文字の幅の違いを扱うために使われます。LINE 232: 全角・半角を考慮した幅の合計。
各文字がWまたはFの区分であれば幅2、それ以外は幅1として合計し、文字列全体の表示幅を求めています。LINE 233: 文字列パディング関数の定義。文字列と目標の幅を受け取り、右側に空白を追加して指定幅に揃える関数を定義しています。
RUN 8/9: 価格変動サマリーの集計結果を確かめる。3件の履歴から、記録件数、初回価格と最新価格、差額、最安値、最高値、平均価格を計算します。SQLの集計関数と初回・最新の比較が意図どおり動いているかを表示前に確認できます。
CHECK 8/9: 途中実行に成功。深煎りドリップコーヒー200g 3件 初回1280最新1360差80 最安1150最高1360平均1263 RETURN 08: エディターへ戻りました。入力済みのコードを維持したまま、次の実装へ進みます。
LINE 238: 必要な空白数の計算と付加。目標幅から実際の表示幅を引いた分だけ半角スペースを文字列の後ろに追加し、幅が足りている場合は追加しないようにしています。LINE 239: initコマンド処理関数の定義。
initサブコマンドが実行されたときの処理をまとめた関数を定義しています。データベースの初期化とその結果表示を行います。LINE 242: 幅計算関数の本体定義。
文字列と幅を引き取り、display_widthで表示幅を揃える処理を定義しています。この関数定義行自体は処理を行わず、名前と引き取り値を決めています。LINE 243: データベース初期化の実行。
init_database関数を呼び出してテーブルを作成し、返されたカラム一覧と履歴件数を変数に受け取っています。LINE 248: init処理関数の定義。CLIでinitコマンドが選ばれたときに呼び出される処理をここで定義しています。
引き取ったパリス情報を元にデータベースを初期化します。LINE 249: データベース初期化の実行と結果取得。args.dbで指定されたパスを使ってinit_database関数を呼び出し、テーブルのカラム一覧と現在の履歴件数を変数に受け取っています。
LINE 250: 初期化完了メッセージ。データベースファイルの絶対パスを表示して、初期化が完了したことを利用者に伝えます。os.path.abspathを使うことで、相対パスで指定していても正しい保存先が分かるようにしています。
LINE 251: テーブル情報の表示。init_databaseから受け取ったカラム名の一覧をカンマ区切りの文字列にして表示します。テーブルにどんな列が作られたかを一目で確認できるようにしています。
LINE 252: 現在の件数表示。現時点でprice_historyテーブルに何件のデータが保存されているかを表示します。初期化直後であれば通常は0件になります。
LINE 255: fetchコマンドの定義開始。fetchコマンドが実行されたときに呼び出される関数です。この中で練習用サーバーの起動から価格取得、保存までの一連の処理を行います。
LINE 256: 練習用サーバーの起動。商品ページを配信するローカルサーバーを起動し、サーバーオブジェクトとアクセス先のベースURLを受け取ります。以降のリクエストはこのURLを使って行います。
LINE 257: データベース接続の作成。引数で指定されたデータベースファイルに接続します。この接続を使って後続の処理でデータの読み書きを行います。
LINE 258: HTTPセッションの作成。requestsライブラリのセッションを作成し、複数回のリクエストで接続を再利用できるようにします。LINE 259: 環境変数の無視設定。
OSのプロキシ設定などの環境変数を無視するように設定し、練習用サーバーへのアクセスが外部設定の影響を受けないようにします。LINE 260: 例外処理の開始。この後の処理で例外が発生しても、必ず後片付けの処理を実行できるようにtryブロックを開始しています。
LINE 261: テーブル存在チェック。データベースにprice_historyテーブルが作られているかを確認します。未初期化であればエラーを出して処理を止めます。
LINE 262: 取得回数のループ開始。引数で指定した回数だけ商品情報を取得するために繰り返し処理を開始します。iには0から始まる取得回数のインデックスが入ります。
LINE 263: 訪問回数の計算。すでに保存されている該当商品の履歴件数に1を加えて、今回が何回目の取得かを求めます。この値が練習用ページの価格の変動パターンに使われます。
LINE 264: 取得先URLの作成。ベースURL、商品ID、訪問回数を組み合わせて、今回アクセスする商品ページのURLを作成します。LINE 265: 商品情報の取得。
作成したURLへアクセスし、商品名と価格をHTMLから抜き出した結果を受け取ります。LINE 266: 取得時刻の記録。現在の日時を「年-月-日 時:分:秒」の形式の文字列に変換し、取得した瞬間の時刻として記録します。
LINE 267: 履歴の保存。取得した商品名、価格、アクセス先URL、取得時刻をデータベースに1件の履歴として保存します。LINE 268: 取得回数の表示。
何回目の取得で、どのURLへアクセスしたかを画面に表示します。LINE 269: 商品名の表示。取得できた商品名を画面に表示し、正しく情報が取得できているかを確認できるようにします。
LINE 270: 価格情報の表示。数値に変換した価格と、ページ上に表示されていた元の価格表記を並べて表示し、変換結果が正しいかを確認できるようにします。LINE 271: 取得時刻の表示。
先ほど記録した取得時刻を画面に表示し、いつ取得したデータかを分かりやすくします。LINE 272: 最終回かどうかの判定。今回の取得が最後の1回でないかを判定し、まだ続きがある場合だけ次の処理に進みます。
LINE 273: 取得間隔の待機。次の取得を行うまでに、指定した秒数だけ処理を一時停止します。連続アクセスの間隔を空けるための処理です。
LINE 274: 後片付けブロックの開始。例外が起きても起きなくても必ず実行される後片付け処理の開始位置です。接続やサーバーの終了処理をまとめて行います。
LINE 275: HTTPセッションの終了。使い終わったリクエスト用セッションを閉じて、リソースを解放します。LINE 276: データベース接続の終了。
使い終わったデータベース接続を閉じて、ファイルへのロックなどを解放します。LINE 277: 練習用サーバーの停止指示。起動していたローカルサーバーに対して停止するよう指示を出します。
LINE 278: サーバーソケットの解放。サーバーが使用していたネットワークのソケットを閉じて、使用していたポートを解放します。LINE 279: 保存完了メッセージ。
何件の価格履歴を、どのデータベースファイルに保存したかをまとめて画面に表示します。LINE 282: listコマンドの定義開始。listコマンドが実行されたときに呼び出される関数です。
保存済みの価格履歴を一覧表示する処理を行います。LINE 283: 履歴データの取得。データベースから指定した件数分の価格履歴を読み込み、古い順に並べたリストとして受け取ります。
LINE 284: 履歴が空か判定。取得した履歴が1件も無かった場合の条件を判定します。LINE 285: 履歴なしの案内。
履歴がまだ保存されていないことと、fetchコマンドを実行するよう案内するメッセージを表示します。LINE 286: 処理の早期終了。表示する履歴が無いため、この時点で関数の処理を終了します。
LINE 287: 見出し行の作成。取得時刻、商品ID、商品名、価格の見出しを、桁を揃えて1行の文字列として組み立てます。LINE 288: 見出しの表示。
組み立てた見出し行を画面に表示し、この下に続く一覧データの列名を示します。LINE 289: 区切り線の表示。見出しの表示幅に合わせた長さのハイフンの線を表示し、見出しとデータ部分を視覚的に分けます。
LINE 290: 履歴の繰り返し表示開始。取得した履歴を1件ずつ処理するための繰り返し処理を開始します。LINE 291: 表示行の組み立て開始。
1件分の履歴データを表示するための文字列の組み立てを開始します。LINE 292: 取得時刻の整形。取得時刻を指定した幅で左詰めに整形し、他の項目と桁が揃うようにします。
LINE 293: 商品IDの整形。商品IDを指定した幅で左詰めに整形し、表示の列が揃うようにします。LINE 294: 商品名の整形。
商品名を指定した幅で左詰めに整形し、全角文字を含んでいても列がずれないようにします。LINE 295: 価格の整形。価格に3桁ごとの桁区切りとカンマを付け、¥記号と合わせて表示用の文字列を作ります。
LINE 296: 表示行の確定。取得時刻、商品ID、商品名、価格を組み合わせた1行分の文字列の組み立てを完了します。LINE 297: 1件分の表示。
組み立てた1件分の履歴の行を画面に表示します。LINE 298: 表示件数の案内。古い順に何件表示したかをまとめて画面に表示し、一覧の終わりを分かりやすくします。
LINE 301: summaryコマンドの定義開始。summaryコマンドが実行されたときに呼び出される関数です。商品ごとの価格変動をまとめて表示する処理を行います。
LINE 302: 要約データの取得。データベースから商品ごとの価格変動をまとめた要約情報のリストを取得します。LINE 303: 要約データの有無判定。
要約できる履歴データが1件も無かった場合の条件を判定します。LINE 304: 要約不可の案内。要約を作成できるだけの履歴が無いことを画面に表示します。
LINE 305: 処理の早期終了。表示する要約が無いため、この時点で関数の処理を終了します。LINE 306: タイトルの表示。
これから価格変動サマリーを表示することを示す見出しを画面に表示します。LINE 307: 商品ごとの繰り返し開始。要約情報を商品ごとに1件ずつ処理するための繰り返し処理を開始します。
LINE 308: 変動方向の判定。最新価格と初回価格の差から、価格が上昇したか下落したか横ばいかを文字列として決定します。LINE 309: 商品名の表示。
商品名と商品IDを合わせて、これから表示するのがどの商品の情報かを示します。LINE 310: 記録件数と期間の表示。その商品の記録件数と、最初の取得から最新の取得までの期間を画面に表示します。
LINE 311: 変動内容の表示開始。初回価格と最新価格を比較した変動内容を表示するための文字列の組み立てを開始します。LINE 312: 価格比較の表示。
初回価格と最新価格を並べて表示し、その差額を分かりやすく示します。LINE 313: 変動率の表示。上昇・下落・横ばいの方向と、差額および変動率をパーセントで合わせて表示します。
LINE 314: 統計表示の組み立て開始。最安値・最高値・平均価格を表示するための文字列の組み立てを開始します。LINE 315: 最安値と最高値の表示。
これまでの記録の中で最も安かった価格と最も高かった価格を表示します。LINE 316: 平均価格の表示。これまでの記録から算出した平均価格を表示し、統計情報の表示を完成させます。
LINE 317: 統計情報の出力完了。最安値・最高値・平均価格をまとめた行の組み立てが完了する部分です。LINE 318: 直近変化の判定。
直前の価格が存在するかどうかを判定し、存在する場合だけ直近の価格変化を表示する処理へ進みます。LINE 319: 直近変化の判定条件。1つ前の取得の価格データが存在するかを確認し、存在する場合のみ直近の変化を表示する条件を判定します。
LINE 320: 直近の価格差を計算。最新価格から一つ前の価格を引いて、直近の変化量をstepという変数に求めています。この値は次の行で表示する変化幅の元になります。
LINE 321: 直近の変化を表示。一つ前の価格と最新価格、その差額をまとめて1行で表示しています。差額には符号とカンマ区切りをつけて、増減が分かりやすいようにしています。
LINE 322: 商品ごとの区切り改行。引数を渡さずにprintを呼び出すことで、空行を出力しています。これにより商品ごとのサマリー表示の間に見やすい区切りができます。
LINE 327: パーサー構築関数の定義。コマンドライン引数を解析するためのargparseの設定をまとめて行う関数を定義しています。ここで作られたパーサーはmain関数から呼び出されて使われます。
LINE 328: ArgumentParserの生成開始。argparse.ArgumentParserのインスタンスを作り始めています。このオブジェクトがコマンドライン全体の解析ルールを管理します。
LINE 329: プログラム名の指定。ヘルプ表示などで使われるプログラム名をprice_trackerに設定しています。ユーザーがコマンドを間違えたときの案内にもこの名前が使われます。
LINE 330: ツール全体の説明文。このツール全体が何を行うかを説明する文章を設定しています。helpオプションを実行した際にこの説明が表示されます。
LINE 331: ArgumentParser生成の完了。progとdescriptionを指定したArgumentParserの作成を閉じる括弧です。これでツール全体の基本設定が完了し、以降はサブコマンドの追加に進みます。
LINE 332: サブコマンドの追加準備。init・fetch・list・summaryのような複数のサブコマンドを扱うための仕組みを追加しています。requiredをTrueにすることでサブコマンドの指定を必須にしています。
LINE 334: initサブコマンドの追加。データベースを初期化するためのinitコマンドをサブコマンドとして登録しています。helpにはこのコマンドの役割を説明する文章を渡しています。
LINE 335: initの--dbオプション定義。initコマンドに対してデータベースファイルのパスを指定できる--dbオプションを追加しています。指定がない場合はDEFAULT_DBが使われます。
LINE 336: init実行関数の紐づけ。initコマンドが選択されたときに呼び出す処理としてcmd_init関数を設定しています。これにより実行時にargs.funcからこの関数が呼ばれます。
LINE 338: fetchサブコマンドの追加。商品ページから商品名と価格を取得して保存するfetchコマンドを登録しています。helpにはコマンドの目的を説明する文章を渡しています。
LINE 339: fetchの--dbオプション定義。fetchコマンドでも保存先のデータベースファイルを指定できるように--dbオプションを追加しています。指定を省略した場合はDEFAULT_DBが使われます。
LINE 340: --productオプションの追加開始。取得対象の商品を指定する--productオプションの定義を始めています。複数行にわたって細かい設定を続けて記述しています。
LINE 341: 商品ID指定の詳細設定。--productオプションを必須にし、選択肢をPRACTICE_PRODUCTSに登録された商品IDに限定しています。存在しない商品IDを指定した場合はエラーになります。
LINE 342: --productオプション定義の終了。複数行にわたって記述していた--productオプションの追加処理を閉じる括弧です。これで商品IDを指定するオプションの設定が完了します。
LINE 343: --timesオプションの定義。連続して取得する回数を指定する--timesオプションを追加しています。型は整数で、指定しなかった場合のデフォルト値は1回です。
LINE 344: --intervalオプションの定義。取得を繰り返す際の待機時間を指定する--intervalオプションを追加しています。型は浮動小数点数で、デフォルトでは1.0秒間隔になります。
LINE 345: fetch実行関数の紐づけ。fetchコマンドが選択されたときに呼び出す処理としてcmd_fetch関数を設定しています。これによりコマンド実行時にこの関数へ引数が渡されます。
LINE 347: listサブコマンドの追加。保存済みの価格履歴を一覧表示するlistコマンドを登録しています。helpにはこのコマンドの目的が説明されています。
LINE 348: listの--dbオプション定義。listコマンドで参照するデータベースファイルのパスを指定できる--dbオプションを追加しています。省略時はDEFAULT_DBが使われます。
LINE 349: --limitオプションの定義。一覧表示する最大件数を指定する--limitオプションを追加しています。型は整数で、指定がない場合は20件が表示されます。
LINE 350: list実行関数の紐づけ。listコマンドが選択されたときに呼び出す処理としてcmd_list関数を設定しています。これによりコマンド実行時にこの関数が呼ばれます。
LINE 352: summaryサブコマンドの追加。価格変動を要約して表示するsummaryコマンドを登録しています。helpにはこのコマンドの目的を説明する文章が設定されています。
LINE 353: summaryの--dbオプション定義。summaryコマンドで参照するデータベースファイルのパスを指定できる--dbオプションを追加しています。省略時はDEFAULT_DBが使われます。
LINE 354: summary実行関数の紐づけ。summaryコマンドが選択されたときに呼び出す処理としてcmd_summary関数を設定しています。これによりコマンド実行時にこの関数が呼ばれます。
LINE 356: 構築したパーサーの返却。ここまで設定してきたパーサーオブジェクトを呼び出し元に返しています。この戻り値がmain関数でコマンドライン引数の解析に使われます。
RUN 9/9: サブコマンドを組み立ててCLIとして動かす。build_parserが返すパーサーで引数を解釈し、initからfetch、listまで続けて実行します。--dbや --timesなどのオプションが各コマンドへ正しく渡っていることを確認できます。
CHECK 9/9: 途中実行に成功。
, product_name, price, source_url, fetched_at) 現在の履歴件数: 0件 取得回数: 2間隔: 0.0 [1/2] GET http://127.0.0.1:43311/product/tea-002?visit=1 商品名 : 有機ほうじ茶 ティーバッグ50個 価格 : ¥980 (ページ表示: ¥980) 取得時刻: 2026-08-14 08:49:55 [2/2] GET http://127.0.0.1:43311/product/tea-002?visit=2 商品名 : 有機ほうじ茶 ティーバッグ50個 価格 : ¥850 (ページ表示: ¥850) 取得時刻: 2026-08-14 08:49:55 2件の価格履歴を /tmp/tmpas4dg8bb/cli.dbへ保存しました。
取得時刻 商品ID商品名 価格 ----------------------------------------------------------------------- 2026-08-14 08:49:55 tea-002有機ほうじ茶 ティーバッグ50個 ¥980 2026-08-14 08:49:55 tea-002有機ほうじ茶 ティーバッグ50個 ¥850
古い順に2件を表示しました。RETURN 09: エディターへ戻りました。入力済みのコードを維持したまま、次の実装へ進みます。
LINE 361: main関数の定義。プログラム全体の実行開始点となるmain関数を定義しています。この関数の中でコマンドライン引数の解析と各コマンドの実行が行われます。
LINE 362: 引数の解析実行。build_parser関数でパーサーを作成し、parse_argsでコマンドライン引数を解析しています。解析結果はargsという変数に格納されます。
LINE 363: 選択コマンドの実行。解析結果に紐づいているfunc属性を呼び出し、args自体を引数として渡しています。これにより指定されたサブコマンドに対応する処理関数が実際に実行されます。
LINE 366: スクリプト直接実行の判定。このファイルがモジュールとしてインポートされたのではなく、直接実行されたかどうかを判定しています。条件が真の場合のみ次の行の処理が行われます。
LINE 367: main関数の呼び出し。直接実行されたと判定された場合にmain関数を呼び出しています。これによりコマンドライン引数の解析と各コマンドの実行が開始されます。
実行1/4: データベースを初期化する。一時ディレクトリを作業場所にしてinitコマンドを実行します。データベースの場所とprice_historyテーブルのカラム構成が表示されることを確認し、最後に一時ディレクトリを片付けます。
確認1/4: データベースを初期化する。一時ディレクトリを作業場所にしてinitコマンドを実行します。データベースの場所とprice_historyテーブルのカラム構成が表示されることを確認し、最後に一時ディレクトリを片付けます。
RETURN 10: エディターへ戻りました。入力済みのコードを維持したまま、次の実装へ進みます。実行2/4: 練習用の商品ページから価格を3回取得する。
初期化に続けてfetchコマンドを実行し、コーヒーの商品ページを3回取得します。取得ごとの商品名、価格、取得時刻と、保存完了の案内が並ぶことを確認し、最後に一時ディレクトリを片付けます。確認2/4: 練習用の商品ページから価格を3回取得する。
初期化に続けてfetchコマンドを実行し、コーヒーの商品ページを3回取得します。取得ごとの商品名、価格、取得時刻と、保存完了の案内が並ぶことを確認し、最後に一時ディレクトリを片付けます。RETURN 11: エディターへ戻りました。
入力済みのコードを維持したまま、次の実装へ進みます。実行3/4: 保存済みの価格履歴を一覧表示する。2種類の商品を取得したうえでlistコマンドを実行します。
取得時刻、商品ID、商品名、価格が古い順に並び、最後に表示件数の案内が出ることを確認し、最後に一時ディレクトリを片付けます。確認3/4: 保存済みの価格履歴を一覧表示する。2種類の商品を取得したうえでlistコマンドを実行します。
取得時刻、商品ID、商品名、価格が古い順に並び、最後に表示件数の案内が出ることを確認し、最後に一時ディレクトリを片付けます。RETURN 12: エディターへ戻りました。入力済みのコードを維持したまま、次の実装へ進みます。
実行4/4: 価格変動サマリーを確認する。同じ商品を3回取得してからsummaryコマンドを実行します。記録件数と期間、初回価格と最新価格の比較、最安値と最高値がまとめて表示されることを確認し、最後に一時ディレクトリを片付けます。
確認4/4: 価格変動サマリーを確認する。同じ商品を3回取得してからsummaryコマンドを実行します。記録件数と期間、初回価格と最新価格の比較、最安値と最高値がまとめて表示されることを確認し、最後に一時ディレクトリを片付けます。
RETURN 13: エディターへ戻りました。入力済みのコードを維持したまま、次の実装へ進みます。学習内容のまとめ。
BeautifulSoup(response.text, "html.parser")で解析器を指定requests.Sessionを1つ作って複数回の取得で共有CREATE TABLE IF NOT EXISTSで再実行しても安全 練習用ページをローカル配信して外部通信を避ける構成 小さく実行確認しながら完成状態まで段階的に組み立てる エンディング。
Python研修はCodeCampでご確認ください。
BeautifulSoup4とrequestsとは
今回使用する主要なライブラリについて、役割と使い分けを順番に確認します。
BeautifulSoup4でHTMLから商品情報を取り出す
BeautifulSoup4は、HTMLの文字列を解析してタグや属性から必要な値を取り出せる形へ整えるPythonのHTML解析ライブラリです。今回のツールでは商品ページのHTMLをhtml.parserで読み込み、クラス名を手がかりに商品名と価格表示を抜き出しています。文字列検索だけでタグを追いかけると、改行や入れ子の違いで壊れやすくなりがちでしょう。
CSSセレクタでどこを見るかを宣言できるため、ページ構造が変わったときにも直す場所が分かりやすくなります。
価格履歴記録CLIの中でBeautifulSoup4に任せている処理を、コード上の呼び出し順に挙げます
- BeautifulSoup(response.text, "html.parser")で解析器を指定
- select_one(".pname")で商品名の見出しだけを取得
- select_one(".price")で価格表示の段落だけを取得
- get_text(strip=True)で前後の空白を除いた文字列を取得
- 要素が見つからない場合にValueErrorを送出
- 追加インストールが不要なhtml.parserの採用
requestsで練習用ページへHTTPリクエストを送る
requestsは、PythonからHTTPリクエストを送り、返ってきた本文やステータスコードを扱えるHTTP通信ライブラリです。今回のツールは自分自身がローカルで立ち上げた練習用サーバへアクセスするため、外部サイトに負荷をかけずに取得処理を試せます。セッションを1つ作って使い回すと、接続の準備を毎回やり直さずに済むでしょう。
タイムアウトとステータス確認も組み合わせ、応答が遅い場合や失敗した場合に処理が止まったままにならないようにしています。
価格取得コマンドがHTTP通信のまわりで用意している設定と後始末を挙げます
- requests.Sessionを1つ作って複数回の取得で共有
- trust_envをFalseにして環境変数のプロキシを無視
- session.getにtimeout=10.0を指定
- raise_for_statusでエラー応答を例外化
- finallyでセッションと練習用サーバを確実に停止
- 取得したURLを履歴のsource_urlとして保存
Python・BeautifulSoup4で開発する場合の環境構築
この記事のセットアップ手順と掲載コードは、Windows 11 Pro、PowerShell 5.1、Python 3.13.3で動作確認しています。仮想環境を有効化せず、その中のPythonを直接指定するため、以下のコマンドはPowerShellとコマンドプロンプト(cmd)の両方で使えます。
python -m venv .venv
.\.venv\Scripts\python.exe -m pip install --upgrade pip
.\.venv\Scripts\python.exe -m pip install requests beautifulsoup4
.\.venv\Scripts\python.exe -m pip install pysqlite3-binary
macOS・Linuxでは仮想環境内のPythonパスが異なります。今回の動作確認環境とは異なるため、以下は環境差分を補う参考手順です。
python3 -m venv .venv
./.venv/bin/python -m pip install --upgrade pip
./.venv/bin/python -m pip install requests beautifulsoup4
./.venv/bin/python -m pip install pysqlite3-binary
- 価格履歴の保存自体はPython標準のsqlite3モジュールで完結しますが、保存したデータベースファイルをCLIからも直接のぞけるように、pysqlite3-binaryを導入してSQLite本体を仮想環境に用意しています。
- pipでの名前はbeautifulsoup4ですが、コード内のimport名はbs4です。取り違えるとModuleNotFoundErrorになります。
- HTMLの解析は標準のhtml.parserを使うので、lxmlなど追加のパーサーは入れなくても動きます。
- 練習用ページはローカルの空きポートで配信します。ローカル接続を遮断するセキュリティソフトがある場合は許可設定を確認してください。
価格履歴記録CLIの要件定義
目的は、requestsとBeautifulSoup4で練習用の商品ページから商品名と価格を取り出し、取得時刻とともにSQLiteへ履歴を積み上げ、価格変動サマリーまで確認できるCLIツールを作れるようになることです。
対象者として、Pythonの基本文法を一通り学び、HTMLの解析とデータベースへの保存を組み合わせたコマンドラインツールの作り方を学びたい人を想定しています。
完成物は、requestsとBeautifulSoup4でHTMLから商品名と価格を抽出し、SQLiteへ取得時刻付きで保存して、init・fetch・list・summaryの4コマンドで履歴と価格変動を確認できる価格履歴記録CLIです。
実装へ入る前に、機能・品質・受け入れ条件を分けて確認します。
機能要件
- initコマンドでprice_historyテーブルとインデックスを作成する
- 初期化後にカラム一覧と現在の履歴件数を表示する
- fetchコマンドで練習用の商品ページを取得する
- クラス名pnameとpriceから商品名と価格表示を取り出す
- 円記号と桁区切りを除いて価格を整数へ変換する
- 商品ID・商品名・価格・取得元URL・取得時刻を保存する
- --timesで指定した回数だけ連続で取得する
- --intervalで指定した秒数だけ取得間隔を空ける
- 保存済み件数から次の取得回数を決めてURLへ渡す
- listコマンドで履歴を古い順に一覧表示する
- --limitで表示する最大件数を絞り込む
- summaryコマンドで商品ごとの記録件数と期間を集計する
- 初回価格と最新価格の差額と変化率を表示する
- 最安値・最高値・平均価格をSQLの集計関数で求める
- 直近2件の価格差を変化として表示する
- 履歴が無い場合に取得を促すメッセージを表示する
非機能要件
- 練習用ページをローカル配信して外部通信を避ける構成
- 空きポートを自動選択するThreadingHTTPServer
- デーモンスレッドで動く練習用サーバ
- trust_envをFalseにしたリクエストセッション
- session.getに指定した10秒のタイムアウト
- raise_for_statusによるHTTPエラー検出
- 未初期化データベースでinitを促すSystemExit
- finallyでセッション・接続・サーバを閉じる後始末
- 列名でアクセスできるsqlite3.Rowの利用
- product_idとidによる複合インデックス
- IF NOT EXISTS付きで再実行できるスキーマ定義
- 東アジア文字幅を考慮した表示幅の計算
- choicesで登録済み商品IDだけを受け付ける引数
- 標準ライブラリとrequests・beautifulsoup4だけの依存
実装方針
今回はBeautifulSoup4とrequestsとSQLiteの基本動作を追いやすくするため、価格履歴記録CLI本体を1つのPythonファイルへまとめます。
入力、判定、結果表示の役割を分け、実行結果を確認しながら機能を積み上げます。
価格履歴記録CLIを安全に組み立てるための実装方針は次のとおりです。
- 練習用ページをローカル配信して外部通信を避ける構成
- 空きポートを自動選択するThreadingHTTPServer
- デーモンスレッドで動く練習用サーバ
- trust_envをFalseにしたリクエストセッション
- session.getに指定した10秒のタイムアウト
- raise_for_statusによるHTTPエラー検出
- 未初期化データベースでinitを促すSystemExit
- finallyでセッション・接続・サーバを閉じる後始末
- 列名でアクセスできるsqlite3.Rowの利用
- product_idとidによる複合インデックス
- IF NOT EXISTS付きで再実行できるスキーマ定義
- 東アジア文字幅を考慮した表示幅の計算
- choicesで登録済み商品IDだけを受け付ける引数
- 標準ライブラリとrequests・beautifulsoup4だけの依存
完成と判断する条件
- init実行後にprice_historyのカラム一覧が表示される
- initを二度実行しても既存の履歴件数が保たれる
- fetch実行で商品名・価格・取得時刻が取得ごとに表示される
- 取得回数ぶんの保存完了メッセージが表示される
- 同じ商品を続けて取得すると価格が上下する
- list実行で取得時刻・商品ID・商品名・価格の表が古い順に並ぶ
- listの末尾に表示件数の案内が出る
- summary実行で初回価格と最新価格の比較が表示される
- 差額の符号に応じて上昇・下落・横ばいが切り替わる
- 最安値・最高値・平均価格が保存した履歴と一致する
- 履歴が無い状態のlistで取得を促す案内が出る
SQLiteで価格履歴記録CLIを作る際の重要ポイント
このCLIの中心は、取得した価格を捨てずに1件ずつSQLiteへ積み上げ、後から変化を振り返れるようにする点にあります。price_historyテーブルには商品ID、商品名、価格、取得元URL、取得時刻を保存し、同じ商品を何度取得しても行が増えていく設計です。
一覧表示では古い順に並べ替えて値動きを追いかけられ、サマリーではSQLの集計関数が最安値や平均価格をまとめます。初回と最新の価格差、直近2件の差も計算し、上昇か下落かをひと目で判断できるようにしました。
履歴の保存から集計までで押さえておきたい実装上の要点を挙げます
- CREATE TABLE IF NOT EXISTSで再実行しても安全
- product_idとidの複合インデックスで検索を補助
- 取得時刻は年月日と時分秒の文字列として保存
- 保存済み件数から次の取得回数を決定
- ORDER BY id DESCとLIMITで最新側を取得
- 表示直前に並びを反転して古い順へ変更
テーブル定義と初期化コマンドの役割
initコマンドは、SQLiteのデータベースファイルとprice_historyテーブルを用意する入口です。SCHEMA_SQLにはIF NOT EXISTS付きのCREATE文を書いてあるため、同じコマンドを二度実行しても既存データは消えません。実行後にPRAGMA table_infoでカラム一覧を読み出し、現在の履歴件数と一緒に画面へ出します。
ほかのコマンドはテーブルの有無を先に確認し、未初期化ならinitを促して終了する流れです。
初期化まわりでツールが行っている確認内容を挙げます
- executescriptでテーブルとインデックスを作成
- PRAGMA table_infoでカラム名を取得
- COUNTで現在の履歴件数を確認
- sqlite_masterにテーブルが無ければSystemExit
取得時刻を添えて1件ずつ保存する手順
fetchコマンドは、練習用サーバの起動、ページ取得、保存という流れを指定回数だけ繰り返します。保存前にその商品の既存件数を数えてURLの取得回数へ渡すため、実行を重ねるほど価格が動いていく仕組みです。1件保存するたびにコミットしておけば、途中で中断しても取得済みの履歴は残ります。
画面には取得元URL、商品名、価格、取得時刻を並べ、何が保存されたのかを目で追えるようにしました。
1回の取得で1行として記録される項目を挙げます
- 商品IDと商品名
- 整数に変換した価格
- 取得元のURL
- 取得時刻の文字列
- 自動採番されるid
SQLの集計関数で価格変動を要約する仕組み
summaryコマンドはGROUP BYで商品ごとに履歴をまとめ、件数、最安値、最高値、平均価格、期間の最初と最後を一度に取り出します。集計だけでは分からないどちらへ動いたかを示すため、価格をid順に並べ直して初回と最新、直近2件の差も計算する作りです。差がプラスなら上昇、マイナスなら下落、同じなら横ばいと表示が切り替わります。
変化率は初回価格を基準にした百分率で求め、金額と並べて読めるようにしてあります。
サマリーが1商品について表示する内容を挙げます
- 記録件数と取得期間
- 初回価格と最新価格の比較
- 上昇・下落・横ばいの判定
- 差額と変化率
- 最安値・最高値・平均価格
- 直近2件の価格差
全角文字を考慮した一覧表示の桁揃え
listコマンドの表は商品名に日本語が入るため、単純な文字数で揃えると列がずれてしまいます。display_widthは東アジア文字幅を調べ、全角を2、半角を1として数える関数です。padはこの幅をもとに不足分の空白を足し、取得時刻、商品ID、商品名、価格の列を左揃えにします。
区切り線の長さも同じ計算で決めているため、見出しと本文の幅がぴったり合うでしょう。
一覧表示の整形で使っている処理を挙げます
- east_asian_widthで全角と半角を判定
- 不足分を半角空白で補って左揃え
- ヘッダー幅に合わせた区切り線を出力
- 最後に表示件数を案内
Pythonで練習用商品ページのHTML構造とセレクタ設計
スクレイピングの練習で困るのは、対象ページが毎回同じ内容を返してしまい、価格の変化を試せない点です。今回はHTMLをツール側で組み立て、アクセス回数に応じて値段が動くようにしました。
配信するHTMLでは、商品名にclass="pname"、価格にclass="price"というクラスを付けています。目印がはっきりしていると、解析側は狙ったクラスを選ぶだけで値を取り出せます。
価格は基準価格にPRICE_STEPSの差分を足して決まる仕組みです。何番目の差分を使うかはvisitの値次第で、同じ商品を続けて取得すると上下する履歴が生まれます。
このセクションの用語
- セレクタ
- HTMLの中から目的の要素を指定するための書き方です。
.priceのようにクラス名で狙い撃ちできます。 - class属性
- HTMLタグに付ける分類用のラベルです。同じ役割の要素をまとめて指定したいときに使います。
- クエリ文字列
- URLの
?より後ろに付ける追加情報です。今回は?visit=2のように何回目の取得かを渡しています。 - ThreadingHTTPServer
- Python標準ライブラリに含まれる簡易HTTPサーバです。複数のリクエストを並行して処理できます。
練習用ページ側であらかじめ決めておいた仕様は次の3点です。
- URLは
/product/<商品ID>の形で、末尾の文字列を商品IDとして扱う -
?visit=<回数>のクエリで何回目の取得かを渡す - 未登録の商品IDには404を返し、指定ミスにその場で気づけるようにする
Pythonで価格履歴記録CLIの完成コード
コードは1ファイルにまとまっています。上から、テーブル定義のSCHEMA_SQL、練習用データとbuild_practice_html、ローカルサーバのハンドラ、各サブコマンドの処理という並びです。
処理そのものは素直です。requestsで練習用ページを取得し、BeautifulSoupでクラス名を頼りに商品名と価格を抜き出し、取得時刻とともにsqlite3で1行追記します。
価格は¥1,280のように記号と桁区切りが付いた文字列で表示されます。price列はINTEGERのため、reやunicodedataで数字だけを取り出してから整数へ変換する流れです。
ネットワーク処理ではtimeoutを指定するのが定石ですが、これは応答を待つ時間の上限であって、処理全体の制限時間ではありません。公式ドキュメントも次のように説明しています。
このセクションの用語
- スキーマ
- テーブルの列名や型など、データの入れ物の設計図にあたる定義です。
- AUTOINCREMENT
- 行を追加するたびに整数のID列を自動で増やす指定です。連番の採番を自前で書かずに済みます。
- インデックス
- 検索を速くするためにデータベースが持つ索引です。条件によく使う列へ付けると効果が出ます。
- timeout
- 通信の応答をどれだけ待つかを指定する設定です。処理全体の制限時間ではない点に注意します。
#!/usr/bin/env python3
"""練習用の商品ページから商品名と価格を取得し、取得時刻付きでSQLiteへ履歴保存するCLI。"""
from __future__ import annotations
import argparse
import os
import re
import sqlite3
import time
import unicodedata
from datetime import datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from threading import Thread
from urllib.parse import parse_qs, urlparse
import requests
from bs4 import BeautifulSoup
DEFAULT_DB = "price_history.db"
SCHEMA_SQL = """
CREATE TABLE IF NOT EXISTS price_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
product_id TEXT NOT NULL,
product_name TEXT NOT NULL,
price INTEGER NOT NULL,
source_url TEXT NOT NULL,
fetched_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_price_history_product
ON price_history (product_id, id);
"""
# 練習用の商品ページ(ツール自身がローカルで配信するため外部通信なし)
PRACTICE_PRODUCTS = {
"coffee-001": {"name": "深煎りドリップコーヒー 200g", "category": "飲料", "base_price": 1280},
"tea-002": {"name": "有機ほうじ茶 ティーバッグ50個", "category": "飲料", "base_price": 980},
}
PRICE_STEPS = (0, -130, 80, -60, 150)
def build_practice_html(product_id: str, visit: int) -> str:
"""取得回数(visit)に応じて価格が決まった順序で上下する商品ページHTMLを組み立てる。"""
item = PRACTICE_PRODUCTS[product_id]
price = item["base_price"] + PRICE_STEPS[(visit - 1) % len(PRICE_STEPS)]
return (
"<!doctype html>\n"
'<html lang="ja"><head><meta charset="utf-8">'
f'<title>{item["name"]}</title></head>\n'
'<body>\n <main class="product">\n'
f' <p class="cat">{item["category"]}</p>\n'
f' <h1 class="pname">{item["name"]}</h1>\n'
f' <p class="price">¥{price:,}</p>\n'
f' <p class="sku">SKU: {product_id}</p>\n'
" </main>\n</body></html>\n"
)
class PracticePageHandler(BaseHTTPRequestHandler):
"""/product/<商品ID>?visit=<取得回数> で練習用ページを返すハンドラ。"""
def do_GET(self) -> None:
parsed = urlparse(self.path)
product_id = parsed.path.rsplit("/", 1)[-1]
raw_visit = parse_qs(parsed.query).get("visit", ["1"])[0]
visit = int(raw_visit) if raw_visit.isdigit() and int(raw_visit) > 0 else 1
if product_id not in PRACTICE_PRODUCTS:
self.send_error(404, "product not found")
return
body = build_practice_html(product_id, visit).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, format: str, *args: object) -> None:
return
def start_practice_server() -> tuple[ThreadingHTTPServer, str]:
"""空きポートで練習用ページのローカルサーバを起動し、ベースURLを返す。"""
server = ThreadingHTTPServer(("127.0.0.1", 0), PracticePageHandler)
Thread(target=server.serve_forever, daemon=True).start()
host, port = server.server_address[0], server.server_address[1]
return server, f"http://{host}:{port}"
# データベースの初期化
def connect(db_path: str) -> sqlite3.Connection:
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
return conn
def init_database(db_path: str) -> tuple[list[str], int]:
"""価格履歴テーブルを作成し、カラム一覧と現在の履歴件数を返す。"""
conn = connect(db_path)
try:
conn.executescript(SCHEMA_SQL)
conn.commit()
columns = [row["name"] for row in conn.execute("PRAGMA table_info(price_history)")]
count = conn.execute("SELECT COUNT(*) AS n FROM price_history").fetchone()["n"]
finally:
conn.close()
return columns, count
def require_schema(conn: sqlite3.Connection, db_path: str) -> None:
row = conn.execute(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'price_history'"
).fetchone()
if row is None:
raise SystemExit(f"{db_path} は未初期化です。先に init コマンドを実行してください。")
def count_records(conn: sqlite3.Connection, product_id: str) -> int:
row = conn.execute(
"SELECT COUNT(*) AS n FROM price_history WHERE product_id = ?", (product_id,)
).fetchone()
return int(row["n"])
# 商品名と価格をHTMLから取得する(requests + BeautifulSoup4)
def parse_price(text: str) -> int:
"""「¥1,280」のような表記から数字だけを取り出して整数にする。"""
digits = re.sub(r"[^0-9]", "", text)
if not digits:
raise ValueError(f"価格を数値に変換できません: {text!r}")
return int(digits)
def fetch_product(session: requests.Session, url: str, timeout: float = 10.0) -> dict:
response = session.get(url, timeout=timeout)
response.raise_for_status()
soup = BeautifulSoup(response.text, "html.parser")
name_el = soup.select_one(".pname")
price_el = soup.select_one(".price")
if name_el is None or price_el is None:
raise ValueError(f"商品名または価格の要素が見つかりません: {url}")
price_text = price_el.get_text(strip=True)
return {
"name": name_el.get_text(strip=True),
"price": parse_price(price_text),
"price_text": price_text,
}
# 取得時刻付きで価格履歴をSQLiteへ保存する
def save_record(
conn: sqlite3.Connection,
product_id: str,
product_name: str,
price: int,
source_url: str,
fetched_at: str,
) -> None:
conn.execute(
"INSERT INTO price_history"
" (product_id, product_name, price, source_url, fetched_at)"
" VALUES (?, ?, ?, ?, ?)",
(product_id, product_name, price, source_url, fetched_at),
)
conn.commit()
# 保存済みの価格履歴を読み出す
def load_history(db_path: str, limit: int) -> list[sqlite3.Row]:
conn = connect(db_path)
try:
require_schema(conn, db_path)
rows = conn.execute(
"SELECT product_id, product_name, price, fetched_at"
" FROM price_history ORDER BY id DESC LIMIT ?",
(limit,),
).fetchall()
finally:
conn.close()
return list(reversed(rows))
# 価格変動を要約する(SQLの集計関数 + 初回・最新の比較)
def summarize(db_path: str) -> list[dict]:
conn = connect(db_path)
try:
require_schema(conn, db_path)
grouped = conn.execute(
"SELECT product_id, product_name, COUNT(*) AS records,"
" MIN(price) AS min_price, MAX(price) AS max_price,"
" ROUND(AVG(price)) AS avg_price,"
" MIN(fetched_at) AS first_at, MAX(fetched_at) AS last_at"
" FROM price_history GROUP BY product_id, product_name ORDER BY product_id"
).fetchall()
summaries = []
for row in grouped:
prices = [
int(r["price"])
for r in conn.execute(
"SELECT price FROM price_history WHERE product_id = ? ORDER BY id",
(row["product_id"],),
)
]
first_price, last_price = prices[0], prices[-1]
diff = last_price - first_price
summaries.append(
{
"product_id": row["product_id"],
"product_name": row["product_name"],
"records": int(row["records"]),
"first_at": row["first_at"],
"last_at": row["last_at"],
"first_price": first_price,
"last_price": last_price,
"prev_price": prices[-2] if len(prices) >= 2 else None,
"min_price": int(row["min_price"]),
"max_price": int(row["max_price"]),
"avg_price": int(row["avg_price"]),
"diff": diff,
"rate": (diff / first_price * 100) if first_price else 0.0,
}
)
finally:
conn.close()
return summaries
# 表示の整形(全角文字を考慮した桁揃え)
def display_width(text: str) -> int:
return sum(2 if unicodedata.east_asian_width(ch) in "WF" else 1 for ch in text)
def pad(text: str, width: int) -> str:
return text + " " * max(0, width - display_width(text))
# 各コマンドの処理
def cmd_init(args: argparse.Namespace) -> None:
columns, count = init_database(args.db)
print(f"データベースを初期化しました: {os.path.abspath(args.db)}")
print(f"テーブル: price_history ({', '.join(columns)})")
print(f"現在の履歴件数: {count}件")
def cmd_fetch(args: argparse.Namespace) -> None:
server, base_url = start_practice_server()
conn = connect(args.db)
session = requests.Session()
session.trust_env = False
try:
require_schema(conn, args.db)
for i in range(args.times):
visit = count_records(conn, args.product) + 1
url = f"{base_url}/product/{args.product}?visit={visit}"
item = fetch_product(session, url)
fetched_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
save_record(conn, args.product, item["name"], item["price"], url, fetched_at)
print(f"[{i + 1}/{args.times}] GET {url}")
print(f" 商品名 : {item['name']}")
print(f" 価格 : ¥{item['price']:,} (ページ表示: {item['price_text']})")
print(f" 取得時刻: {fetched_at}")
if i < args.times - 1:
time.sleep(args.interval)
finally:
session.close()
conn.close()
server.shutdown()
server.server_close()
print(f"{args.times}件の価格履歴を {args.db} へ保存しました。")
def cmd_list(args: argparse.Namespace) -> None:
rows = load_history(args.db, args.limit)
if not rows:
print("価格履歴はまだありません。fetch コマンドで取得してください。")
return
header = f"{pad('取得時刻', 21)}{pad('商品ID', 12)}{pad('商品名', 34)}価格"
print(header)
print("-" * display_width(header))
for row in rows:
line = (
f"{pad(row['fetched_at'], 21)}"
f"{pad(row['product_id'], 12)}"
f"{pad(row['product_name'], 34)}"
f"¥{row['price']:,}"
)
print(line)
print(f"\n古い順に {len(rows)}件を表示しました。")
def cmd_summary(args: argparse.Namespace) -> None:
summaries = summarize(args.db)
if not summaries:
print("価格変動サマリーを作成できる履歴がありません。")
return
print("=== 価格変動サマリー ===")
for s in summaries:
arrow = "上昇" if s["diff"] > 0 else ("下落" if s["diff"] < 0 else "横ばい")
print(f"■ {s['product_name']} ({s['product_id']})")
print(f" 記録件数: {s['records']}件 期間: {s['first_at']} 〜 {s['last_at']}")
print(
f" 初回 ¥{s['first_price']:,} → 最新 ¥{s['last_price']:,}"
f" {arrow} {s['diff']:+,}円 ({s['rate']:+.1f}%)"
)
print(
f" 最安 ¥{s['min_price']:,} / 最高 ¥{s['max_price']:,}"
f" / 平均 ¥{s['avg_price']:,}"
)
if s["prev_price"] is not None:
step = s["last_price"] - s["prev_price"]
print(f" 直近の変化: ¥{s['prev_price']:,} → ¥{s['last_price']:,} ({step:+,}円)")
print()
# コマンドライン引数の定義
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="price_tracker",
description="練習用の商品ページから商品名と価格を取得し、SQLiteへ価格履歴を記録するCLI",
)
sub = parser.add_subparsers(dest="command", required=True)
p_init = sub.add_parser("init", help="SQLiteデータベースと価格履歴テーブルを初期化する")
p_init.add_argument("--db", default=DEFAULT_DB, help="データベースファイルのパス")
p_init.set_defaults(func=cmd_init)
p_fetch = sub.add_parser("fetch", help="練習用の商品ページから商品名と価格を取得して保存する")
p_fetch.add_argument("--db", default=DEFAULT_DB, help="データベースファイルのパス")
p_fetch.add_argument(
"--product", required=True, choices=sorted(PRACTICE_PRODUCTS), help="取得する商品ID"
)
p_fetch.add_argument("--times", type=int, default=1, help="連続して取得する回数")
p_fetch.add_argument("--interval", type=float, default=1.0, help="取得間隔(秒)")
p_fetch.set_defaults(func=cmd_fetch)
p_list = sub.add_parser("list", help="保存済みの価格履歴を一覧表示する")
p_list.add_argument("--db", default=DEFAULT_DB, help="データベースファイルのパス")
p_list.add_argument("--limit", type=int, default=20, help="表示する最大件数")
p_list.set_defaults(func=cmd_list)
p_summary = sub.add_parser("summary", help="価格変動を要約して表示する")
p_summary.add_argument("--db", default=DEFAULT_DB, help="データベースファイルのパス")
p_summary.set_defaults(func=cmd_summary)
return parser
# エントリポイント
def main() -> None:
args = build_parser().parse_args()
args.func(args)
if __name__ == "__main__":
main()
コード全文は上の折り畳みに入れてあるので、全部を上から読む必要はありません。ここでは特に重要な部分だけを抜き出して、何をしているのか順番に見ていきます。
SCHEMA_SQLで履歴テーブルを定義する
CREATE TABLE IF NOT EXISTS price_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
product_id TEXT NOT NULL,
product_name TEXT NOT NULL,
price INTEGER NOT NULL,
source_url TEXT NOT NULL,
fetched_at TEXT NOT NULL
);履歴の入れ物になるテーブルを定義している部分です。商品ID・商品名・価格・取得元URL・取得時刻をそろえて持たせることで、あとから「いつ・どこで・いくらだったか」を復元できます。IF NOT EXISTSを付けているので、initを何度実行しても既存のテーブルを壊しません。
CREATE INDEXで商品別の絞り込みを支える
CREATE INDEX IF NOT EXISTS idx_price_history_product
ON price_history (product_id, id);商品IDと連番のペアに索引を張っています。履歴は増える一方なので、listやsummaryで特定商品だけを取り出すときの検索が助かります。件数が少ないうちは差が見えにくいものの、後から足すより最初に入れておくほうが楽です。
PRACTICE_PRODUCTSとPRICE_STEPSの役割
PRACTICE_PRODUCTS = {
"coffee-001": {"name": "深煎りドリップコーヒー 200g", "category": "飲料", "base_price": 1280},
"tea-002": {"name": "有機ほうじ茶 ティーバッグ50個", "category": "飲料", "base_price": 980},
}
PRICE_STEPS = (0, -130, 80, -60, 150)練習用の商品カタログと、価格を動かすための差分をまとめた定義です。基準価格に差分を足すだけなので、乱数を使わずに毎回同じ順番で値上がりと値下がりを再現できます。動きが決まっていると、期待どおりの履歴になっているか確認しやすくなります。
build_practice_htmlで価格を差し替える
item = PRACTICE_PRODUCTS[product_id]
price = item["base_price"] + PRICE_STEPS[(visit - 1) % len(PRICE_STEPS)]何回目のアクセスかを表すvisitから、使う差分を選んでいます。% len(PRICE_STEPS)で余りを取っているため、5回を超えても添字がはみ出さず先頭に戻ります。この1行が「価格が変動するページ」の中心です。
抽出の目印になるclass付きHTML
f' <h1 class="pname">{item["name"]}</h1>\n'
f' <p class="price">¥{price:,}</p>\n'商品名と価格を、それぞれ専用のクラス名を持つタグで囲んで出力しています。この目印があるおかげで、解析側はpnameとpriceを指定するだけで値を取り出せます。価格は{price:,}で桁区切りが入るため、保存前には数字だけへ戻す処理が必要です。
do_GETでvisitパラメータを読み取る
parsed = urlparse(self.path)
product_id = parsed.path.rsplit("/", 1)[-1]
raw_visit = parse_qs(parsed.query).get("visit", ["1"])[0]
visit = int(raw_visit) if raw_visit.isdigit() and int(raw_visit) > 0 else 1リクエストされたURLを分解して、パスの末尾から商品IDを、クエリから取得回数を取り出しています。isdigit()で数字かどうかを確かめ、不正な値なら1として扱う保険が入っています。外部からの入力をそのままint()に渡さない書き方の練習にもなるでしょう。
UTF-8を明示してHTMLを返す
body = build_practice_html(product_id, visit).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")組み立てたHTMLをUTF-8のバイト列に変換し、同じ文字コードをヘッダでも宣言しています。ここがずれると日本語の商品名が文字化けし、取得側で原因を探す羽目になります。配信側と取得側で表記をそろえるのが安全です。
参考:
©Requests公式ドキュメントQuickstarttimeout is not a time limit on the entire response download; rather, an exception is raised if the server has not issued a response for timeout seconds (more precisely, if no bytes have been received on the underlying socket for timeout seconds).
Pythonで価格履歴記録CLIのエラー対処
動かしていて手が止まりやすいのは、ライブラリの読み込み、要素の取り違え、そしてデータベースの初期化まわりです。エラー出力の最終行を読むだけでも、原因の見当はかなり絞れます。
とくにNoneが絡むエラーは、HTML側の変化に気づくためのサインとして受け止めると理解しやすくなります。
このセクションの用語
- トレースバック
- エラーが起きるまでの呼び出し経路を示す出力です。最後の行に例外の種類と内容が表示されます。
- NoneType
- 値が存在しないことを表す
Noneの型です。要素が見つからなかったときに返ってきます。 - OperationalError
-
sqlite3がデータベース操作に失敗したときに送出する例外です。テーブル不足やファイル権限で起きやすい種類です。
| エラー例 | 原因 | 対処 |
|---|---|---|
| ModuleNotFoundError: No module named 'bs4' |
beautifulsoup4が未インストール、または別の環境に入っている |
いま動いている環境を確認し、その環境へ入れ直す |
| AttributeError: 'NoneType' object has no attribute 'text' | クラス名が違い、要素が見つからずNoneが返っている |
pnameやpriceの綴りを確認し、取得したHTMLを一度そのまま出力して構造を見る |
| ValueError: invalid literal for int() with base 10: '¥1,280' | 通貨記号や桁区切りが残ったままint()へ渡している |
reで数字以外を取り除き、必要に応じてunicodedataで全角を半角へそろえる |
| sqlite3.OperationalError: no such table: price_history |
initを実行する前にfetchやlistを叩いている |
同じ--dbのパスを指定してinitを先に実行する |
| requests.exceptions.ConnectionError | 練習用ページを配信するローカルサーバが起動していない、またはポートが埋まっている | サーバの起動処理が走っているか確認し、他プロセスが同じポートを使っていないか調べる |
価格履歴記録CLIで注意したい点
つまずきの多くはHTMLの読み取りそのものではなく、その周辺に潜んでいます。文字コード、価格の型、データベースの状態という3か所です。
文字コードは配信側と受信側でそろえるのが基本になります。今回はContent-Typeにcharset=utf-8を明示し、本文もencode("utf-8")してから送り出しました。
価格を文字列のまま保存すると、後から大小比較も差分計算もできません。取り出した時点で整数に直しておくと、サマリーの計算が素直に書けます。
テーブル作成にIF NOT EXISTSを付けているのは、同じデータベースへ何度initを実行しても壊れないようにするためです。ポイントとしては、文字コード・価格の型・初期化の扱いという3点を先に決めておくことです。
文字コード:配信と取得でUTF-8統一
価格の型:整数に直してから保存
初期化:再実行してもテーブル安全
取得間隔:連続アクセスは控えめに
価格履歴記録CLIの動作確認
実行の流れは、初期化・取得・取得・一覧・サマリーの5コマンドです。すべて終了コード0で完了し、途中で例外が出て止まることはありませんでした。
最初にinitでデータベースを作成しました。保存先を/tmp/price_history.dbにしたのは、作業ディレクトリを汚さずに何度でも作り直せるからです。
続く2回のfetchでは、--productで商品を切り替えつつ--times 3を指定しています。同じ商品へ続けてアクセスするので、1コマンドで複数時点の記録が積み上がる形になります。
最後はlist --limit 10で履歴を一覧し、summaryで価格変動のまとめを確認しました。各コマンドの出力はキャプチャとして残してあります。
このセクションの用語
- 終了コード
- コマンドが終わったときに返す数値で、0は正常終了を表します。自動実行の成否判定にも使われます。
- サブコマンド
-
initやfetchのように、コマンド名の後ろに続けて機能を切り替える指定方法です。
実際に流したコマンドを、実行した順番で並べます。
python subject.py init --db /tmp/price_history.dbpython subject.py fetch --db /tmp/price_history.db --product coffee-001 --times 3python subject.py fetch --db /tmp/price_history.db --product tea-002 --times 3python subject.py list --db /tmp/price_history.db --limit 10python subject.py summary --db /tmp/price_history.db





価格履歴の記録が役立つ場面と応用例
このツールの本質は、時系列の記録を1行ずつ増やしていくところにあります。取得元を実在のページへ差し替えれば、そのまま業務の監視にも転用できます。
履歴が溜まれば、単価の変化に加えて「いつ変わったか」まで追えるのが強みです。
このセクションの用語
- cron
- 指定した時刻や間隔でコマンドを自動実行するUnix系OSの仕組みです。定期取得と相性がよい機能といえるでしょう。
- 時系列データ
- 時刻とセットで記録され、時間の流れに沿って並ぶデータとなります。価格履歴はその典型例になります。
| 使える場面 | 具体的な使い方 |
|---|---|
| 自社ECの表示価格チェック | キャンペーン設定のミスで想定外の価格が出ていないか、日次で取得した履歴を並べて確認する |
| スクレイピング学習の練習環境 | 外部サイトへアクセスせずに、セレクタの書き方や要素が見つからないときの挙動を安全に試す |
| 仕入れ・原価の社内資料づくり | 商品IDごとの最安・最高・直近の価格をサマリーで出し、そのまま報告資料の数値として引用する |
| スケジュール実行との組み合わせ |
fetchをcronやタスクスケジューラから1日1回呼び出し、長期の価格推移を自動で蓄積する |
| 他ツールへのデータ受け渡し | SQLiteファイルをpandasで読み込み、グラフ化や他データとの突き合わせに回す |
Python×SQLiteの価格履歴ツールを作ってみて
requests・BeautifulSoup4・sqlite3という3点セットだけで、価格の履歴を残すCLIは十分に形になりました。練習用ページを自前で配信したおかげで、外部通信なしに取得から集計までを通して確認できています。
実際に5回のコマンドを流し、初期化から価格変動サマリーまでがすべて終了コード0で完了しました。仕組みが小さいぶん、どこを直せば何が変わるのかも見通しやすい構成です。
スクレイピングは、取得先の規約や負荷への配慮とセットで学ぶ分野です。まずは手元の練習用ページで型を身につけてから、対象を広げていくのが安全な進め方になります。
手を動かした流れのまま試せる、次の一手を3つ挙げます。
-
--timesを増やして取得回数を伸ばし、より長い価格履歴を作る -
summaryの結果をCSVへ書き出し、表計算ソフトで折れ線グラフにする - 前回より値下がりしたときだけメッセージを出す判定を
fetchに足す
参考にした一次情報
- ^ Requests: HTTP for Humans — 公式ドキュメント. https://requests.readthedocs.io/en/latest/, (参照26-08-14).
- ^ Beautiful Soup Documentation. https://www.crummy.com/software/BeautifulSoup/bs4/doc/, (参照26-08-14).
- ^ sqlite3 — DB-API 2.0 interface for SQLite databases (Python標準ライブラリ). https://docs.python.org/ja/3/library/sqlite3.html, (参照26-08-14).
- ^ argparse — コマンドラインオプション、引数、サブコマンドのパーサー. https://docs.python.org/ja/3/library/argparse.html, (参照26-08-14).
※内容は執筆時点のものです。ライブラリやサイトの仕様は変わる可能性があるため、公式ドキュメントもあわせてご確認ください。
