【Python】ScrapyとSQLiteで商品カタログ収集CLIを作ってみた

【Python】ScrapyとSQLiteで商品カタログ収集CLIを作ってみた

公開: 更新:
CodeCampが提供するDX人材育成が可能なプログラミングやITが学べる公開講座

ローカルに用意した検証用の商品カタログを対象に、PythonのScrapyで一覧ページをページネーションで巡回し、商品名・価格・在庫状況・商品URLをSQLiteへ重複なく保存するCLIツールを作りました。カタログHTMLの生成からクロール、商品一覧、集計、CSV出力までを1ファイルにまとめ、5回のコマンド実行がすべて終了コード0で完了しています。

この記事では、実装の要点と初心者がつまずきやすい所を順番に解説します。

Scrapyの基本概念、要件定義、実装、動作確認までを順番に学べる構成です。動画は目次から確認したい場面へ移動でき、本文だけでも手順と考え方が完結します。

【Python】ScrapyとSQLiteで商品カタログ収集CLIを作ってみた|フルカリキュラム
動画の内容をテキストで確認する

オープニング。ScrapyとSQLiteを使って商品カタログ収集CLIを作るカリキュラムを始めます。概要紹介。

ScrapyとSQLiteの役割と使い方を学ぶ 商品カタログ収集CLIの要件を整理する 完成コードと実行結果を確認する 最後に実コマンドとファイル状態で完成挙動を確かめる 具体的にやること。

init-catalogサブコマンドで検証用商品カタログを生成する --pagesと--per-pageでページ数と1ページの商品数を指定する2ページ目以降へ前ページ先頭の商品カードを再掲するcrawlサブコマンドでpage-1htmlから巡回を始めるarticle.productのカードから商品名と価格を抽出する 実装環境・必須アプリ。

OS:Windows 11 Pro Python:3.13.3シェル:PowerShell 5.1必須アプリ:コードエディター、ターミナル、エクスプローラー パッケージ:pip、scrapy、pysqlite3-binary PythonのクローリングフレームワークScrapyとSQLiteとは。

Scrapy:Scrapyは、Webページの取得からリンクの追跡、抽出したデータの受け渡しまでを一括して引き受けるPythonのクローリングフレームワークSQLite:SQLiteは、1つのファイルの中にテーブルを持てる軽量なリレーショナルデータベース ScrapyとSQLiteで作る商品収集CLIの要点。

前ページ先頭を再掲して重複を意図的に作るseen集合で同一クロール内の重複を判定するDropItemで落とした件数を重複スキップとして数える 商品カタログ収集CLIの要件定義。

init-catalogで一覧ページと詳細ページが生成される 生成結果に重複カードの件数が表示されるcrawlで巡回した一覧ページ数が表示される 重複した商品カードが重複スキップとして数えられるSQLite保存件数が抽出カード数より少なくなる 同じデータベースへ再クロールしても保存件数が増えない INTRO: Monaco Editorで商品カタログ収集CLIを実装。

コードを1行ずつ入力し、補完と自動インデントを使いながら実行結果を確認します。LINE 001: モジュールの説明文。このツール全体が何をするものかを説明するドキュメント文字列です。

ローカルの商品カタログをScrapyで巡回し、SQLiteへ保存するCLIであることが分かります。LINE 003: 将来の型注釈を有効化。annotationsの機能を先取りして使うための宣言です。

これにより後の型ヒントの書き方が柔軟になります。LINE 005: argparseの読み込み。コマンドライン引数を扱うためのargparseモジュールを読み込んでいます。

CLIのサブコマンドや引数の定義に使われます。LINE 006: asyncioの読み込み。非同期処理を扱うasyncioモジュールを読み込んでいます。

Windows環境でのイベントループ設定に使われます。LINE 007: csvの読み込み。CSVファイルを読み書きするためのcsvモジュールを読み込んでいます。

商品データのCSV出力機能で使われます。LINE 008: randomの読み込み。乱数を扱うrandomモジュールを読み込んでいます。

検証用の商品データをランダムに生成する際に使われます。LINE 009: sqlite3の読み込み。SQLiteデータベースを操作するためのsqlite3モジュールを読み込んでいます。

商品データの保存や検索に使われます。LINE 010: sysの読み込み。実行環境の情報を扱うsysモジュールを読み込んでいます。

プラットフォームの判定に使われます。LINE 011: datetimeのインポート。日時を扱うdatetimeクラスを読み込んでいます。

商品の初回・最終取得日時を記録する際に使われます。LINE 012: Pathのインポート。ファイルパスを扱いやすくするPathクラスを読み込んでいます。

ディレクトリ作成やファイル操作に使われます。LINE 014: Scrapy本体の読み込み。クローラーを作るためのScrapyライブラリを読み込んでいます。

Spiderクラスの定義に使われます。LINE 015: CrawlerProcessの読み込み。クロールを実行するためのCrawlerProcessクラスを読み込んでいます。

設定を渡してクロールを起動する際に使われます。LINE 016: DropItemの読み込み。アイテムを破棄するための例外クラスDropItemを読み込んでいます。

重複した商品データをスキップする処理で使われます。LINE 020: カテゴリ一覧の定義。検証用商品カタログで使うカテゴリ名のタプルを定義しています。

商品データを組み立てる際にカテゴリを割り当てるために使われます。LINE 021: 商品名候補の定義開始。商品名の元になる単語のタプルを定義し始めています。

ここから複数の商品名候補を列挙していきます。LINE 022: 商品名候補1。商品名の候補として「ステンレスボトル」を追加しています。

商品データ生成時に順番に使われます。LINE 023: 商品名候補2。商品名の候補として「折りたたみ傘」を追加しています。

商品データ生成時に順番に使われます。LINE 024: 商品名候補3。商品名の候補として「ワイヤレスマウス」を追加しています。

商品データ生成時に順番に使われます。LINE 025: 商品名候補4。商品名の候補として「アルミ弁当箱」を追加しています。

商品データ生成時に順番に使われます。LINE 026: 商品名候補5。商品名の候補として「耐熱ガラスポット」を追加しています。

商品データ生成時に順番に使われます。LINE 027: 商品名候補6。商品名の候補として「ゲルインクペン」を追加しています。

商品データ生成時に順番に使われます。LINE 028: 商品名候補7。商品名の候補として「USB充電器」を追加しています。

商品データ生成時に順番に使われます。LINE 029: 商品名候補8。商品名の候補として「レジャーシート」を追加しています。

商品データ生成時に順番に使われます。LINE 030: 商品名候補9。商品名の候補として「珪藻土マット」を追加しています。

商品データ生成時に順番に使われます。LINE 031: 商品名候補10。商品名の候補として「ドリップコーヒー」を追加しています。

商品データ生成時に順番に使われます。LINE 032: 商品名候補11。商品名の候補として「収納ボックス」を追加しています。

商品データ生成時に順番に使われます。LINE 033: 商品名候補12。商品名の候補として「LEDデスクライト」を追加しています。

これでITEM_WORDSの定義が終わります。LINE 034: 商品名候補タプルの終了。ITEM_WORDSタプルの定義を閉じています。

ここまでに列挙した単語が商品名の元として使われます。LINE 035: 在庫ラベルの定義。在庫状況を表すラベルのタプルを定義しています。

在庫ありが多めに並んでいるため、生成される商品の多くが在庫ありになります。LINE 037: 一覧ページテンプレートの開始。商品一覧ページのHTMLひな形を定義し始めています。

後ほどformatメソッドで値を埋め込んで使います。LINE 038: HTMLの言語指定。HTML文書の言語を日本語に指定するタグです。

ブラウザが正しく日本語ページとして扱うために必要です。LINE 039: ページタイトルの埋め込み。文字コードとページタイトルを設定するheadタグです。

pageとtotalのプレースホルダーに現在ページ数と総ページ数が入ります。LINE 040: bodyタグの開始。ページ本体の開始タグです。

この後に見出しや商品一覧が続きます。LINE 041: 見出しの表示。ページ上部に表示する見出しです。

常に「検証用商品カタログ」という固定文字列が表示されます。LINE 042: ページ番号の表示。現在のページ番号と総ページ数を表示する段落です。

pageとtotalのプレースホルダーに実際の値が埋め込まれます。LINE 043: 商品グリッドの開始。商品カードを並べるためのdivタグの開始です。

この中にcardsのプレースホルダーで商品カードが挿入されます。LINE 044: 商品カードの挿入位置。実際の商品カードのHTMLが挿入されるプレースホルダーです。

formatメソッド呼び出し時にcards変数の内容に置き換わります。LINE 045: 商品グリッドの終了。商品カードを囲んでいたdivタグを閉じています。

ここまでが商品一覧のグリッド部分です。LINE 046: 次ページリンクの表示。次のページへのリンクを表示するnavタグです。

next_linkのプレースホルダーに、次ページがある場合のみリンクHTMLが入ります。LINE 047: bodyタグの終了。ページ本体を閉じるタグです。

ここまでの内容がページの表示部分になります。LINE 048: htmlタグの終了。HTML文書全体を閉じるタグです。

これで1つの一覧ページのHTML構造が完結します。LINE 049: 一覧ページテンプレートの終了。PAGE_TEMPLATE文字列の定義を閉じています。

この文字列はinit_catalog関数内でformatされ、実際のHTMLファイルとして書き出されます。LINE 051: 詳細ページテンプレートの開始。商品詳細ページのHTMLひな形を定義し始めています。

商品ごとの名前・価格・在庫状況を埋め込むために使います。LINE 052: HTMLの言語指定。詳細ページも日本語であることを示すタグです。

ブラウザが正しく表示するために必要です。LINE 053: 商品名をタイトルに設定。文字コードと、商品名をページタイトルに設定するheadタグです。

nameのプレースホルダーに商品名が入ります。LINE 054: bodyタグの開始。詳細ページの本体開始タグです。

この後に商品情報が続きます。LINE 055: 商品名の見出し表示。商品名を見出しとして表示するタグです。

nameのプレースホルダーに実際の商品名が埋め込まれます。LINE 056: 価格の表示。商品価格を円マーク付きで表示する段落です。

priceのプレースホルダーにカンマ区切りの価格が入ります。LINE 057: 在庫状況の表示。商品の在庫状況を表示する段落です。

stockのプレースホルダーに在庫あり・在庫切れなどのラベルが入ります。LINE 058: bodyタグの終了。詳細ページの本体を閉じるタグです。

ここまでが表示される商品情報です。LINE 059: htmlタグの終了。詳細ページのHTML構造を閉じるタグです。

これで1つの商品詳細ページの構造が完結します。LINE 060: 詳細ページテンプレートの終了。DETAIL_TEMPLATE文字列の定義を閉じています。

この文字列はinit_catalog関数内でformatされ、商品ごとのHTMLファイルとして書き出されます。LINE 064: 商品データ生成関数の定義。指定したページ数と1ページあたりの商品数から、検証用の商品データを組み立てる関数を定義しています。

戻り値はページごとの商品リストのリストです。LINE 065: 関数の説明文。この関数が固定シードの乱数を使ってページごとの商品データを組み立てることを説明しています。

同じシードなら毎回同じデータが生成されます。LINE 066: 乱数生成器の作成。指定したseedを使って乱数生成器を作成しています。

同じシードを渡すことで、価格や在庫状況を毎回同じ結果で再現できます。LINE 067: 結果格納用リストの初期化。ページごとの商品データを格納するための空リストを用意しています。

この後のループでページ単位のデータが追加されていきます。LINE 068: 商品連番の初期化。商品ID生成に使う連番用の変数を0で初期化しています。

この後のループで商品ごとに1ずつ増えていきます。LINE 069: ページ数分のループ開始。指定されたページ数だけ繰り返す外側のループです。

1回のループが1ページ分の商品データ作成に対応します。LINE 070: ページ内商品リストの初期化。1ページ分の商品データを格納する空リストを用意しています。

この後のループで商品の辞書が追加されていきます。LINE 071: ページあたりの商品数分のループ開始。1ページに含める商品数だけ繰り返す内側のループです。

1回のループが商品1件分のデータ作成に対応します。LINE 072: 商品連番の加算。商品ごとに連番を1つ増やしています。

この番号は商品IDや商品名の枝番として使われます。LINE 073: 商品データの辞書を追加開始。1件分の商品情報を表す辞書を組み立ててrowsリストに追加し始めています。

この辞書には商品ID・名前・カテゴリ・価格・在庫状況が含まれます。LINE 074: 商品辞書の開始。1件分の商品情報を表す辞書リテラルの中括弧を開いています。

この後に各項目が定義されます。LINE 075: 商品IDの設定。連番から3桁ゼロ埋めの商品IDを作成しています。

例えばnumberが1なら「p001」というIDになります。LINE 076: 商品名の組み立て。ITEM_WORDSからnumberに対応する単語を選び、連番付きの型番と組み合わせて商品名を作成します。

numberを単語数で割った余りを使うことで、単語が一定周期で繰り返されるようになっています。LINE 077: カテゴリの割り当て。CATEGORIESの配列からnumberの余りを使ってカテゴリを1つ選び出しています。

商品ごとに異なるカテゴリが順番に割り当たるようになります。LINE 078: 価格のランダム生成。固定シードの乱数を使い、480円から24800円未満の範囲で10円単位の価格をランダムに決めています。

シードが固定されているため、実行しても毎回同じ価格になります。LINE 079: 在庫状況のランダム選択。STOCK_LABELSの中からrng.choiceで在庫状況を1つランダムに選んでいます。

在庫あり・残りわずか・入荷待ちなどが商品ごとにばらつきを持って設定されます。LINE 080: 商品データ辞書の終了。これまでに作成したpid・name・category・price・stockをまとめた1商品分の辞書の定義を閉じています。

この辞書が1件分の商品データとして扱われます。LINE 081: append呼び出しの終了。rows.appendの呼び出しの括弧を閉じ、直前で作った商品辞書をrowsリストに追加する処理を完了させています。

LINE 082: 1ページ分の商品を確定。1ページ分作成した商品リストrowsを、全ページ分を保持するpages_dataに追加しています。これによりページごとの商品データが積み重なっていきます。

LINE 083: 生成結果の返却。すべてのページ分の商品データが入ったpages_dataを呼び出し元へ返しています。この戻り値がカタログHTML生成の元データとして使われます。

RUN 1/8: 商品データの組み立てを確かめる。build_productsまで入力した状態です。ページ数と1ページあたりの件数を渡し、辞書の中身が期待どおりか確認します。

CHECK 1/8: 途中実行に成功。ページ数: 2 1ページ目の件数: 3 先頭商品: p001折りたたみ傘PB-001文具8080在庫あり RETURN 01: エディターへ戻りました。入力済みのコードを維持したまま、次の実装へ進みます。

LINE 086: カード用HTML関数の定義。商品1件分の情報を受け取り、一覧ページに表示するカードのHTML文字列を組み立てる関数を定義しています。LINE 087: HTML文字列の返却開始。

複数行の文字列を連結してreturnする処理の始まりで、以降の各行がカードHTMLの一部として順に結合されます。LINE 088: カード要素の開始タグ。商品カードを表すarticle要素の開始タグを文字列として用意しています。

この要素の中に商品情報が入っていきます。LINE 089: カテゴリ表示の埋め込み。商品辞書からcategoryを取り出し、span要素の中に埋め込んでカテゴリ表示部分のHTMLを作成しています。

LINE 090: 商品名見出しの埋め込み。商品名をh2要素に埋め込み、カード内で見出しとして表示される部分のHTMLを作成しています。LINE 091: 価格表示の埋め込み。

価格をカンマ区切りの数値としてフォーマットし、¥記号を付けたうえでspan要素に埋め込んでいます。LINE 092: 在庫状況表示の埋め込み。在庫状況の文字列をspan要素に埋め込み、カード上で在庫の状態が分かるようにしています。

LINE 093: 詳細ページへのリンク作成。商品のpidを使って詳細ページへのリンクを組み立て、クリックすると詳細ページに移動するa要素を作成しています。LINE 094: カード要素の終了タグ。

article要素を閉じるタグを追加し、これまでの各要素を1つのカードとしてまとめています。LINE 095: 文字列連結の終了。returnで連結してきた文字列リテラルの括弧を閉じ、1商品分の完成したカードHTMLを返す処理を終えています。

LINE 098: カタログ生成関数の定義。サイトの出力先ディレクトリとページ数・1ページあたりの商品数を受け取り、カタログ全体のHTMLを生成する関数を定義しています。LINE 099: 関数の説明文。

この関数が一覧ページと詳細ページのHTMLを書き出し、2ページ目以降では前ページの先頭商品を再掲することを説明しているドキュメント文字列です。LINE 100: 出力先ディレクトリの解決。引数で渡されたsite_dirをPathオブジェクトに変換し、絶対パスに解決してrootという変数に格納しています。

LINE 101: 商品用ディレクトリの作成。詳細ページを保存するためのproductディレクトリを、必要な親ディレクトリも含めて作成しています。既に存在していてもエラーにならないよう設定しています。

LINE 102: 商品データの生成呼び出し。build_products関数を呼び出し、指定したページ数と1ページあたりの商品数に基づいて商品データ一式を作成しています。LINE 103: 総ページ数の取得。

生成されたpages_dataの要素数を数えることで、全体のページ数をtotal_pagesとして求めています。LINE 104: 重複カード数の初期化。前ページ先頭の商品を再掲した回数を数えるための変数repeatedを0で初期化しています。

LINE 106: ページ単位の繰り返し開始。pages_dataをインデックス付きで1ページずつ取り出し、以降でページごとのHTMLを組み立てる繰り返し処理を開始しています。LINE 107: カードリストの複製。

そのページの商品リストrowsをコピーしてcardsという新しいリストを作り、このあとの挿入処理で元データを変更しないようにしています。LINE 108: 先頭ページ以外の判定。現在のページが2ページ目以降であるかどうかをindexが0より大きいかで判定しています。

LINE 109: 前ページ先頭商品の再掲。前のページの先頭商品を取り出し、現在のページのカードリストの先頭に挿入することで、意図的に重複カードを再掲しています。LINE 110: 重複カウントの加算。

重複カードを追加したのでrepeatedの値を1増やし、あとで統計情報として表示できるようにしています。LINE 111: 次ページリンクの初期化。次のページへのリンクHTMLを入れる変数next_linkを、まずは空文字列で用意しています。

LINE 112: 次ページ有無の判定。現在のページの次に、まだページが存在するかどうかをindex+1がtotal_pagesより小さいかで確認しています。LINE 113: 次ページリンクHTMLの作成。

次のページが存在する場合に、そのページへ移動するためのリンクタグをnext_linkに組み立てています。LINE 114: ページHTMLの組み立て開始。PAGE_TEMPLATEのformatメソッドを呼び出し、テンプレート内の各プレースホルダーに値を埋め込む処理を開始しています。

LINE 115: 現在ページ番号の埋め込み。テンプレートのpage部分に、1から始まる現在のページ番号を渡しています。LINE 116: 総ページ数の埋め込み。

テンプレートのtotal部分に、先ほど求めた総ページ数total_pagesを渡しています。LINE 117: 商品カード群の埋め込み。cardsに含まれる各商品を_card_htmlで文字列化し、改行でつなげたものをテンプレートのcards部分に渡しています。

LINE 118: 次ページリンクの埋め込み。先ほど作成したnext_linkをテンプレートのnext_link部分に渡し、ページ下部のナビゲーション表示に反映させています。LINE 119: format呼び出しの終了。

PAGE_TEMPLATE.formatの呼び出しの括弧を閉じ、完成したページ全体のHTML文字列をhtmlに代入しています。LINE 120: 一覧ページファイルの書き出し。組み立てたHTMLを、ページ番号に応じたファイル名でrootディレクトリ内に書き出しています。

文字コードはUTF-8を指定しています。LINE 122: 全ページに対する繰り返し開始。詳細ページを生成するために、pages_dataに含まれる各ページの商品リストを順に取り出す繰り返し処理を開始しています。

LINE 123: ページ内商品の繰り返し開始。1ページ分の商品リストrowsから、商品を1件ずつ取り出す内側の繰り返し処理を開始しています。LINE 124: 詳細ページHTMLの組み立て。

DETAIL_TEMPLATEに商品辞品の各値を展開して渡し、その商品専用の詳細ページHTMLを作成しています。LINE 125: 詳細ページファイルの書き出し。商品のpidをファイル名に使い、productディレクトリ内に詳細ページのHTMLファイルを書き出しています。

LINE 127: 結果情報の辞書返却開始。init_catalogの呼び出し元に返す、生成結果をまとめた辞書の作成を開始しています。LINE 128: 出力先ディレクトリ情報。

生成したカタログの出力先ディレクトリのパスを文字列として結果に含めています。LINE 129: 総ページ数の記録。生成した一覧ページの総数total_pagesを結果に含め、あとで表示できるようにしています。

LINE 130: 総商品数の記録。各ページの商品件数を合計し、カタログ全体で生成した商品の総数を結果に含めています。LINE 131: 重複カード数の記録。

前ページ先頭の商品を再掲した回数repeatedを結果に含め、意図的な重複の件数を確認できるようにしています。LINE 132: 開始URLの記録。1ページ目のHTMLファイルをファイルURI形式に変換し、クロールを開始するためのURLとして結果に含めています。

LINE 133: 結果辞書の終了。init_catalogが返す結果情報の辞書の定義を閉じ、これで関数の戻り値が完成します。RUN 2/8: 検証用カタログのHTMLを生成する。

init_catalogが一覧ページと詳細ページを書き出せる状態になりました。一時ディレクトリへ生成し、戻り値の件数を見ます。CHECK 2/8: 途中実行に成功。

一覧ページ数: 3 商品件数: 12 重複カード: 2 開始URL: file:///tmp/tmpl6u9nc5n/site/page-1.html RETURN 02: エディターへ戻りました。入力済みのコードを維持したまま、次の実装へ進みます。LINE 137: スキーマ文字列の定義開始。

SQLiteのテーブル定義を記述するための複数行文字列SCHEMAの定義を開始しています。LINE 138: productsテーブルの作成宣言。productsという名前のテーブルを、まだ存在しない場合にのみ作成するSQL文を記述しています。

LINE 139: URL列の定義。商品URLを格納するurl列を、テーブルの主キーとして定義しています。同じURLの商品は重複して登録できなくなります。

LINE 140: 商品名列の定義。商品名を格納するname列を、値が必須のNOT NULL制約付きで定義しています。LINE 141: 価格列の定義。

商品の価格を格納するprice列を整数型で定義しています。LINE 142: 在庫状況列の定義。在庫状況の文字列を格納するstock列をテキスト型で定義しています。

LINE 143: 初回検出日時列の定義。その商品を初めて検出した日時を記録するfirst_seen列を、値が必須のテキスト型として定義しています。LINE 144: 最終検出日時列の定義。

その商品を最後に検出した日時を記録するlast_seen列を、値が必須のテキスト型として定義しています。LINE 145: CREATE TABLE文の終了。テーブル定義の括弧を閉じ、productsテーブルのカラム構成の記述を完了させています。

LINE 146: スキーマ文字列の終了。複数行文字列SCHEMAの定義を終了し、これでテーブル作成用のSQL文が完成した状態になります。LINE 147: CSV出力列の定義。

CSVへ出力する際の列名と順序を、タプルCSV_COLUMNSとしてまとめて定義しています。LINE 150: DB接続関数の定義。データベースファイルのパスを受け取り、SQLiteへの接続を確立して返す関数connectを定義しています。

LINE 151: パスオブジェクトへの変換。引数で渡された文字列のパスdb_pathをPathオブジェクトに変換し、以降のファイル操作で扱いやすくしています。LINE 152: 保存先ディレクトリの作成。

DBファイルを置く親ディレクトリが存在しない場合に自動で作成します。exist_ok=Trueにしているので、既にディレクトリがあってもエラーにならず安全です。LINE 153: SQLiteへの接続。

指定したパスのSQLiteファイルに接続し、ファイルが無ければ新規作成します。以降の処理ではこのconnオブジェクトを使ってデータベースを操作します。LINE 154: 行の取得形式の設定。

取得した行をsqlite3.Row形式にすることで、カラム名を使って値を取り出せるようにします。これによりrow["name"]のように読みやすくアクセスできます。LINE 155: テーブル作成の実行。

あらかじめ定義したSCHEMAを実行し、productsテーブルが無ければ作成します。既にテーブルがある場合は何も変更されません。LINE 156: 変更の確定。

テーブル作成などの変更をデータベースに確定させます。commitを行わないと変更が保存されないことがあるための処理です。LINE 157: 接続オブジェクトの返却。

作成・準備済みの接続オブジェクトを呼び出し元に返します。これ以降の関数はこの接続を使ってデータの読み書きを行います。RUN 3/8: SQLiteのテーブルが作られるか確認する。

connectでデータベースへ接続し、スキーマを流すところまで入力しました。作成されたテーブル名と列名を確認します。CHECK 3/8: 途中実行に成功。

テーブル: ['products'] 列: ['url', 'name', 'price', 'stock', 'first_seen', 'last_seen'] RETURN 03: エディターへ戻りました。入力済みのコードを維持したまま、次の実装へ進みます。LINE 160: 商品保存関数の定義。

商品データをSQLiteへ登録・更新するための関数を定義します。戻り値は登録か更新かを示す文字列になります。LINE 161: 関数の説明コメント。

この関数がURLをキーにして既存データを更新するか、新規に追加するかを説明する文字列です。処理の意図を明確にする役割を持ちます。LINE 162: 現在時刻の取得。

現在の日時を秒単位の文字列として取得します。この値は商品情報の最終確認日時として保存されます。LINE 163: 既存レコードの検索。

保存対象の商品URLが既にテーブルに存在するかどうかを検索します。取得結果はfoundに格納され、存在確認の判定に使われます。LINE 164: 既存レコードの有無判定。

検索結果が存在する場合、つまり同じURLの商品が既に登録済みかどうかを判定します。存在する場合は更新処理に進みます。LINE 165: 更新処理の開始。

既存の商品レコードを最新の情報で更新するSQLを実行します。name・price・stock・last_seenの値が書き換えられます。LINE 166: UPDATE文とパラメータ。

名前・価格・在庫・最終確認日時を更新するSQL文と、その値を渡しています。URLをキーに対象の行を特定して更新します。LINE 167: 更新パラメータの続き。

UPDATE文に渡す実際の値をタプルでまとめています。最後のURLはWHERE句の条件として使われます。LINE 168: 更新処理の終端。

execute呼び出しの括弧を閉じ、更新SQLの実行を完了させます。この時点でデータベース上のレコードが更新されます。LINE 169: 更新結果の返却。

更新処理が行われたことを示す文字列"updated"を返します。呼び出し元はこの戻り値で処理結果を判別します。LINE 170: 新規登録処理の開始。

URLが未登録だった場合に実行される新規登録用のSQLです。ここから新しい商品レコードを追加する処理が始まります。LINE 171: INSERT文の定義。

新規商品を登録するためのINSERT文を定義しています。url・name・price・stock・first_seen・last_seenの各列に値を挿入します。LINE 172: INSERT文の続き。

INSERT文のVALUES部分を記述しており、挿入する値の数を示すプレースホルダーが並んでいます。次の行で実際の値が渡されます。LINE 173: INSERT値の指定。

新規登録する商品の各項目を順番に渡しています。first_seenとlast_seenには同じ現在時刻nowが設定されます。LINE 174: 新規登録処理の終端。

execute呼び出しの括弧を閉じ、INSERT文の実行を完了させます。これにより新しい商品レコードがテーブルに追加されます。LINE 175: 新規登録結果の返却。

新規登録が行われたことを示す文字列"inserted"を返します。呼び出し元はこの値で保存件数の集計などに利用します。RUN 4/8: 同じURLの保存がUPDATEになるか試す。

save_productまで入力できました。同じ商品URLを二度保存し、戻り値と保存件数の変化を確かめます。CHECK 4/8: 途中実行に成功。

1回目: inserted 2回目: updated 保存件数: 1 最新の価格と在庫: 1500残りわずか RETURN 04: エディターへ戻りました。入力済みのコードを維持したまま、次の実装へ進みます。LINE 179: 価格文字列変換関数の定義。

抽出した価格テキストを整数に変換するための関数を定義します。数値以外の文字が混ざっていても正しく処理できるようにします。LINE 180: 数字だけの抽出。

渡された文字列から数字だけを取り出して連結します。¥記号やカンマなど数値以外の文字を取り除くための処理です。LINE 181: 整数への変換と判定。

数字が1文字以上あれば整数に変換して返し、数字が無ければNoneを返します。価格が取得できなかった場合の安全な処理です。LINE 184: 価格表示整形関数の定義。

価格の値を表示用の文字列に整形する関数を定義します。画面出力時に見やすい形式へ変換する役割を持ちます。LINE 185: 価格の表示形式変換。

値がNoneの場合はハイフンを、そうでなければ¥記号とカンマ区切りの数値を返します。価格情報の有無を分かりやすく表示するための処理です。RUN 5/8: 価格文字列の変換を確かめる。

抽出したテキストを整える関数がそろいました。記号や桁区切りを含む文字列から数値が取り出せるか確認します。CHECK 5/8: 途中実行に成功。

12800 1000 None ¥12,800 - RETURN 05: エディターへ戻りました。入力済みのコードを維持したまま、次の実装へ進みます。LINE 189: Spiderクラスの定義。

商品一覧ページを巡回して商品情報を抽出するScrapyのSpiderクラスを定義します。以降のメソッドでクロール処理の詳細を実装します。LINE 190: Spider名の設定。

このSpiderを識別するための名前を設定します。Scrapyの実行時にこの名前を使ってSpiderを指定できます。LINE 192: 初期化メソッドの定義。

Spiderの初期化処理を行うメソッドを定義します。クロールを開始するURLを外部から受け取れるようにしています。LINE 193: 親クラスの初期化呼び出し。

親クラスであるscrapy.Spiderの初期化処理を呼び出します。Scrapyの内部機能を正しく使うために必要な処理です。LINE 194: 開始URLの設定。

渡されたstart_urlをリストとしてstart_urlsに設定します。値が無い場合は空リストになり、クロール対象が指定されていない状態を表します。LINE 196: parseメソッドの定義。

取得したレスポンスを解析するメソッドを定義します。このメソッドが商品情報の抽出とページ送りの処理を行います。LINE 197: ページ数統計の加算。

巡回したページ数を統計情報に1件加算します。最終的にクロール結果として何ページ処理したかを確認できるようにします。LINE 198: 商品カードの繰り返し処理。

レスポンス内のarticle.product要素をすべて取得し、1件ずつ繰り返し処理します。各商品カードから情報を抽出していきます。LINE 199: 詳細リンクの取得。

商品カード内の詳細ページへのリンクURLを取得します。この値が無いと商品URLが特定できないため、後続の処理で確認されます。LINE 200: リンク不在時の判定。

詳細リンクが取得できなかった場合を判定します。リンクが無い不正なカードを除外するための条件分岐です。LINE 201: 処理のスキップ。

リンクが取得できなかった商品カードの処理をスキップします。不完全なデータを後続処理に渡さないための安全策です。LINE 202: カード数統計の加算。

有効な商品カードを1件処理したことを統計情報に加算します。抽出できた商品カードの総数を後で確認できます。LINE 203: 商品データの生成開始。

抽出した商品情報を辞書としてyieldする処理を開始します。この辞書がScrapyのItem PipelineへItemとして渡されます。LINE 204: 商品名の抽出。

商品カードから商品名のテキストを取得し、前後の空白を取り除きます。値が取得できない場合は空文字を使うようにしています。LINE 205: 価格の抽出と変換。

商品カードから価格テキストを取得し、parse_price関数で整数に変換します。テキストが無い場合は空文字を渡して安全に処理します。LINE 206: 在庫状況の抽出。

商品カードから在庫状況のテキストを取得し、前後の空白を取り除きます。値が取得できない場合は空文字を使います。LINE 207: 商品URLの絶対化。

相対パスの詳細リンクを、レスポンスの基準URLと結合して絶対URLに変換します。これによりどのページからでも同じ形式のURLが得られます。LINE 208: 商品データ辞書の終端。

yieldする辞書の定義を閉じます。この辞書がひとつの商品Itemとしてパイプラインへ送られます。LINE 210: 次ページリンクの取得。

一覧ページ内にある次ページへのリンクを取得します。取得できなければ最後のページであることを意味します。LINE 211: 次ページ有無の判定。

次ページへのリンクが存在するかどうかを判定します。存在する場合のみ次ページへの巡回処理を続けます。LINE 212: 次ページへの遷移。

取得した次ページのリンクへ遷移するリクエストを生成し、再度parseメソッドをコールバックとして呼び出します。これにより全ページを再帰的に巡回できます。RUN 6/8: Spiderの開始URLを確認する。

ProductCatalogSpiderの定義が終わりました。開始URLを渡して生成し、名前と初期状態を確認します。CHECK 6/8: 途中実行に成功。

Spider名: product_catalog 開始URL: ['file:///demo/page-1.html'] parseが呼べるか: True RETURN 06: エディターへ戻りました。入力済みのコードを維持したまま、次の実装へ進みます。LINE 216: パイプラインクラスの定義。

抽出した商品ItemをSQLiteへ保存するためのパイプラインクラスを定義します。重複チェックや保存処理をこのクラスで担います。LINE 217: パイプライン初期化メソッド。

パイプラインの初期化処理を行うメソッドを定義します。DBパスと統計情報オブジェクトを受け取って初期状態を設定します。LINE 218: DBパスの保持。

渡されたデータベースファイルのパスをインスタンス変数に保持します。後でconnectする際にこのパスを使用します。LINE 219: 統計情報の保持。

Scrapyの統計情報オブジェクトをインスタンス変数に保持します。処理件数のカウントなどに利用されます。LINE 220: 接続オブジェクトの初期化。

データベース接続を保持する変数を初期化し、まだ接続していない状態を表すNoneを設定します。open_spiderで実際の接続が作られます。LINE 221: 重複判定用集合の初期化。

同一クロール内で処理済みのURLを記録するための空の集合を用意します。この集合を使って重複商品を検出します。LINE 223: クラスメソッドの指定。

from_crawlerメソッドをクラスメソッドとして定義するためのデコレーターです。インスタンス化前にクローラー情報からパイプラインを生成できるようにします。LINE 224: from_crawlerメソッドの定義。

Scrapyがパイプラインを生成する際に呼び出すクラスメソッドを定義します。設定値や統計情報オブジェクトを受け取ります。LINE 225: パイプラインインスタンスの生成。

設定からCATALOG_DBの値を取得し、統計情報とともに新しいパイプラインインスタンスを生成して返します。この処理でDBパスがパイプラインに渡されます。LINE 227: Spider開始時処理の定義。

Spiderの実行開始時に呼び出されるメソッドを定義します。データベース接続の準備などをここで行います。LINE 228: データベース接続の確立。

保持しているDBパスを使ってSQLiteへ接続し、インスタンス変数connに保存します。以降のItem保存処理でこの接続を使用します。LINE 229: 重複判定集合のリセット。

クロール開始時に重複チェック用の集合を空の状態にリセットします。前回のクロールの状態が残らないようにするための処理です。LINE 231: Item処理メソッドの定義。

各商品Itemを受け取って処理するメソッドを定義します。重複チェックと保存処理を行い、最終的にItemを返します。LINE 232: 商品URLの取り出し。

受け取ったItemから商品URLを取り出します。この値が重複判定や保存処理のキーとして使われます。LINE 233: 重複判定。

同一クロール内で既に処理済みのURLかどうかを判定します。既に処理済みであれば重複した商品カードとして扱われます。LINE 234: 重複件数の加算。

重複と判定された商品の件数を統計情報に加算します。最終的にクロール結果として重複スキップ件数を確認できるようにします。LINE 235: 重複URLの例外送出。

同じクロール内で既に処理済みのURLだった場合に、DropItem例外を発生させてこの商品データを取り込み対象から除外します。エラーメッセージには対象のURLを含めて、どの商品が重複していたかを分かりやすくしています。LINE 236: 処理済みURLの記録。

重複チェック用の集合seenに今回処理したURLを追加します。次回以降に同じURLが来たときに重複と判定できるようにするための記録です。LINE 237: 保存結果の統計を記録。

save_product関数を呼び出して商品データをデータベースへ保存し、その戻り値(insertedまたはupdated)に応じた統計値をカウントアップします。新規登録と既存更新のどちらが行われたかをここで集計しています。LINE 238: アイテムの返却。

処理済みのitemをそのまま返し、Scrapyのパイプライン処理を継続させます。後続のパイプラインが存在する場合はこのitemが引き渡されます。LINE 240: スパイダー終了時の処理定義。

クロールが終了したタイミングでScrapyが呼び出すメソッドを定義しています。ここでデータベース接続の後始末を行います。LINE 241: 接続が有効かの確認。

self.connがNoneでない、つまりデータベース接続がまだ開いている場合にだけ後続の処理を実行するための条件分岐です。LINE 242: 変更内容のコミット。これまでにINSERTやUPDATEした内容をデータベースファイルへ確定して書き込みます。

コミットしないと変更が保存されないため必要な処理です。LINE 243: データベース接続のクローズ。使い終わったデータベース接続を閉じて、リソースを解放します。

LINE 244: 接続変数のクリア。self.connをNoneに戻して、接続が既に閉じられていることを分かりやすくしておきます。LINE 248: クロール実行関数の定義。

開始URLとデータベースパスを受け取り、クロールを実行して結果をまとめた辞書を返す関数を定義しています。LINE 249: Windows環境の判定。実行しているOSがWindowsかどうかを判定する条件分岐です。

Windows特有の非同期処理の設定が必要かどうかをここで確認しています。LINE 250: イベントループポリシーの設定。Windows環境でScrapyが正しく非同期処理を行えるように、専用のイベントループポリシーを設定しています。

LINE 252: データベースパスの絶対化。指定されたデータベースパスを絶対パスの文字列に変換します。相対パスのままだと実行場所によって参照先がずれてしまうため、明確なパスに揃えています。

LINE 253: クローラープロセスの生成開始。Scrapyのクロールを実行するためのCrawlerProcessオブジェクトを生成し始めています。ここに続く設定を渡して初期化します。

LINE 254: 設定辞書の開始。CrawlerProcessに渡す各種設定をまとめた辞書の定義を開始しています。LINE 255: ログレベルの設定。

ログ出力のレベルをERRORに設定し、通常のクロール中に発生する情報ログなどを表示しないようにしています。LINE 256: robots.txt遵守の無効化。ローカルの検証用サイトを対象としているため、robots.txtのルールを無視してクロールできるように設定しています。

LINE 257: テルネットコンソールの無効化。Scrapyのデバッグ用テルネットコンソール機能を無効にし、不要な待ち受けポートが開かれないようにしています。LINE 258: アイテムパイプラインの登録。

作成したSQLitePipelineクラスを、優先度300で有効なアイテムパイプラインとして登録しています。これによりクロールで取得した商品データがこのパイプラインを通ってデータベースへ保存されます。LINE 259: データベースパスの設定注入。

Scrapyの設定にCATALOG_DBというキーでデータベースファイルのパスを渡し、パイプライン側のfrom_crawlerで取得できるようにしています。LINE 260: 設定辞書の終端。CrawlerProcessに渡す設定辞書の定義を閉じています。

LINE 261: CrawlerProcess呼び出しの終端。CrawlerProcessの生成呼び出しを閉じ、processという実行環境オブジェクトが用意されます。LINE 262: クローラーインスタンスの作成。

ProductCatalogSpiderを使ってクロール処理の実体であるcrawlerオブジェクトを作成しています。これによりクロール後に統計情報などへアクセスできるようになります。LINE 263: クロールのスケジュール登録。

作成したcrawlerに開始URLを渡してクロールの実行をスケジュールに登録しています。まだ実際には走り出していません。LINE 264: クロールプロセスの開始。

登録済みのクロール処理を実際に開始し、完了するまで処理をブロックして待機します。この行が終わるとクロールが完了しています。LINE 266: クロール統計情報の取得。

クロール中にカウントされた各種統計値(ページ数やカード数など)を辞書形式で取得しています。LINE 267: 集計用データベース接続の作成。クロール結果を確認するために、connect関数を使って改めてデータベースへ接続しています。

LINE 268: 保存件数の取得。productsテーブルの総件数をSELECT COUNT(*)で取得し、現在データベースに何件の商品が保存されているかを調べています。LINE 269: 確認用接続のクローズ。

件数確認のために開いたデータベース接続を閉じ、リソースを解放しています。LINE 270: 結果辞書の返却開始。クロール結果をまとめた辞書を関数の戻り値として返し始めています。

呼び出し元がこの辞書を使って結果を表示します。LINE 271: 開始URLの格納。クロールに使用した開始URLを結果辞書に格納し、後で結果表示に利用できるようにしています。

LINE 272: データベースパスの格納。クロール結果として、保存先のデータベースファイルパスを結果辞書に含めています。LINE 273: 巡回ページ数の格納。

統計情報からcatalog/pagesの値を取り出し、巡回した一覧ページ数として結果辞書に格納しています。値が存在しない場合は0を使います。LINE 274: 抽出カード数の格納。

統計情報からcatalog/cardsの値を取り出し、抽出した商品カードの件数として結果辞書に格納しています。LINE 275: 重複スキップ数の格納。統計情報からcatalog/duplicate_skippedの値を取り出し、同一クロール内で重複と判定されスキップした件数を結果辞書に格納しています。

LINE 276: 新規登録件数の格納。統計情報からcatalog/insertedの値を取り出し、新規にデータベースへ登録された商品の件数を結果辞書に格納しています。LINE 277: 既存更新件数の格納。

統計情報からcatalog/updatedの値を取り出し、既存レコードが更新された件数を結果辞書に格納しています。LINE 278: 総保存件数の格納。先に取得したデータベース内の総商品件数を結果辞書に格納し、最終的な保存状況を表せるようにしています。

LINE 279: 結果辞書の終端。crawl_catalog関数が返す辞書リテラルの定義を閉じています。LINE 283: initサブコマンド処理の定義。

init-catalogサブコマンドが実行されたときに呼ばれる関数を定義しています。引数argsにはコマンドラインで指定された値が入っています。LINE 284: 検証用カタログの生成呼び出し。

init_catalog関数を呼び出して検証用商品カタログのHTMLファイル群を実際に生成し、その結果情報をinfoに受け取っています。LINE 285: 生成先ディレクトリの表示。検証用商品カタログを生成した旨と、生成先のディレクトリパスを画面に表示しています。

LINE 286: ページ数と商品数の表示。生成された一覧ページの数と商品の総数を画面に表示し、生成結果の規模を伝えています。LINE 287: 重複カード件数の表示。

前ページ先頭の商品が再掲された件数、つまり意図的に作った重複カード数を画面に表示しています。LINE 288: 開始URLの表示。クロール時に使用する開始URLを画面に表示し、次にcrawlサブコマンドを実行する際の目印としています。

LINE 291: crawlサブコマンド処理の定義。crawlサブコマンドが実行されたときに呼ばれる関数を定義しています。LINE 292: 開始ファイルパスの組み立て。

引数で指定されたサイトディレクトリを絶対パスに変換し、その中のpage-1.htmlのパスを組み立てています。LINE 293: 開始ファイルの存在確認。組み立てたpage-1.htmlが実際に存在するかどうかを確認する条件分岐です。

存在しない場合はクロールを行わずに案内を表示します。LINE 294: 未生成時のエラー表示。検証用商品カタログのファイルが見つからない旨のメッセージを、対象パスとともに画面に表示しています。

LINE 295: 生成コマンドの案内表示。先にinit-catalogサブコマンドでカタログを生成する必要がある旨を利用者へ案内しています。LINE 296: 処理の中断。

カタログが存在しないためクロール処理を行わずに関数の処理を終了させています。LINE 297: クロール処理の実行。start_fileのURI形式のURLとargs.dbを渡してcrawl_catalog関数を呼び出し、実際のクロールを実行してその結果をresultに受け取っています。

LINE 298: 開始URLの表示。クロールに使用した開始URLを結果resultから取り出して画面に表示しています。LINE 299: 巡回ページ数の表示。

クロールで巡回した一覧ページの数を画面に表示しています。LINE 300: 抽出カード数と重複スキップ数の表示。抽出した商品カードの件数と、そのうち重複としてスキップした件数をまとめて画面に表示しています。

LINE 301: 新規登録と既存更新件数の表示。新規に登録された件数と既存レコードとして更新された件数を画面に表示し、クロール結果の内訳を伝えています。LINE 302: SQLite保存件数の表示。

データベースへの総保存件数と、その保存先ファイルパスを画面に表示しています。LINE 305: listサブコマンド処理の定義。listサブコマンドが実行されたときに呼ばれる関数を定義しています。

保存済み商品の一覧を表示する処理です。LINE 306: データベース接続の取得。connect関数を呼び出して、引数で指定されたデータベースファイルへの接続を取得しています。

LINE 307: 一覧取得クエリの開始。商品情報を価格の高い順、同じ価格なら名前順に取得するSELECT文の実行を開始しています。LINE 308: 表示件数の制限値指定。

クエリのLIMITに渡すパラメータとして、引数で指定された表示件数args.limitを渡しています。LINE 309: クエリ実行と結果取得。組み立てたSQL文を実行し、fetchallで結果を全件取得してrowsに格納しています。

LINE 310: 総件数の取得。productsテーブルの総件数をあらためて取得し、一覧表示件数と区別できるようにtotalへ格納しています。LINE 311: データベース接続のクローズ。

一覧表示に使用したデータベース接続を閉じ、リソースを解放しています。LINE 312: DB接続のクローズ。listサブコマンドで使い終えたSQLite接続を閉じています。

件数取得と一覧取得が終わったタイミングで確実に接続を解放します。LINE 314: DBファイルパスの表示。参照しているSQLiteファイルの絶対パスを画面に表示しています。

どのファイルを見ているのかを利用者に分かりやすく伝えます。LINE 315: 保存件数と表示件数の表示。テーブル全体の保存件数と、今回表示する件数を価格の高い順であることと合わせて表示しています。

全体件数と表示範囲の違いを利用者に知らせます。LINE 316: 商品0件の判定。取得した行が空かどうかを判定しています。

商品が1件も無い場合は後続の一覧表示を行わないようにする分岐です。LINE 317: 商品なし時の案内表示。商品が保存されていない場合に、先にcrawlサブコマンドを実行するよう案内するメッセージを表示しています。

LINE 318: 一覧表示処理の中断。商品が0件のため、これ以降の一覧表示処理を行わずに関数を終了させています。LINE 319: 取得結果のループ開始。

取得した商品行を1件ずつ処理するために、enumerateで1から始まる連番を付けてループしています。表示時に番号を振るための準備です。LINE 320: 商品情報の1行表示。

連番・商品名・整形済み価格・在庫状況を1行にまとめて表示しています。yen関数で価格を読みやすい表記に変換しています。LINE 321: 商品URLの表示。

直前で表示した商品に対応するURLを、インデントを付けて次の行に表示しています。商品情報とURLを視覚的に紐づけています。RUN 7/8: 保存済み商品の一覧表示を試す。

cmd_listまで入力しました。サンプルを保存したデータベースを用意し、一覧表示の書式を確認します。CHECK 7/8: 途中実行に成功。

SQLite: /tmp/tmpeli479iy/list.db 保存件数: 3件 / 表示: 3件(価格の高い順) 1.ワイヤレスマウスPB-002 / ¥22,360 / 在庫切れ file:///demo/product/p002.html 2.アルミ弁当箱PB-003 / ¥19,020 / 在庫あり file:///demo/product/p003.html 3.折りたたみ傘PB-001 / ¥8,080 / 在庫あり file:///demo/product/p001.html RETURN 07: エディターへ戻りました。

入力済みのコードを維持したまま、次の実装へ進みます。LINE 324: statsサブコマンドの定義。件数・価格・在庫状況を集計して表示するcmd_stats関数を定義しています。

statsサブコマンド実行時に呼ばれる処理の入り口です。LINE 325: DBへの接続。引数で指定されたSQLiteファイルに接続し、スキーマ作成済みの接続オブジェクトを取得しています。

以降の集計クエリで使用します。LINE 326: 集計クエリの実行開始。件数・平均価格・最安値・最高値・最終更新日時を1回のSQLで集計するクエリを実行しています。

結果はまとめて1行で取得します。LINE 327: 集計項目の指定。COUNT・AVG・MIN・MAXの各集約関数で件数と価格の統計値を指定しています。

SQL文の前半部分にあたります。LINE 328: 最終更新日時の集計指定。最大価格と最終更新日時の最大値をproductsテーブルから取得するSQL文の続きです。

1件のみ結果が返るよう集約しています。LINE 329: 集計結果の取得。実行したSQLの結果を1行だけ取得しています。

fetchone()により、集計結果が1つのRowオブジェクトとして受け取れます。LINE 330: 在庫別集計クエリの実行開始。在庫状況ごとの件数と平均価格を集計するクエリを実行しています。

summaryとは別に在庫別の内訳を得るための処理です。LINE 331: 在庫別の集計項目とグルーピング。stock列でグループ化し、件数と平均価格を計算するSQL文を記述しています。

SELECT句とGROUP BYの前半部分です。LINE 332: 並び順の指定。件数の多い順、同数の場合は在庫状況名の順に並べるようORDER BYを指定しています。

SQL文の続きにあたります。LINE 333: 在庫別集計結果の取得。実行したSQLの結果を全件取得しています。

fetchall()により、在庫状況ごとの集計行がリストとして得られます。LINE 334: DB接続のクローズ。統計情報の取得が終わったSQLite接続を閉じています。

以降の処理はメモリ上のデータだけで完結します。LINE 336: DBファイルパスの表示。参照しているSQLiteファイルの絶対パスを画面に表示しています。

集計対象のファイルを利用者に明示します。LINE 337: 集計対象0件の判定。summaryの件数nが0かどうかを判定しています。

商品が1件も無い場合は以降の集計表示を行わないための分岐です。LINE 338: 集計対象なし時の案内表示。商品が保存されていない場合に、先にcrawlサブコマンドを実行するよう案内するメッセージを表示しています。

LINE 339: 集計表示処理の中断。集計対象が0件のため、これ以降の集計表示処理を行わずに関数を終了させています。LINE 340: 商品件数の表示。

summaryから取得した商品の総件数を表示しています。集計結果のうち最初の項目にあたります。LINE 341: 価格統計表示の開始。

平均・最安値・最高値をまとめて1行で表示するprint文を開始しています。複数行の文字列を連結して1つのメッセージを組み立てています。LINE 342: 平均価格の整形表示。

summaryの平均価格を四捨五入し、yen関数で¥表記に整形した文字列を組み立てています。価格統計表示の前半部分です。LINE 343: 最安値と最高値の整形表示。

最安値と最高値をyen関数で¥表記に整形し、平均価格に続けて表示する文字列を組み立てています。価格統計表示の後半部分です。LINE 344: 在庫状況内訳の見出し表示。

これから在庫状況ごとの件数と平均価格を表示することを示す見出しを表示しています。LINE 345: 在庫状況内訳の見出し表示。これから在庫状況ごとの件数と平均価格を表示することを示す見出しを表示しています。

LINE 346: 在庫別集計結果のループ。in-stock別に集計した各行を1件ずつ処理するループです。以降で在庫状況ごとの明細を表示します。

LINE 347: 在庫別明細の表示。在庫状況名を左詰め、件数を右詰めで揃えて表示し、その在庫状況の平均価格をyen関数で整形して続けています。見やすく列を揃えた1行表示です。

LINE 348: 最終更新日時の表示。summaryから取得した最終更新日時を表示しています。全商品の中で最も新しいlast_seenの値です。

LINE 351: exportサブコマンドの定義。保存済み商品をCSVへ出力するcmd_export関数を定義しています。exportサブコマンド実行時に呼ばれる処理の入り口です。

LINE 352: DBへの接続。引数で指定されたSQLiteファイルに接続し、CSV出力用のデータ取得に使う接続オブジェクトを取得しています。LINE 353: 全件取得クエリの実行開始。

CSVに出力する全商品データを取得するSELECT文を実行しています。CSV_COLUMNSで指定した列のみを対象とします。LINE 354: 出力列の指定と並び順。

CSV_COLUMNSをカンマ区切りで結合して選択列を指定し、商品URLの昇順で並べ替えるSQL文を組み立てています。LINE 355: 全件取得結果の受け取り。実行したSQLの結果を全件取得しています。

fetchall()により、CSV出力対象の全行がリストとして得られます。LINE 356: DB接続のクローズ。CSV出力に必要なデータ取得が終わったSQLite接続を閉じています。

以降の処理はファイル書き込みが中心になります。LINE 358: 出力先パスの解決。引数で指定された出力先ファイルパスを絶対パスに変換しています。

相対パス指定でも正しい場所へ出力できるようにします。LINE 359: 出力先フォルダの作成。出力先ファイルの親ディレクトリが存在しない場合に作成しています。

事前にフォルダを用意しておくことで書き込みエラーを防ぎます。LINE 360: CSVファイルのオープン。出力先ファイルを書き込みモードで開いています。

改行やエンコーディングをCSV出力に適した設定にしています。LINE 361: CSVライターの作成。開いたファイルハンドルに対してcsvモジュールのwriterオブジェクトを作成しています。

以降このwriterで行を書き込みます。LINE 362: ヘッダー行の書き込み。CSV_COLUMNSの列名をヘッダー行として書き込んでいます。

出力されるCSVの1行目に列名が入ります。LINE 363: 取得行のループ開始。取得した商品データの行を1件ずつ処理するループです。

以降で各行をCSVに書き込みます。LINE 364: データ行の書き込み。CSV_COLUMNSの順に各列の値を取り出し、1行分のデータとしてCSVへ書き込んでいます。

列の順序を揃えて出力するための処理です。LINE 366: 出力完了メッセージの表示。CSVファイルの出力が完了したことと、その出力先パスを表示しています。

LINE 367: 出力結果の詳細表示。出力した行数と列名の一覧を表示しています。利用者が出力内容を確認しやすくするための表示です。

LINE 371: パーサー構築関数の定義。CLIの引数解析を行うArgumentParserを組み立てるbuild_parser関数を定義しています。この関数がCLI全体の入口となる設定を作ります。

LINE 372: ArgumentParserの生成開始。argparseのArgumentParserインスタンスを生成しています。プログラム名や説明文をここで設定していきます。

LINE 373: プログラム名の指定。CLI実行時に表示されるプログラム名としてcatalog_crawlerを指定しています。ヘルプ表示などに使われます。

LINE 374: CLIの説明文の指定。このツールがどんなものかを説明する文言を設定しています。--helpで表示される説明文になります。

LINE 375: ArgumentParser生成の完了。ArgumentParserの生成に渡す引数の指定を閉じています。ここまでの設定でパーサー本体が作られます。

LINE 376: サブコマンドの追加設定。init-catalogやcrawlなどのサブコマンドを扱うためのサブパーサーを追加しています。commandという名前でどのサブコマンドが選ばれたかを保持し、指定必須にしています。

LINE 378: init-catalogサブコマンドの追加。検証用商品カタログのHTMLを生成するinit-catalogサブコマンドを登録しています。ヘルプメッセージも合わせて設定しています。

LINE 379: site-dir引数の追加。生成先ディレクトリを指定する必須引数--site-dirをinit-catalogサブコマンドに追加しています。LINE 380: pages引数の追加。

生成する一覧ページ数を指定する--pages引数を追加しています。指定がなければ既定値の8ページになります。LINE 381: per-page引数の追加。

1ページあたりの商品数を指定する--per-page引数を追加しています。指定がなければ既定値の12件になります。LINE 382: 実行関数の紐付け。

init-catalogサブコマンドが選ばれたときに呼び出す関数としてcmd_initを設定しています。LINE 384: crawlサブコマンドの追加。次ページを追跡して全商品をSQLiteへ保存するcrawlサブコマンドを登録しています。

ヘルプメッセージも合わせて設定しています。LINE 385: site-dir引数の追加。クロール対象の検証用商品カタログディレクトリを指定する必須引数--site-dirをcrawlサブコマンドに追加しています。

LINE 386: db引数の追加。保存先のSQLiteファイルを指定する--db引数を追加しています。指定がなければ既定値のcatalog.dbが使われます。

LINE 387: 実行関数の紐付け。crawlサブコマンドが選ばれたときに呼び出す関数としてcmd_crawlを設定しています。LINE 389: listサブコマンドの追加。

保存済みの商品一覧を表示するlistサブコマンドを登録しています。ヘルプメッセージも合わせて設定しています。LINE 390: listサブコマンドのDB引数を定義。

listサブコマンドに--dbオプションを追加しています。省略した場合はcatalog.dbが使われるので、DBファイル名を指定しなくても一覧表示を実行できます。LINE 391: listサブコマンドの表示件数引数を定義。

表示する商品の件数を指定する--limitオプションを追加しています。型はint、デフォルトは10件なので、指定しない場合は上位10件が表示されます。LINE 392: listサブコマンドの処理関数を登録。

listサブコマンドが実行されたときに呼び出す処理としてcmd_list関数を登録しています。これによりCLI実行時にargs.funcからcmd_listが呼ばれます。LINE 394: statsサブコマンドを追加。

件数・価格・在庫状況を集計するstatsサブコマンドを新たに追加しています。ヘルプ文で機能内容が表示されるようになります。LINE 395: statsサブコマンドのDB引数を定義。

statsサブコマンドに--dbオプションを追加しています。集計対象のSQLiteファイルを指定でき、省略時はcatalog.dbが使われます。LINE 396: statsサブコマンドの処理関数を登録。

statsサブコマンドが実行されたときの処理としてcmd_stats関数を登録しています。これにより件数や価格の集計処理が呼び出されます。LINE 398: exportサブコマンドを追加。

保存済みの商品をCSVへ出力するexportサブコマンドを追加しています。ヘルプ文にCSV出力機能であることが表示されます。LINE 399: exportサブコマンドのDB引数を定義。

exportサブコマンドに--dbオプションを追加しています。出力元となるSQLiteファイルを指定でき、省略時はcatalog.dbが使われます。LINE 400: exportサブコマンドの出力先引数を定義。

出力するCSVファイル名を指定する--outオプションを追加しています。省略した場合はproducts.csvという名前で出力されます。LINE 401: exportサブコマンドの処理関数を登録。

exportサブコマンドが実行されたときの処理としてcmd_export関数を登録しています。これによりCSV出力処理が呼び出されます。LINE 403: 組み立てたパーサーを返す。

ここまでで作成したサブコマンドやオプションをすべて含んだargparseのパーサーを呼び出し元へ返しています。この戻り値がCLI全体の解析に使われます。RUN 8/8: サブコマンドの解析結果を見る。

build_parserでCLIの定義が完成しました。引数を渡して解析し、選ばれたコマンドと既定値を確認します。CHECK 8/8: 途中実行に成功。

コマンド: list / DB: catalog.db / 表示件数: 5 コマンド: init-catalog / ページ数: 8 / 1ページ件数: 12 RETURN 08: エディターへ戻りました。入力済みのコードを維持したまま、次の実装へ進みます。LINE 406: CLI全体のエントリ関数を定義。

コマンドライン引数のリストを受け取り、実際の処理を実行するmain関数を定義しています。argvを省略した場合は通常のコマンドライン入力が使われます。LINE 407: コマンドライン引数を解析。

build_parserで組み立てたパーサーを使い、渡された引数を解析してargsオブジェクトを取得しています。ここでどのサブコマンドが選ばれたかが決まります。LINE 408: 選択されたサブコマンド処理を実行。

解析結果に紐づいたfunc属性、つまり選択されたサブコマンドの処理関数をargsを渡して呼び出しています。これによりinitやcrawlなど実際の処理が実行されます。LINE 411: スクリプト直接実行時の判定。

このファイルがモジュールとしてimportされたのではなく、直接スクリプトとして実行されたときだけ以降の処理を行うようにする判定です。LINE 412: main関数を呼び出して起動。スクリプトが直接実行されたときにmain関数を呼び出し、CLIツール全体の処理を開始しています。

ここが実際のプログラムの起点になります。実行1/4: 検証用カタログを生成してクロールする。init-catalogでHTMLを作り、続けてcrawlで一覧ページを巡回します。

巡回ページ数と重複スキップ件数、SQLiteの保存件数が表示されます。確認1/4: 検証用カタログを生成してクロールする。init-catalogでHTMLを作り、続けてcrawlで一覧ページを巡回します。

巡回ページ数と重複スキップ件数、SQLiteの保存件数が表示されます。RETURN 09: エディターへ戻りました。入力済みのコードを維持したまま、次の実装へ進みます。

実行2/4: 保存済みの商品を価格の高い順に一覧する。サンプル商品を保存したデータベースを用意し、listサブコマンドを実行します。保存件数と商品名、商品URLが並びます。

確認2/4: 保存済みの商品を価格の高い順に一覧する。サンプル商品を保存したデータベースを用意し、listサブコマンドを実行します。保存件数と商品名、商品URLが並びます。

RETURN 10: エディターへ戻りました。入力済みのコードを維持したまま、次の実装へ進みます。実行3/4: 在庫状況ごとの集計を確認する。

価格と在庫状況が異なる3件を保存してからstatsサブコマンドを実行します。件数と価格帯、在庫状況の内訳が表示されます。確認3/4: 在庫状況ごとの集計を確認する。

価格と在庫状況が異なる3件を保存してからstatsサブコマンドを実行します。件数と価格帯、在庫状況の内訳が表示されます。RETURN 11: エディターへ戻りました。

入力済みのコードを維持したまま、次の実装へ進みます。実行4/4: 保存済みの商品をCSVへ出力する。サンプルを保存したデータベースからexportサブコマンドでCSVを書き出します。

出力先と行数、先頭行の見出しを確認します。確認4/4: 保存済みの商品をCSVへ出力する。サンプルを保存したデータベースからexportサブコマンドでCSVを書き出します。

出力先と行数、先頭行の見出しを確認します。RETURN 12: エディターへ戻りました。入力済みのコードを維持したまま、次の実装へ進みます。

学習内容のまとめ。start_urlsへ最初に取得するURLを渡すsqlite3.connectでデータベースファイルを開く 前ページ先頭を再掲して重複を意図的に作る クロール対象をローカルのfile URIに限定する 小さく実行確認しながら完成状態まで段階的に組み立てる エンディング。Python研修はCodeCampでご確認ください。

PythonのクローリングフレームワークScrapyとSQLiteとは

今回使用する主要なライブラリについて、役割と使い分けを順番に確認します。

クローリングフレームワークScrapy

Scrapyは、Webページの取得からリンクの追跡、抽出したデータの受け渡しまでを一括して引き受けるPythonのクローリングフレームワークです。取得処理を自分で組み立てる場合、リクエストの順番待ちや例外処理まで自作する必要が出てきます。

Scrapyなら、Spiderというクラスに『どこから始め、何を取り出し、次にどこへ進むか』を書くだけで、取得の制御をフレームワーク側に任せられます。今回の収集CLIでも、CrawlerProcessでSpiderを起動し、抽出結果をItem Pipelineへ流す形にまとめました。

Scrapyでクロール処理を組み立てるときに登場する要素を挙げます

  • start_urlsへ最初に取得するURLを渡す
  • レスポンスごとにparseメソッドが呼ばれる
  • response.cssでCSSセレクタから要素を選ぶ
  • response.followで相対リンクを絶対URLへ直す
  • yieldした辞書がItem Pipelineへ渡る
  • crawler.statsで独自の集計値を増やす
  • CrawlerProcessのsettingsでパイプラインを登録する

標準ライブラリsqlite3とSQLiteの役割

SQLiteは、1つのファイルの中にテーブルを持てる軽量なリレーショナルデータベースです。Pythonにはこれを操作するsqlite3モジュールが標準で入っているため、追加インストールなしでSQLを実行できます。CSVへ都度書き出す方式と違い、主キーや集計関数をそのまま利用できるので、再クロール時の更新や在庫状況ごとの平均価格の算出が短いコードで済みます。

今回の収集CLIでは、商品URLを主キーにしたproductsテーブルを1つだけ用意しました。

sqlite3で保存処理を書くときに押さえておく操作を挙げます

  • sqlite3.connectでデータベースファイルを開く
  • CREATE TABLE IF NOT EXISTSで初回だけ表を作る
  • url列のPRIMARY KEYで同じ商品を二重に持たない
  • 値の埋め込みにプレースホルダを使う
  • row_factoryにsqlite3.Rowを設定して列名で読む
  • COUNTやAVGなどの集計関数をSQL側で計算する
  • 書き込み後にcommitしてからcloseする
さらに詳しく学べるPython研修の詳細はこちら

Python・Scrapyで開発する場合の環境構築

この記事のセットアップ手順と掲載コードは、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 scrapy
.\.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 scrapy
./.venv/bin/python -m pip install pysqlite3-binary
  • SQLiteはPython標準のsqlite3モジュールで使うため追加インストールは不要です。DBファイルは--dbで指定した場所に自動生成されます。
  • ScrapyはTwistedやlxmlを依存に持つため、インストールには少し時間がかかります。
  • ScrapyのCrawlerProcessは1プロセスで1回しか起動できないので、crawlは1回のCLI実行につき1回だけ走る設計にしています。
  • file://スキームはScrapy標準のダウンロードハンドラが処理するため、ローカルHTMLでも外部通信なしでCSSセレクタによる抽出を試せます。
さらに詳しく学べるPython研修の詳細はこちら

商品カタログ収集CLIの要件定義

目的は、ローカルの検証用商品カタログをScrapyで巡回し、商品名・価格・在庫状況・商品URLをSQLiteへ重複なく保存したうえで、一覧・集計・CSV出力までをCLIから確認できるようにすることです。

対象者として、Pythonの基本文法を学び終え、Scrapyによるページネーション巡回とsqlite3への保存を1本のCLIとしてまとめる流れを学びたい人を想定しています。

完成物は、ScrapyのSpiderとItem PipelineでローカルHTMLを巡回し、抽出した商品をSQLiteへ保存してlist・stats・exportで確認できる商品カタログ収集CLIです。

実装へ入る前に、機能・品質・受け入れ条件を分けて確認します。

機能要件

  • init-catalogサブコマンドで検証用商品カタログを生成する
  • --pagesと--per-pageでページ数と1ページの商品数を指定する
  • 2ページ目以降へ前ページ先頭の商品カードを再掲する
  • crawlサブコマンドでpage-1htmlから巡回を始める
  • article.productのカードから商品名と価格を抽出する
  • 在庫状況と詳細ページの商品URLを抽出する
  • a.nextのリンクをたどって次の一覧ページへ進む
  • 商品URLを主キーにproductsテーブルへ保存する
  • 既存の商品URLはUPDATEでlast_seenを更新する
  • 同一クロール内の重複商品URLをDropItemで除外する
  • listサブコマンドで価格の高い順に商品を表示する
  • statsサブコマンドで件数と平均価格を集計する
  • 在庫状況ごとの件数と平均価格を内訳表示する
  • exportサブコマンドで6列のCSVを書き出す
  • クロール結果の新規登録件数と既存更新件数を表示する

非機能要件

  • クロール対象をローカルのfile URIに限定する
  • 検証用データを固定シード20260813で再現する
  • ScrapyのLOG_LEVELをERRORにして出力を絞る
  • ROBOTSTXT_OBEYをFalseにしてローカル検証に限る
  • TELNETCONSOLE_ENABLEDをFalseにする
  • Windowsではasyncioのイベントループポリシーを切り替える
  • CREATE TABLE IF NOT EXISTSで再実行に耐える
  • sqlite3.Rowで列名アクセスを可能にする
  • SQLの値渡しをプレースホルダに限定する
  • CSVをutf-8-sigで書き出す
  • close_spiderでcommitしてから接続を閉じる
  • 実装を1ファイルにまとめ外部ライブラリをScrapyだけにする

実装方針

今回はScrapyとSQLiteの基本動作を追いやすくするため、商品カタログ収集CLI本体を1つのPythonファイルへまとめます。

入力、判定、結果表示の役割を分け、実行結果を確認しながら機能を積み上げます。

商品カタログ収集CLIを安全に組み立てるための実装方針は次のとおりです。

  • クロール対象をローカルのfile URIに限定する
  • 検証用データを固定シード20260813で再現する
  • ScrapyのLOG_LEVELをERRORにして出力を絞る
  • ROBOTSTXT_OBEYをFalseにしてローカル検証に限る
  • TELNETCONSOLE_ENABLEDをFalseにする
  • Windowsではasyncioのイベントループポリシーを切り替える
  • CREATE TABLE IF NOT EXISTSで再実行に耐える
  • sqlite3.Rowで列名アクセスを可能にする
  • SQLの値渡しをプレースホルダに限定する
  • CSVをutf-8-sigで書き出す
  • close_spiderでcommitしてから接続を閉じる
  • 実装を1ファイルにまとめ外部ライブラリをScrapyだけにする

完成と判断する条件

  1. init-catalogで一覧ページと詳細ページが生成される
  2. 生成結果に重複カードの件数が表示される
  3. crawlで巡回した一覧ページ数が表示される
  4. 重複した商品カードが重複スキップとして数えられる
  5. SQLite保存件数が抽出カード数より少なくなる
  6. 同じデータベースへ再クロールしても保存件数が増えない
  7. listで保存件数と価格の高い順の並びが表示される
  8. statsで平均価格と在庫状況ごとの件数が表示される
  9. exportでCSVの絶対パスと行数が表示される
  10. カタログ未生成のcrawlで案内メッセージが出る
  11. 5つのサブコマンドが終了コード0で完了する
さらに詳しく学べるPython研修の詳細はこちら

商品カタログ収集CLIを作る際の重要ポイント

この収集CLIの中心は、同じ商品を二重に数えないための二段構えの重複排除です。検証用カタログは2ページ目以降の先頭に前ページの1件目をわざと再掲する作りにしてあり、クロールすると必ず重複カードが現れます。1回のクロール中の重複はPipelineが持つ集合で弾き、時間をおいた再クロールの重複は商品URLの主キーとUPDATEで吸収する形です。

結果として、何度実行しても保存件数は商品の実数と一致します。

重複排除と再実行に関わる仕組みを実装箇所とあわせて挙げます

  • 前ページ先頭を再掲して重複を意図的に作る
  • seen集合で同一クロール内の重複を判定する
  • DropItemで落とした件数を重複スキップとして数える
  • 商品URLの主キーで再クロール時の重複を防ぐ
  • 新規登録件数と既存更新件数を分けて表示する
  • argparseのサブコマンドで5つの操作を分ける

検証用カタログをあえて重複ありで作る理由

init-catalogは、固定シードの乱数で商品名・カテゴリ・価格・在庫状況を組み立て、ページごとのHTMLを書き出す処理です。2ページ目からは前ページ先頭の商品カードを先頭へ差し込むため、実際のサイトでよくある同一商品が別ページにも並ぶ状態を再現できます。

この重複があるおかげで、重複スキップ件数が0以外になり、排除処理が働いていることを実行結果から確かめられます。

init-catalogが生成するファイルとページ構成を示します

  • page-1htmlから始まる連番の一覧ページ
  • product配下に並ぶ商品詳細ページ
  • 次ページへ進むa.nextのリンク
  • 前ページ先頭を再掲した重複カード

二段構えの重複排除で保存件数を保つ仕組み

1回のクロール中に同じ商品URLが二度届いた場合、Pipelineはseen集合を見て2件目をDropItemで落とします。落とした件数はcatalog/duplicate_skippedとして記録され、実行後の表示に現れる仕組みです。

別のタイミングで同じデータベースへクロールし直したときは、集合が空に戻るためDropItemは働きません。代わりに商品URLを条件にしたSELECTが既存行を見つけ、UPDATEとして処理されるので保存件数は増えないままです。

抽出したカードが保存されるまでに通る判定を順に並べます

  • 抽出した商品URLをseen集合と照合する
  • 重複ならDropItemで後続処理を止める
  • 初回のURLならINSERTで新規登録する
  • 既存のURLならUPDATEで最新値へ書き換える

保存後の一覧・集計・CSV出力の使い分け

クロールで貯めたデータは、3つのサブコマンドから確認します。listは価格の高い順に商品名・価格・在庫状況・URLを並べるため、中身を目視で確かめたいときに向いています。statsは件数と価格帯、在庫状況ごとの平均価格、最終更新時刻をまとめて出すので、全体像の把握に便利です。

exportは6列すべてをCSVへ書き出し、表計算ソフトでの共有や別ツールへの受け渡しに使えます。データが空のときは、listとstatsがクロールを促すメッセージを表示する作りです。

3つの確認用サブコマンドと得られる情報を対応させます

  • listで上位商品と商品URLを確認する
  • statsで平均価格と在庫状況の内訳を確認する
  • exportでCSVの行数と列名を確認する

Python研修一覧はこちら

目的に合うPython研修を一覧形式から探したい方は、ぜひご利用ください。

Python研修を比較する

Java研修一覧はこちら

目的に合うJava研修を一覧形式から探したい方は、ぜひご利用ください。

Java研修を比較する

PHP研修一覧はこちら

目的に合うPHP研修を一覧形式から探したい方は、ぜひご利用ください。

PHP研修を比較する

新入社員研修

目的に合う新入社員研修を一覧形式から探したい方は、ぜひご利用ください。

新入社員研修を比較する

全ての研修からも探したい方はこちら

Pythonで商品カタログ収集CLIの完成コード

実装は1ファイルにまとめ、前半に検証用カタログを生成する処理、中盤にSpiderItem Pipeline、後半にCLIの入口を置いています。ここでは前半の生成処理を中心に、押さえておきたい部分だけを抜き出して見ていきます。

生成側の作りは抽出側と表裏一体です。どのクラス名でどの項目を包むかを先に決めておくと、Spiderのセレクタがそのまま読みやすくなります。

保存側は商品URLを一意キーにする方針です。同じURLの商品が再び流れてきたときは、新規追加ではなく更新として扱います。

このセクションの用語

CSSセレクタ
span.priceのように、クラス名やタグ名でHTMLの要素を指し示す書き方です。抽出したい場所をピンポイントで指定できます。
CrawlerProcess
Scrapyのクロールをプログラムの中から起動するためのクラスです。scrapyコマンドを使わずCLIツールへ組み込めます。
DropItem
パイプラインの中でデータを捨てるときに投げる例外です。重複や条件外のデータを保存前に落とす用途で使います。
乱数シード
乱数の出発点になる数値です。同じ値を与えると同じ並びの乱数が出るので、検証用データを毎回同じ内容にできます。
f文字列
文字列の前にfを付けて{変数}を埋め込む書き方です。HTMLの組み立てのように値を差し込む処理で読みやすくなります。
抽出からSQLite保存までのデータの流れ 一覧ページのHTMLを取得セレクタで4項目を抽出1商品を1件のデータとして次へ渡すパイプラインで商品URLを照合SQLiteへ登録または更新list・stats・exportで読み出し
抽出からSQLite保存までのデータの流れ
"""ローカルの検証用商品カタログをScrapyで巡回し、商品名・価格・在庫状況・商品URLをSQLiteへ重複なく保存する公開CLIでクロール・商品一覧・集計・CSV出力を実行できるツール。"""

from __future__ import annotations

import argparse
import asyncio
import csv
import random
import sqlite3
import sys
from datetime import datetime
from pathlib import Path

import scrapy
from scrapy.crawler import CrawlerProcess
from scrapy.exceptions import DropItem


# 検証用商品カタログ(ローカルHTML)の定義
CATEGORIES = ("キッチン", "文具", "家電", "アウトドア", "日用品", "食品")
ITEM_WORDS = (
    "ステンレスボトル",
    "折りたたみ傘",
    "ワイヤレスマウス",
    "アルミ弁当箱",
    "耐熱ガラスポット",
    "ゲルインクペン",
    "USB充電器",
    "レジャーシート",
    "珪藻土マット",
    "ドリップコーヒー",
    "収納ボックス",
    "LEDデスクライト",
)
STOCK_LABELS = ("在庫あり", "在庫あり", "在庫あり", "残りわずか", "入荷待ち", "在庫切れ")

PAGE_TEMPLATE = """<!DOCTYPE html>
<html lang="ja">
<head><meta charset="utf-8"><title>検証用商品カタログ {page}/{total}</title></head>
<body>
<h1>検証用商品カタログ</h1>
<p class="pageinfo">{page} / {total}</p>
<div class="grid">
{cards}
</div>
<nav class="pager">{next_link}</nav>
</body>
</html>
"""

DETAIL_TEMPLATE = """<!DOCTYPE html>
<html lang="ja">
<head><meta charset="utf-8"><title>{name}</title></head>
<body>
<h1 class="pname">{name}</h1>
<p class="price">¥{price:,}</p>
<p class="stock">{stock}</p>
</body>
</html>
"""


# 検証用商品カタログの生成
def build_products(pages: int, per_page: int, seed: int = 20260813) -> list[list[dict]]:
    """固定シードの乱数で、ページごとの商品データを組み立てる。"""
    rng = random.Random(seed)
    pages_data: list[list[dict]] = []
    number = 0
    for _ in range(pages):
        rows = []
        for _ in range(per_page):
            number += 1
            rows.append(
                {
                    "pid": f"p{number:03d}",
                    "name": f"{ITEM_WORDS[number % len(ITEM_WORDS)]} PB-{number:03d}",
                    "category": CATEGORIES[number % len(CATEGORIES)],
                    "price": rng.randrange(480, 24800, 10),
                    "stock": rng.choice(STOCK_LABELS),
                }
            )
        pages_data.append(rows)
    return pages_data


def _card_html(product: dict) -> str:
    return (
        '<article class="product">'
        f'<span class="cat">{product["category"]}</span>'
        f'<h2 class="pname">{product["name"]}</h2>'
        f'<span class="price">¥{product["price"]:,}</span>'
        f'<span class="stock">{product["stock"]}</span>'
        f'<a class="detail" href="product/{product["pid"]}.html">詳細を見る</a>'
        "</article>"
    )


def init_catalog(site_dir: str, pages: int, per_page: int) -> dict:
    """一覧ページ・詳細ページのHTMLを書き出す。2ページ目以降は前ページ先頭を再掲する。"""
    root = Path(site_dir).resolve()
    (root / "product").mkdir(parents=True, exist_ok=True)
    pages_data = build_products(pages, per_page)
    total_pages = len(pages_data)
    repeated = 0

    for index, rows in enumerate(pages_data):
        cards = list(rows)
        if index > 0:
            cards.insert(0, pages_data[index - 1][0])
            repeated += 1
        next_link = ""
        if index + 1 < total_pages:
            next_link = f'<a class="next" href="page-{index + 2}.html">次のページ</a>'
        html = PAGE_TEMPLATE.format(
            page=index + 1,
            total=total_pages,
            cards="\n".join(_card_html(product) for product in cards),
            next_link=next_link,
        )
        (root / f"page-{index + 1}.html").write_text(html, encoding="utf-8")

    for rows in pages_data:
        for product in rows:
            detail = DETAIL_TEMPLATE.format(**product)
            (root / "product" / (product["pid"] + ".html")).write_text(detail, encoding="utf-8")

    return {
        "dir": str(root),
        "pages": total_pages,
        "products": sum(len(rows) for rows in pages_data),
        "duplicate_cards": repeated,
        "start_url": (root / "page-1.html").as_uri(),
    }


# SQLiteのスキーマと重複を作らない保存処理
SCHEMA = """
CREATE TABLE IF NOT EXISTS products (
    url        TEXT PRIMARY KEY,
    name       TEXT NOT NULL,
    price      INTEGER,
    stock      TEXT,
    first_seen TEXT NOT NULL,
    last_seen  TEXT NOT NULL
)
"""
CSV_COLUMNS = ("url", "name", "price", "stock", "first_seen", "last_seen")


def connect(db_path: str) -> sqlite3.Connection:
    path = Path(db_path)
    path.parent.mkdir(parents=True, exist_ok=True)
    conn = sqlite3.connect(str(path))
    conn.row_factory = sqlite3.Row
    conn.execute(SCHEMA)
    conn.commit()
    return conn


def save_product(conn: sqlite3.Connection, item: dict) -> str:
    """再クロール時は商品URLをキーに既存レコードを更新する。存在しなければ新規にINSERTする。"""
    now = datetime.now().isoformat(timespec="seconds")
    found = conn.execute("SELECT url FROM products WHERE url = ?", (item["url"],)).fetchone()
    if found is not None:
        conn.execute(
            "UPDATE products SET name = ?, price = ?, stock = ?, last_seen = ? WHERE url = ?",
            (item["name"], item["price"], item["stock"], now, item["url"]),
        )
        return "updated"
    conn.execute(
        "INSERT INTO products (url, name, price, stock, first_seen, last_seen)"
        " VALUES (?, ?, ?, ?, ?, ?)",
        (item["url"], item["name"], item["price"], item["stock"], now, now),
    )
    return "inserted"


# 抽出テキストの整形
def parse_price(text: str) -> int | None:
    digits = "".join(ch for ch in text if ch.isdigit())
    return int(digits) if digits else None


def yen(value) -> str:
    return "-" if value is None else f"¥{int(value):,}"


# ScrapyのSpiderで商品一覧の次ページを追跡して全商品を抽出する
class ProductCatalogSpider(scrapy.Spider):
    name = "product_catalog"

    def __init__(self, start_url: str | None = None, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.start_urls = [start_url] if start_url else []

    def parse(self, response):
        self.crawler.stats.inc_value("catalog/pages")
        for card in response.css("article.product"):
            href = card.css("a.detail::attr(href)").get()
            if not href:
                continue
            self.crawler.stats.inc_value("catalog/cards")
            yield {
                "name": (card.css(".pname::text").get() or "").strip(),
                "price": parse_price(card.css(".price::text").get() or ""),
                "stock": (card.css(".stock::text").get() or "").strip(),
                "url": response.urljoin(href),
            }

        next_href = response.css("a.next::attr(href)").get()
        if next_href:
            yield response.follow(next_href, callback=self.parse)


# 商品名・価格・在庫状況・商品URLをSQLiteへ重複なく保存するScrapyのItem Pipeline
class SQLitePipeline:
    def __init__(self, db_path: str, stats):
        self.db_path = db_path
        self.stats = stats
        self.conn: sqlite3.Connection | None = None
        self.seen: set[str] = set()

    @classmethod
    def from_crawler(cls, crawler):
        return cls(crawler.settings.get("CATALOG_DB"), crawler.stats)

    def open_spider(self, spider):
        self.conn = connect(self.db_path)
        self.seen = set()

    def process_item(self, item, spider):
        url = item["url"]
        if url in self.seen:
            self.stats.inc_value("catalog/duplicate_skipped")
            raise DropItem(f"同一クロール内の重複商品URL: {url}")
        self.seen.add(url)
        self.stats.inc_value(f"catalog/{save_product(self.conn, item)}")
        return item

    def close_spider(self, spider):
        if self.conn is not None:
            self.conn.commit()
            self.conn.close()
            self.conn = None


# クロールの実行
def crawl_catalog(start_url: str, db_path: str) -> dict:
    if sys.platform == "win32":
        asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())

    db_file = str(Path(db_path).resolve())
    process = CrawlerProcess(
        settings={
            "LOG_LEVEL": "ERROR",
            "ROBOTSTXT_OBEY": False,
            "TELNETCONSOLE_ENABLED": False,
            "ITEM_PIPELINES": {f"{__name__}.SQLitePipeline": 300},
            "CATALOG_DB": db_file,
        }
    )
    crawler = process.create_crawler(ProductCatalogSpider)
    process.crawl(crawler, start_url=start_url)
    process.start()

    stats = crawler.stats.get_stats()
    conn = connect(db_file)
    total = conn.execute("SELECT COUNT(*) FROM products").fetchone()[0]
    conn.close()
    return {
        "start_url": start_url,
        "db": db_file,
        "pages": stats.get("catalog/pages", 0),
        "cards": stats.get("catalog/cards", 0),
        "duplicate_skipped": stats.get("catalog/duplicate_skipped", 0),
        "inserted": stats.get("catalog/inserted", 0),
        "updated": stats.get("catalog/updated", 0),
        "total": total,
    }


# CLIサブコマンドの処理
def cmd_init(args) -> None:
    info = init_catalog(args.site_dir, args.pages, args.per_page)
    print(f"検証用商品カタログを生成しました: {info['dir']}")
    print(f"一覧ページ: {info['pages']}ページ / 商品: {info['products']}件")
    print(f"重複カード(前ページ先頭の再掲): {info['duplicate_cards']}件")
    print(f"開始URL: {info['start_url']}")


def cmd_crawl(args) -> None:
    start_file = Path(args.site_dir).resolve() / "page-1.html"
    if not start_file.exists():
        print(f"検証用商品カタログが見つかりません: {start_file}")
        print("先にinit-catalogサブコマンドで生成してください。")
        return
    result = crawl_catalog(start_file.as_uri(), args.db)
    print(f"開始URL: {result['start_url']}")
    print(f"巡回した一覧ページ: {result['pages']}ページ")
    print(f"抽出した商品カード: {result['cards']}件(重複スキップ: {result['duplicate_skipped']}件)")
    print(f"新規登録: {result['inserted']}件 / 既存更新: {result['updated']}件")
    print(f"SQLite保存件数: {result['total']}件 -> {result['db']}")


def cmd_list(args) -> None:
    conn = connect(args.db)
    rows = conn.execute(
        "SELECT name, price, stock, url FROM products ORDER BY price DESC, name LIMIT ?",
        (args.limit,),
    ).fetchall()
    total = conn.execute("SELECT COUNT(*) FROM products").fetchone()[0]
    conn.close()

    print(f"SQLite: {Path(args.db).resolve()}")
    print(f"保存件数: {total}件 / 表示: {len(rows)}件(価格の高い順)")
    if not rows:
        print("商品がありません。crawlサブコマンドを先に実行してください。")
        return
    for index, row in enumerate(rows, 1):
        print(f"{index:2d}. {row['name']} / {yen(row['price'])} / {row['stock']}")
        print(f"    {row['url']}")


def cmd_stats(args) -> None:
    conn = connect(args.db)
    summary = conn.execute(
        "SELECT COUNT(*) AS n, AVG(price) AS avg_price, MIN(price) AS min_price,"
        " MAX(price) AS max_price, MAX(last_seen) AS last_seen FROM products"
    ).fetchone()
    by_stock = conn.execute(
        "SELECT stock, COUNT(*) AS n, AVG(price) AS avg_price FROM products"
        " GROUP BY stock ORDER BY n DESC, stock"
    ).fetchall()
    conn.close()

    print(f"SQLite: {Path(args.db).resolve()}")
    if not summary["n"]:
        print("集計対象の商品がありません。crawlサブコマンドを先に実行してください。")
        return
    print(f"商品件数: {summary['n']}件")
    print(
        f"価格: 平均{yen(round(summary['avg_price']))}"
        f" / 最安{yen(summary['min_price'])} / 最高{yen(summary['max_price'])}"
    )
    print("在庫状況の内訳:")
    for row in by_stock:
        print(f"  {row['stock']:<6}{row['n']:>4}件  平均{yen(round(row['avg_price']))}")
    print(f"最終更新: {summary['last_seen']}")


def cmd_export(args) -> None:
    conn = connect(args.db)
    rows = conn.execute(
        f"SELECT {', '.join(CSV_COLUMNS)} FROM products ORDER BY url"
    ).fetchall()
    conn.close()

    out = Path(args.out).resolve()
    out.parent.mkdir(parents=True, exist_ok=True)
    with out.open("w", newline="", encoding="utf-8-sig") as handle:
        writer = csv.writer(handle)
        writer.writerow(CSV_COLUMNS)
        for row in rows:
            writer.writerow([row[column] for column in CSV_COLUMNS])

    print(f"CSVを出力しました: {out}")
    print(f"行数: {len(rows)}件 / 列: {', '.join(CSV_COLUMNS)}")


# CLIの定義とエントリポイント(公開CLIでクロール・商品一覧・集計・CSV出力を実行できる)
def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="catalog_crawler",
        description="ローカルの検証用商品カタログをScrapyで巡回してSQLiteへ集める収集ツール",
    )
    sub = parser.add_subparsers(dest="command", required=True)

    p_init = sub.add_parser("init-catalog", help="検証用商品カタログのHTMLを生成する")
    p_init.add_argument("--site-dir", required=True, help="生成先ディレクトリ")
    p_init.add_argument("--pages", type=int, default=8, help="一覧ページ数")
    p_init.add_argument("--per-page", type=int, default=12, help="1ページあたりの商品数")
    p_init.set_defaults(func=cmd_init)

    p_crawl = sub.add_parser("crawl", help="次ページを追跡して全商品をSQLiteへ保存する")
    p_crawl.add_argument("--site-dir", required=True, help="検証用商品カタログのディレクトリ")
    p_crawl.add_argument("--db", default="catalog.db", help="SQLiteファイル")
    p_crawl.set_defaults(func=cmd_crawl)

    p_list = sub.add_parser("list", help="保存済みの商品一覧を表示する")
    p_list.add_argument("--db", default="catalog.db", help="SQLiteファイル")
    p_list.add_argument("--limit", type=int, default=10, help="表示件数")
    p_list.set_defaults(func=cmd_list)

    p_stats = sub.add_parser("stats", help="件数・価格・在庫状況を集計する")
    p_stats.add_argument("--db", default="catalog.db", help="SQLiteファイル")
    p_stats.set_defaults(func=cmd_stats)

    p_export = sub.add_parser("export", help="保存済みの商品をCSVへ出力する")
    p_export.add_argument("--db", default="catalog.db", help="SQLiteファイル")
    p_export.add_argument("--out", default="products.csv", help="出力するCSVファイル")
    p_export.set_defaults(func=cmd_export)

    return parser


def main(argv: list[str] | None = None) -> None:
    args = build_parser().parse_args(argv)
    args.func(args)


if __name__ == "__main__":
    main()

コード全文は上の折り畳みに入れてあるので、全部を上から読む必要はありません。ここでは特に重要な部分だけを抜き出して、何をしているのか順番に見ていきます。

モジュールdocstringで役割を宣言する

"""ローカルの検証用商品カタログをScrapyで巡回し、商品名・価格・在庫状況・商品URLをSQLiteへ重複なく保存する公開CLIでクロール・商品一覧・集計・CSV出力を実行できるツール。"""

ファイル先頭のdocstringに、何を巡回して何をどこへ保存するのかを1文で書いています。後から読み返したとき、このツールがCLIで何をできるかがここだけで分かります。

CrawlerProcessとDropItemを読み込む

import scrapy
from scrapy.crawler import CrawlerProcess
from scrapy.exceptions import DropItem

CrawlerProcessは、スクリプトの中からクロールを起動するための入口です。DropItemは、重複や不正なデータをパイプラインの途中で捨てたいときに投げる例外になります。

一覧ページのテンプレートとpager

<div class="grid">
{cards}
</div>
<nav class="pager">{next_link}</nav>

商品カードをdiv.gridに並べ、次ページへのリンクをnav.pagerに差し込む形です。追跡すべきリンクの置き場所が1か所に決まるので、抽出側はnav.pagerの中だけを見れば済みます。

build_productsを固定シードで再現可能にする

def build_products(pages: int, per_page: int, seed: int = 20260813) -> list[list[dict]]:
    """固定シードの乱数で、ページごとの商品データを組み立てる。"""
    rng = random.Random(seed)

random.Random(seed)で乱数の種を固定しているため、何度実行しても同じ価格と在庫のカタログが再現されます。抽出結果を比べたいときに、データ側が毎回変わってしまう心配がありません。

_card_htmlが抽出の目印を埋め込む

        f'<h2 class="pname">{product["name"]}</h2>'
        f'<span class="price">¥{product["price"]:,}</span>'
        f'<span class="stock">{product["stock"]}</span>'
        f'<a class="detail" href="product/{product["pid"]}.html">詳細を見る</a>'

4項目にそれぞれpnamepricestockdetailというクラス名を付けています。抽出側はこのクラス名をセレクタに書くだけで済み、価格には¥と桁区切りが入ることも先に把握できます。

init_catalogが重複をあえて作る

def init_catalog(site_dir: str, pages: int, per_page: int) -> dict:
    """一覧ページ・詳細ページのHTMLを書き出す。2ページ目以降は前ページ先頭を再掲する。"""
    root = Path(site_dir).resolve()

一覧と詳細のHTMLを書き出す関数です。docstringのとおり2ページ目以降には前ページ先頭の商品を再掲するため、重複が起きる前提のデータになり、重複なし保存の効き目を確認できます。


Python基礎・実践(Django)

企業・法人向けのPython研修では、基礎から応用まで体系的に学べます。

Python研修の詳細

DX社員研修

企業・法人向けのDX研修では、実務に繋がるリスキリングでITレベルを向上させます。

DX研修の詳細

Javaエンジニア育成研修

企業・法人向けのJavaエンジニア育成研修では、Javaの基礎から応用まで確実に習得できます。

Java研修の詳細

新卒・新入社員向け研修

企業・法人に新入社員・新卒社員に向けたプログラミング研修を提供しています。

新入社員研修の詳細

コードキャンプのIT研修を全て見る

PythonのCLIで5つのサブコマンドを使い分ける

CLIはargparseのサブコマンド方式にして、init-catalogcrawlliststatsexportの5つを公開しています。役割ごとにコマンドを分けると、途中の状態を確認しながら進められます。

実行の順番は、まずinit-catalogでカタログHTMLを作り、次にcrawlでSQLiteへ保存し、その後にliststatsexportで中身を確かめる流れです。

どのコマンドでも--dbで同じデータベースファイルを指すことが前提になります。ここがずれると、保存したはずの商品が見つからない状態になりがちです。

実行順序と--dbの指定で特に注意したいポイントを、次にまとめます。

このセクションの用語

argparse
コマンドライン引数を解析するPython標準のモジュールです。サブコマンドやオプションの定義に使います。
サブコマンド
git commitのように、1つのコマンドの下に用途別の命令を並べる仕組みです。機能ごとに実行を分けられます。
サブコマンド 何をするか 実行時に指定した値
init-catalog 検証用カタログのHTMLを書き出す --site-dir /tmp/catalog_site --pages 8 --per-page 12
crawl 一覧を巡回してSQLiteへ保存する --site-dir /tmp/catalog_site --db /tmp/catalog.db
list 保存済みの商品を一覧表示する --db /tmp/catalog.db --limit 5
stats 保存済みデータを集計して表示する --db /tmp/catalog.db
export 保存済みデータをCSVへ書き出す --db /tmp/catalog.db --out /tmp/products.csv
POINT

初回:init-catalogで生成

順序:crawlの後にlist

DB指定:--dbを全コマンドで揃える

さらに詳しく学べるPython研修の詳細はこちら

PythonのScrapyとSQLite利用時に起きやすいエラーと対処法

ここで挙げるのは、この構成で一般に起こりやすいエラーです。今回の実行はすべて終了コード0でしたが、パスや環境の指定を1つ間違えるだけで簡単に再現します。

エラーメッセージは長く見えても、最後の1行に例外の型と原因が書かれています。まずはそこだけ読む癖を付けると、対処までの時間が短くなるはずです。

このセクションの用語

仮想環境
プロジェクトごとにライブラリを分けて入れる仕組みです。どの環境で実行しているかを取り違えると、入れたはずのライブラリが見つかりません。
ReactorNotRestartable
Scrapyが内部で使うイベントループを再起動できないときに出る例外です。同じプロセスで二度クロールを始めると発生します。
UNIQUE制約
同じ値を2行以上入れられないようにするデータベース側の決まりです。重複保存を防ぐ土台になります。
エラー例 原因 対処
ModuleNotFoundError: No module named 'scrapy' Scrapyが未インストール、または別の仮想環境で実行している 実行に使っているPythonでインストールし直し、python -m pip listで入っている場所を確認する
twisted.internet.error.ReactorNotRestartable 1つのプロセスの中でCrawlerProcessを二度起動している クロールは1コマンドにつき1回にとどめ、繰り返したいときはコマンド自体を分けて実行する
sqlite3.OperationalError: no such table: products テーブルが作られる前にliststatsを実行した、または--dbのパスが違う 先にcrawlを実行し、すべてのコマンドで同じ--dbを指しているか見直す
sqlite3.IntegrityError: UNIQUE constraint failed 同じ商品URLの行を、そのまま挿入しようとしている 挿入前に既存行を照合し、一致したら更新へ切り替える処理をパイプラインに置く
FileNotFoundError: /tmp/catalog_site init-catalogを実行していない、または--site-dirの指定が違う 先にカタログを生成し、--site-dirは絶対パスで指定して打ち間違いを防ぐ

商品カタログ収集CLIで注意したい点

重複なし保存でよく詰まるのは、何を「同じ商品」とみなすかの決め方です。今回は商品URLを一意キーにしているので、名前や価格が変わってもURLが同じなら同一商品として更新します。

もう1つの落とし穴が相対リンクです。一覧ページのa.detailproduct/p001.htmlのような相対パスなので、そのまま保存すると同じ商品でもページごとに違うキーになりかねません。

response.urljoinのように絶対URLへ直してから保存すれば、再クロールしても同じキーに揃います。ポイントとしては、キーの決め方とリンク正規化まわりのチェック項目をまとめます。

POINT

一意キー:商品URLで同一判定

相対リンク:絶対URLに直す

再実行:件数増加は重複の合図

確認:statsで件数を毎回見る

さらに詳しく学べるPython研修の詳細はこちら

商品カタログ収集CLIの動作確認

作ったCLIを5回のコマンドで実行し、いずれも終了コード0で完了しました。途中でエラー終了したものはありません。

最初にpython subject.py init-catalog --site-dir /tmp/catalog_site --pages 8 --per-page 12を実行し、8ページ構成で1ページあたり12件のカタログを書き出しています。

続けてcrawlでカタログを巡回し、/tmp/catalog.dbへ保存しました。その後はlist --limit 5で先頭だけを確認し、statsで集計、export/tmp/products.csvへ書き出しています。

同じcrawlをもう一度流しても、商品URLが一致する商品は新規追加ではなく更新として扱う設計です。再実行で件数が膨らんでいないかはstatsの出力で確かめられます。

このセクションの用語

終了コード
コマンドが終わるときに返す数値です。0が正常終了を表し、それ以外は何らかの異常を示します。
クロール統計
Scrapyが実行の最後に出力する集計情報です。リクエスト数や処理したデータ件数などを確認できます。

5枚のキャプチャで、コマンドごとに見ておきたい出力は次のとおりです。

  • init-catalog: 生成したページ数と出力先ディレクトリの表示
  • crawl: Scrapyのログに並ぶリクエスト数やパイプラインの処理状況
  • list: --limit 5を付けたときに表示される件数
  • stats: 集計結果と、再実行後に数字が変わらないかどうか
  • export: CSVの出力先パスと、書き出し完了の表示
python subject.py init-catalog --site-dir /tmp/catalog_site --pages 8 --per-page 12の実行結果(終了コード0)
python subject.py init-catalog --site-dir /tmp/catalog_site --pages 8 --per-page 12の実行結果(終了コード0)
python subject.py crawl --site-dir /tmp/catalog_site --db /tmp/catalog.dbの実行結果(終了コード0)
python subject.py crawl --site-dir /tmp/catalog_site --db /tmp/catalog.dbの実行結果(終了コード0)
python subject.py list --db /tmp/catalog.db --limit 5の実行結果(終了コード0)
python subject.py list --db /tmp/catalog.db --limit 5の実行結果(終了コード0)
python subject.py stats --db /tmp/catalog.dbの実行結果(終了コード0)
python subject.py stats --db /tmp/catalog.dbの実行結果(終了コード0)
python subject.py export --db /tmp/catalog.db --out /tmp/products.csvの実行結果(終了コード0)
python subject.py export --db /tmp/catalog.db --out /tmp/products.csvの実行結果(終了コード0)
さらに詳しく学べるPython研修の詳細はこちら

商品カタログ収集ツールが活きる実務シーン

検証用に作った構成ですが、対象サイトと抽出セレクタを差し替えれば、そのまま業務向けの収集ツールとして使えます。1ファイルで完結しているので、社内の共有も簡単です。

ただし外部サイトを対象にする場合は、利用規約やrobots.txt、アクセス間隔への配慮が前提になります。まずは今回のようなローカルの検証用サイトで動作を固めるのが安全です。

このセクションの用語

robots.txt
サイト側がクローラに対して巡回してよい範囲を伝えるファイルです。外部サイトを対象にするときは事前に確認します。
回帰テスト
変更を加えた後も以前と同じ結果になるかを確かめるテストです。抽出ルールの修正時に効果があります。
使える場面 具体的な使い方
自社ECの棚卸し 公開中の商品一覧を巡回して商品名・価格・在庫状況を集め、exportで出したCSVを基幹データと突き合わせて差分を洗い出す
価格改定の追跡 定期的にcrawlを回し、商品URLをキーに更新される価格を記録して、いつどの商品が動いたかを追う
在庫切れの検知 statsで在庫状況の内訳を集計し、「在庫切れ」「入荷待ち」の比率が跳ねた日を見つけて補充判断につなげる
抽出ロジックの回帰テスト 固定シードで生成した検証用カタログを基準データにして、セレクタ変更後も同じ結果になるかを確認する
スクレイピング研修の教材 init-catalogで作ったローカルサイトを題材にし、外部へ負荷をかけずにページ追跡とセレクタ設計を練習させる
さらに詳しく学べるPython研修の詳細はこちら

商品カタログ収集CLI開発のまとめ

ローカルの検証用カタログを次ページリンクで巡回し、4項目をSQLiteへ重複なく保存するCLIツールを作りました。5回のコマンドはすべて終了コード0で完了し、生成から集計、CSV出力までを一続きで確認できています。

作ってみて効いたのは、抽出対象のHTMLを自分で生成したことです。クラス名も重複の起き方も分かっているため、うまく取れないときに原因がサイト側か抽出側かをすぐ切り分けられました。

商品URLを一意キーに据える設計は、収集ツールを長く運用するほど価値が出ます。再クロールしても件数が増えない状態を最初に作っておくと、後からデータを掃除する手間が要りません。

今回の構成から手を広げるなら、次の改造が取り組みやすいです。

  • 詳細ページまで巡回して、説明文やカテゴリを保存項目に追加する
  • --pages--per-pageを変え、ページ数が増えたときの挙動を確かめる
  • statsの出力にカテゴリ別や在庫状況別の内訳を足す
  • CSVに加えてJSON出力のサブコマンドを増やし、他ツールとつなぐ
さらに詳しく学べるPython研修の詳細はこちら

参考にした一次情報

  1. ^ Scrapy Tutorial — リンクをたどるクロールの基本. https://docs.scrapy.org/en/latest/intro/tutorial.html, (参照26-08-13).
  2. ^ Scrapy Item Pipeline — アイテムの後処理と保存. https://docs.scrapy.org/en/latest/topics/item-pipeline.html, (参照26-08-13).
  3. ^ Scrapy Common Practices — スクリプトからScrapyを実行する. https://docs.scrapy.org/en/latest/topics/practices.html, (参照26-08-13).
  4. ^ Python公式ドキュメントsqlite3. https://docs.python.org/ja/3/library/sqlite3.html, (参照26-08-13).

※内容は執筆時点のものです。ライブラリやサイトの仕様は変わる可能性があるため、公式ドキュメントもあわせてご確認ください。

ブログに戻る

コメントを残す

コメントは公開前に承認される必要があることにご注意ください。

企業・法人向けのIT・プログラミング・生成AI研修を探す、比較する - IT・プログラミングを知って学べるコネクトメディア CodeCampが提供するDX人材育成が可能なプログラミングやITが学べる公開講座 - IT・プログラミングを知って学べるコネクトメディア コードキャンプが提供する無料で学べるプログラミングスクール講座 - IT・プログラミングを知って学べるコネクトメディア コードキャンプDX人材育成研修 - IT・プログラミングを知って学べるコネクトメディア 3.5日の研修で、年間1,600時間の削減効果が見込まれる。東京きらぼしフィナンシャルグループのDX人材育成事例 - IT・プログラミングを知って学べるコネクトメディア 配属3ヶ月で30%の生産性向上を実現するいよぎんコンピュータサービスの新人研修に迫る - IT・プログラミングを知って学べるコネクトメディア 金融業界の業務効率化を加速するニッセイアセットマネジメントの生成AI×GAS活用研修事例 - IT・プログラミングを知って学べるコネクトメディア 【製造業のDX人材育成事例】デジタル人材の即戦力化を実現する、日本ガイシ株式会社の異動者向オンボーディング研修 - ITやプログラミングを知って学べるコネクトメディア フューチャーアーキテクト株式会社が実現した新入社員向けIT研修プログラムでタスクフォース制度が主体的な学びと成長を生み出す - IT・プログラミングを知って学べるコネクトメディア コードキャンプDX人材育成研修 - IT・プログラミングを知って学べるコネクトメディア コードキャンプIT・プログラミング研修事例/【IT新入社員研修】オンラインとオフラインの最適バランスを実現したFutureOneの導入事例 - IT・プログラミングを知って学べるコネクトメディア コードキャンプIT・プログラミング研修事例/【新入社員研修】柔軟なハイブリッド型Java研修で実現した新卒20名の成長と成果|サークレイス株式会社 - ITやプログラミングを知って学べるコネクトメディア コードキャンプIT・プログラミング研修事例/現場により近いところにデジタルを根付かせるDX基礎講座研修|株式会社ブリヂストン - ITやプログラミングを知って学べるコネクトメディア コードキャンプIT・プログラミング研修事例/業務の効率化・DX推進に向けたIT人材育成への第一歩|株式会社カナエ - ITやプログラミングを知って学べるコネクトメディア 企業・法人向けのIT・プログラミング研修 - ITやプログラミングを知って学べるコネクトメディア

新着記事

対象者別で探す

子供(小学生・中学生・高校生)向け
プログラミング教室検索する

子供(小学生・中学生・高校生)がロボットやプログラミング言語を学ぶことができるオフラインからオンラインスクールを検索、比較することが可能です。

子供(小学生・中学生・高校生)
プログラミング教室検索する

ITやプログラムなどの
最新情報を検索する

日々、新しいITやプログラミング言語の情報が流れていきますが、特定の情報を時系列でニュースやコラムを確認することができます。

ITやプログラムなどの
最新情報を検索する