Pythonでスクレイピングする際は、取得した文書の形式に合った解析方法を選ぶ必要があります。HTMLではページの要素や属性をたどり、XMLでは要素の階層に加えて名前空間も確認します。どちらも、値を取り出しただけでは完了ではありません。必要な項目を検証してから保存します。
本記事では、同じ2商品を表すHTMLとXMLのサンプルを使い、共通のCSV形式に出力する手順を解説します。完全なPythonコードにはサンプルデータを含めているため、別のコード一式をダウンロードする必要はありません。まずローカルで動かし、その後で取得許可のあるデータソースに対応させます。
押さえるべき点は次の3つです。
- 解析方法は形式ごとに分けます。 HTMLのCSSセレクターを、そのままXMLに適用しないでください。
- 欠損値を推測で埋めません。 在庫情報がなければ「不明」として残し、確認対象にします。
- プロキシは通信経路を設定するために使います。 セレクターの誤りやデータの欠落は、出口IPを変えても直りません。
XMLとHTMLの違いは、スクレイピングにどう影響しますか?
HTMLはWebページの構造や意味を表し、XMLは用途に応じた構造化データを記述します。スクレイピングでは、両者の構文規則と要素の指定方法の違いを押さえることが重要です。
HTMLにも有用なデータは含まれています。本文、属性、表、埋め込みデータなどが抽出対象になります。「HTMLは表示専用なのでデータを持たない」と考えると、取得方法を誤ります。
| 比較項目 | HTML | XML |
|---|---|---|
| 要素の定義 | 標準のHTML要素と、仕様で定められた拡張の仕組みを使います | データソースの用途に応じて要素名や構造を定義します |
| 主な取得元 | 商品ページ、記事、一覧ページ、表 | フィード、サイトマップ、エクスポート、システム間の交換データ |
| 構文エラーへの対応 | 一部の不正なマークアップを補正できますが、生成されるツリーはパーサーによって異なります | 整形式である必要があり、厳格な解析では構文エラーを拒否します |
| 名前の照合 | HTMLの要素名に関する規則に従います | 大文字・小文字を区別し、名前空間も考慮します |
| 抽出方法 | CSSセレクターや、パーサーが対応するXPathを使います | 名前空間を指定したパス、XPath、ストリーミング処理を使います |
| 動的なデータ | JavaScriptの実行後に必要な値が現れる場合があります | XML文書の取得だけではクライアント側のJavaScriptは実行しません |
| 検証の考え方 | 要素を選択できても、必要な商品や項目とは限りません | 構文が正しくても、スキーマや業務上の条件を満たすとは限りません |

HTMLの解析仕様とW3CのXML仕様では、異なる解析規則を定めています。ブラウザーがページを表示できることと、抽出結果が正しいことは別問題です。XMLも、解析できただけでは価格の正確さやレコードの完全性を確認できません。
XMLとして配信・解析するXHTMLと、通常のtext/htmlも区別します。ファイル名だけで判断せず、レスポンスの形式とデータソースの仕様を確認してください。
Pythonのデータ抽出には、どのライブラリを使いますか?
この例では、取得をRequests、HTMLの抽出をBeautiful Soup、XMLの解析をlxmlで行います。通信、文書解析、項目検証の役割を分けると、障害の切り分けがしやすくなります。
| ツール | 主な役割 | 注意点 |
|---|---|---|
| Requests | 設定したHTTPクライアントで、許可のあるデータソースからレスポンスを取得します | JavaScriptの実行や文書の解析は行いません |
| Beautiful Soup | HTMLツリーをたどり、CSSセレクターで要素を抽出します | 明示的に選択したパーサーによって解析結果が変わります |
| lxml | XMLの厳格な解析、名前空間、XPathなどを扱います。HTMLにも対応します | 入力を無条件に信頼せず、安全性に関わる設定を確認します |
| Scrapy | 大規模な許可済み収集に向けて、スケジューリングやデータ処理を管理します | アクセス条件や項目検証が不要になるわけではありません |
HTMLにはPython標準のhtml.parserを指定し、XMLには設定済みのlxml.etree.XMLParserを使います。パーサーの違いやツリーの操作方法は、Beautiful Soupのドキュメントで確認できます。
ここで使うパーサーは、ページのJavaScriptを実行しません。必要な値が許可済みのレンダリング後ページや正式なフィードにしか存在しない場合は、それに合う取得処理を別途用意します。レスポンスに含まれていないデータは、CSSセレクターを変更するだけでは取得できません。
実装例:同じ商品データをHTMLとXMLから抽出します
形式ごとに抽出処理を分け、検証と出力は共通化します。まずローカルのサンプルで同じ結果が得られることを確認すると、ネットワークの問題と解析の問題を混同せずに済みます。
サンプルには、次の架空の商品を含めています。
SKU-DEMO-01:Demo Coffee、価格は24.90米ドル、在庫はin_stockです。SKU-DEMO-02:Demo Tea、価格は18.50米ドル、在庫情報は意図的に省略しています。
2つ目の商品の在庫は、unknownのまま残して確認用のフラグを付けます。在庫あり・在庫なしのどちらにも置き換えません。
1. 項目と検証ルールを決めます
| 出力項目 | HTMLでの取得元 | XMLでの取得元 | この例での検証ルール |
|---|---|---|---|
sku |
article.productのdata-sku属性 |
c:productのsku属性 |
必須です。同じレスポンス内で重複を認めません |
name |
h2 aのテキスト |
c:nameのテキスト |
空でない文字列を要求します |
amount |
.priceのテキスト |
c:priceのテキスト |
通貨記号を含まない、正の十進数を要求します |
currency |
.priceのdata-currency属性 |
c:priceのcurrency属性 |
このサンプルでは英大文字3文字を要求します |
url |
h2 aのhref属性 |
c:pathのテキスト |
基準URLと結合し、認証情報を含まないHTTPS URLであることを確認します |
stock |
.stockのテキスト |
c:stockのテキスト |
欠損値や未対応の値はunknownとして確認対象にします |
cは、urn:demo:catalogという名前空間に対してコード側で付けた接頭辞です。XML文書に書かれた接頭辞と同じである必要はありません。デフォルト名前空間を使う要素も名前空間に属するため、単にproductを検索しても一致しません。

2. 仮想環境を作り、依存ライブラリをインストールします
Python 3.10以降を使い、専用の仮想環境を作成します。
WindowsのPowerShellでは、次のコマンドを実行します。
python -m venv .venv
.\.venv\Scripts\python.exe -m pip install "requests[socks]>=2.32,<3" "beautifulsoup4>=4.13,<5" "lxml>=5,<7"
以降のWindows用コマンドも、仮想環境のPythonを直接指定します。仮想環境のアクティベートは不要です。
macOS/Linuxでは、次のコマンドを使います。
python3 -m venv .venv
.venv/bin/python -m pip install "requests[socks]>=2.32,<3" "beautifulsoup4>=4.13,<5" "lxml>=5,<7"
バージョン範囲は依存関係の境界を示すもので、将来のすべてのバージョンで同じ動作を保証するものではありません。実際のインストール済みバージョンを記録し、更新後はサンプルを再実行してください。
3. CSSセレクターとXMLの名前空間付きパスを比較します
次の抜粋は、解析方法の違いを示すためのプレビューです。後述の完全版にあるHTML、XML、NSを参照しているため、この部分だけでは実行できません。
html_tree = BeautifulSoup(HTML, "html.parser")
html_card = html_tree.select_one("article.product")
html_name = html_card.select_one("h2 a").get_text(strip=True)
html_price = html_card.select_one(".price").get_text(strip=True)
xml_parser = etree.XMLParser(resolve_entities=False, load_dtd=False,
no_network=True, recover=False, huge_tree=False)
xml_tree = etree.fromstring(XML, parser=xml_parser)
xml_product = xml_tree.find("c:product", NS)
xml_name = xml_product.findtext("c:name", namespaces=NS)
xml_price = xml_product.findtext("c:price", namespaces=NS)
HTMLではarticle.product、h2 a、.priceを使って要素を選択します。XMLではc:productやc:nameのように、名前空間を指定した名前で要素を探します。
この抜粋には検証処理を含めていません。実行する際は、次の完全版をコピーしてください。構成はサンプルデータ→抽出処理→正規化→通信の確認→コマンドライン処理と出力の順です。

4. 完全なスクリプトを保存します
以下をmarkup_scraper.pyとして保存します。初期設定では、--format htmlまたは--format xmlでローカルのサンプルを選びます。ネットワークから取得する場合は、--urlと--approvedの両方が必要です。
コード内の項目名、診断コード、英語メッセージは原稿のまま残しています。出力結果と照合する際も、同じ識別子を使ってください。
import argparse
import csv
import ipaddress
import json
import os
import re
import sys
from datetime import datetime, timezone
from decimal import Decimal
from pathlib import Path
from urllib.parse import quote, urljoin, urlsplit, urlunsplit
from uuid import uuid4
import requests
from bs4 import BeautifulSoup
from lxml import etree
MAX_BYTES = 2_000_000
NS = {"c": "urn:demo:catalog"}
BASE = "https://catalog.example.invalid/"
HTML = b'''<!doctype html><html><body><main id="catalog">
<article class="product" data-sku="SKU-DEMO-01">
<h2><a href="/p/coffee">Demo Coffee</a></h2>
<span class="price" data-currency="USD">24.90</span><p class="stock">in_stock</p></article>
<article class="product" data-sku="SKU-DEMO-02">
<h2><a href="/p/tea">Demo Tea</a></h2>
<span class="price" data-currency="USD">18.50</span></article>
</main></body></html>'''
XML = b'''<?xml version="1.0" encoding="UTF-8"?>
<catalog xmlns="urn:demo:catalog"><product sku="SKU-DEMO-01">
<name>Demo Coffee</name><price currency="USD">24.90</price>
<path>/p/coffee</path><stock>in_stock</stock></product>
<product sku="SKU-DEMO-02"><name>Demo Tea</name>
<price currency="USD">18.50</price><path>/p/tea</path></product></catalog>'''
def only(nodes):
if len(nodes) != 1:
raise ValueError("Expected exactly one required field or container")
return nodes[0]
def parse_html(data):
root = only(BeautifulSoup(data, "html.parser").select("main#catalog"))
cards = root.select("article.product")
records = []
for card in cards:
link = only(card.select("h2 a"))
price = only(card.select(".price"))
stock = card.select(".stock")
if len(stock) > 1:
raise ValueError("Ambiguous stock field")
records.append({"sku": card.get("data-sku", ""),
"name": link.get_text(" ", strip=True),
"amount": price.get_text(" ", strip=True),
"currency": price.get("data-currency", ""),
"path": link.get("href", ""),
"stock": stock[0].get_text(" ", strip=True) if stock else "unknown"})
return records
def parse_xml(data):
parser = etree.XMLParser(resolve_entities=False, load_dtd=False, no_network=True,
recover=False, huge_tree=False)
root = etree.fromstring(data, parser=parser)
if root.getroottree().docinfo.doctype:
raise ValueError("DTD-bearing XML is outside this tutorial contract")
if root.tag != "{urn:demo:catalog}catalog":
raise ValueError("Unexpected XML root or namespace")
def text(node):
return " ".join(part.strip() for part in node.itertext() if part.strip())
records = []
for product in root.findall("c:product", NS):
name = only(product.findall("c:name", NS))
price = only(product.findall("c:price", NS))
path = only(product.findall("c:path", NS))
stock = product.findall("c:stock", NS)
if len(stock) > 1:
raise ValueError("Ambiguous stock field")
records.append({"sku": product.get("sku", ""), "name": text(name),
"amount": text(price), "currency": price.get("currency", ""),
"path": text(path), "stock": text(stock[0]) if stock else "unknown"})
return records
def normalize(records, base_url, kind, fmt, run_id, stamp):
if not records:
raise ValueError("No product records; inspect the source instead of claiming sold out")
rows, seen = [], set()
for raw in records:
row = {key: str(value).strip() for key, value in raw.items()}
if not row["sku"] or not row["name"] or not row["path"] or row["sku"] in seen:
raise ValueError("Missing required identity/text or duplicate SKU")
seen.add(row["sku"])
if not re.fullmatch(r"\d+(?:\.\d+)?", row["amount"]) or Decimal(row["amount"]) <= 0:
raise ValueError("Require an explicit positive decimal amount")
if not re.fullmatch(r"[A-Z]{3}", row["currency"]):
raise ValueError("Require an explicit three-letter uppercase currency label")
target = urljoin(base_url, row.pop("path"))
parsed = urlsplit(target)
if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password:
raise ValueError("Expected HTTPS product URL without embedded credentials")
row["url"] = target
row["amount"] = str(Decimal(row["amount"]))
row["review_reason"] = ""
if row["stock"] not in {"in_stock", "out_of_stock"}:
row["stock"], row["review_reason"] = "unknown", "stock_unknown"
row.update(source_kind=kind, source_format=fmt, run_id=run_id, retrieved_at_utc=stamp)
rows.append(row)
return rows
def public_source(url):
p = urlsplit(url)
if p.scheme != "https" or not p.hostname or p.username or p.password:
raise ValueError("Use an approved public HTTPS source without credentials")
host = p.hostname.lower().rstrip(".")
if host == "localhost" or host.endswith(".localhost"):
raise ValueError("Use local fixtures, not network mode, for localhost")
try:
address = ipaddress.ip_address(host)
except ValueError:
address = None
if address is not None and not address.is_global:
raise ValueError("Private IP literals are outside network mode")
return p
def make_session(use_proxy):
session = requests.Session()
session.trust_env = False
session.headers["User-Agent"] = "AuthorizedMarkupTutorial/1.0"
if use_proxy:
host = os.environ["SOCKS5IO_HOST"]
port = int(os.environ["SOCKS5IO_PORT"])
if not re.fullmatch(r"[A-Za-z0-9.-]+", host) or not 1 <= port <= 65535:
session.close()
raise ValueError("Check issued proxy host and port")
user = quote(os.environ["SOCKS5IO_USER"], safe="")
password = quote(os.environ["SOCKS5IO_PASSWORD"], safe="")
proxy = f"socks5h://{user}:{password}@{host}:{port}"
session.proxies.update(http=proxy, https=proxy)
return session
def get_body(session, url, accepted):
with session.get(url, timeout=(5, 20), allow_redirects=False, stream=True) as response:
if response.status_code != 200:
raise ValueError(f"HTTP {response.status_code}: stop and review access/quota")
mime = response.headers.get("Content-Type", "").split(";", 1)[0].strip().lower()
if mime not in accepted:
raise ValueError("Unexpected response type; check for a changed source or challenge")
data = bytearray()
for chunk in response.iter_content(65536):
data.extend(chunk)
if len(data) > MAX_BYTES:
raise ValueError("Response exceeds tutorial limit")
return bytes(data)
def check_exit(session):
body = get_body(session, "https://api.ipify.org?format=json", {"application/json"})
address = ipaddress.ip_address(json.loads(body)["ip"])
if not address.is_global:
raise ValueError("Checker returned a non-public address")
return {"observed_ip": str(address), "checked_at_utc": datetime.now(timezone.utc).isoformat()}
def safe_diagnostic(error):
if isinstance(error, etree.XMLSyntaxError):
return "XML_SYNTAX: inspect XML well-formedness privately."
if isinstance(error, requests.RequestException):
return "NETWORK: inspect connection, proxy authentication and TLS privately."
if isinstance(error, OSError):
return "OUTPUT_IO: check destination and write permissions."
if isinstance(error, KeyError):
return "CONFIG_FIELD: check required configuration or checker fields."
message = str(error)
status = re.fullmatch(r"HTTP (\d{3}): stop and review access/quota", message)
if status:
return "HTTP_STATUS_" + status[1] + ": review authorization, quota or source status."
codes = {
"Unexpected response type; check for a changed source or challenge": "RESPONSE_MIME",
"Response exceeds tutorial limit": "INPUT_SIZE",
"Input exceeds tutorial limit": "INPUT_SIZE",
"Unexpected XML root or namespace": "XML_NAMESPACE",
"DTD-bearing XML is outside this tutorial contract": "XML_DTD",
"Expected exactly one required field or container": "FIELD_CARDINALITY",
"Ambiguous stock field": "FIELD_CARDINALITY",
"No product records; inspect the source instead of claiming sold out": "NO_RECORDS",
"Missing required identity/text or duplicate SKU": "FIELD_IDENTITY",
"Require an explicit positive decimal amount": "FIELD_AMOUNT",
"Require an explicit three-letter uppercase currency label": "FIELD_CURRENCY",
"Expected HTTPS product URL without embedded credentials": "FIELD_URL",
}
return codes.get(message, "INPUT_OR_CONFIG") + ": inspect the corresponding input or configuration privately."
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--format", choices=["html", "xml"], required=True)
parser.add_argument("--url")
parser.add_argument("--approved", action="store_true")
parser.add_argument("--proxy", action="store_true")
parser.add_argument("--out", type=Path, required=True)
args = parser.parse_args()
log_path = args.out.with_suffix(".run.json")
if args.out.exists() or log_path.exists():
parser.error("Use new output filenames")
run_id = uuid4().hex
log = {"run_id": run_id, "format": args.format, "proxy_requested": args.proxy}
if args.url:
p = public_source(args.url)
if not args.approved:
parser.error("Review source permission and request budget before --approved")
accepted = {"text/html"} if args.format == "html" else {"application/xml", "text/xml"}
with make_session(args.proxy) as session:
log["before"] = check_exit(session)
data = get_body(session, args.url, accepted)
stamp = datetime.now(timezone.utc).isoformat()
log["after"] = check_exit(session)
log["same_observed_ip"] = log["before"]["observed_ip"] == log["after"]["observed_ip"]
base, kind = args.url, "user_approved_source"
log["source"] = urlunsplit((p.scheme, p.netloc, p.path, "", ""))
else:
if args.proxy:
parser.error("A local fixture is not a proxy test")
data = HTML if args.format == "html" else XML
base, kind = BASE, "synthetic_fixture"
stamp = datetime.now(timezone.utc).isoformat()
log["source"] = "local:embedded-" + args.format
if len(data) > MAX_BYTES:
raise ValueError("Input exceeds tutorial limit")
records = parse_html(data) if args.format == "html" else parse_xml(data)
rows = normalize(records, base, kind, args.format, run_id, stamp)
log.update(source_kind=kind, records_exported=len(rows), retrieved_at_utc=stamp)
with log_path.open("x", encoding="utf-8") as handle:
json.dump(log, handle, indent=2)
with args.out.open("x", encoding="utf-8", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=list(rows[0]))
writer.writeheader()
writer.writerows(rows)
print(f"Saved {len(rows)} {args.format.upper()} records; inspect review_reason.")
if __name__ == "__main__":
try:
main()
except (ValueError, etree.XMLSyntaxError, KeyError, OSError, requests.RequestException) as error:
print("Stopped:", safe_diagnostic(error), file=sys.stderr)
sys.exit(1)
5. HTMLとXMLをそれぞれ実行し、CSVを確認します
Windowsでは、次のコマンドを実行します。
.\.venv\Scripts\python.exe markup_scraper.py --format html --out products-html.csv
.\.venv\Scripts\python.exe markup_scraper.py --format xml --out products-xml.csv
macOS/Linuxでは、次のコマンドを使います。
.venv/bin/python markup_scraper.py --format html --out products-html.csv
.venv/bin/python markup_scraper.py --format xml --out products-xml.csv
どちらのサンプルも、業務上の項目は同じ結果になります。
SKU |
商品名 |
金額 |
通貨 |
在庫 |
確認理由 |
|---|---|---|---|---|---|
SKU-DEMO-01 |
Demo Coffee |
24.90 |
USD |
in_stock |
空欄 |
SKU-DEMO-02 |
Demo Tea |
18.50 |
USD |
unknown |
stock_unknown |
商品URLは、架空のカタログを基準としたHTTPS URLになります。結果を比較するときは、次のメタデータも確認します。
source_formatは、HTMLとXMLで異なります。run_idと取得時刻は、実行ごとに生成します。- 同じレスポンスから抽出した行には、共通のタイムスタンプを付けます。
review_reasonが空でも、現在の在庫、税の扱い、別ソースの商品との同一性まで確認したことにはなりません。
CSVと同じ場所には、.run.jsonという実行記録も保存します。既存ファイルは上書きしません。ただし、CSVと実行記録を同時に確定する仕組みではないため、書き込みに失敗すると片方だけ残る場合があります。
信頼できない文字列を表計算ソフトへ取り込む場合は、数式として実行しないよう、適切なテキスト取り込み設定を使ってください。


6. 入力を変更し、想定どおりに失敗するか確認します
ネットワークに接続する前に、サンプルの一部を変更してエラー時の動作を確かめます。変更は1項目ずつ行い、出力には新しいファイル名を指定してください。
| 変更内容 | 確認する動作 |
|---|---|
HTMLのclass="product"をclass="offer"に変更します |
解析処理を変えなければ抽出を停止します。全商品を在庫切れとは判断しません |
XMLのurn:demo:catalogを別の名前空間に変更します |
想定した文書構造ではないため拒否します |
| XMLの必須の終了タグを削除します | 構文エラーで停止し、黙って修復しません |
| 価格を0にするか、通貨属性を削除します | 出力ファイルを作る前に正規化処理で拒否します |
| 2つ目の商品の在庫を未指定のままにします | どちらの形式でもunknownと確認フラグを残します |
確認後はサンプルを元に戻します。この例は、1レスポンスにつき1つのSKUを1レコードとして扱うため、SKUの重複を拒否します。仕様違い、店舗別在庫、複数販売者のオファーを扱う場合は、それらを区別できる識別方法を先に定義してください。
XMLをPythonで解析する際、名前空間と安全性はどう扱いますか?
XMLで要素が見つからない場合は、データ欠落を疑う前に名前空間とパスを確認します。また、XMLの構文が正しくても、安全性や業務上の正しさが保証されるわけではありません。
接頭辞よりも名前空間URIを確認します
root.findall("c:product", NS)は、cを対応する名前空間URIへ展開して検索します。XML側で別の接頭辞を使っていても、URIが同じなら同じ修飾名を表します。URIを変更することは、接頭辞の表記を変えることとは異なります。
このサンプルでは、skuやcurrency属性に名前空間は付いていません。一方、要素名にはデフォルト名前空間が適用されます。RSS、Atom、サイトマップを処理するときも、実際のルート要素、階層、項目の対応を確認してください。
findallとXPathの機能は同じではありません
ElementTree形式のfindallで使えるパス表現には制限があります。より複雑な問い合わせが必要なら、lxmlのXPathインターフェースを検討します。ただし、問い合わせ方法を変えても、抽出結果の検証は必要です。
信頼できないXMLの解析機能を制限します
PythonのXMLセキュリティガイドでは、リソース枯渇などのリスクを説明しています。lxmlの解析ドキュメントと合わせて、設定の意味を確認できます。
このコードでは、次の制限を設けています。
resolve_entities=Falseで、エンティティの置換を無効にします。load_dtd=Falseで、外部DTDの読み込みを無効にします。no_network=Trueで、外部文書の参照時にネットワークを使わせません。recover=Falseで、不正なXMLを自動修復しません。huge_tree=Falseを維持し、巨大なツリー向けに安全上の制限を解除しません。- DTDを含む文書を拒否し、XIncludeやXSLTは実行しません。
レスポンスの上限は2_000_000バイトです。これらは小さな教材用入力を扱うための基本設定であり、完全なセキュリティサンドボックスではありません。本番ではライブラリを更新し、プロセス単位のメモリや実行時間も制限します。
また、整形式のXMLとスキーマ検証済みXMLは同じではありません。このコードはルート要素と必須項目を確認しますが、XSDやDTDによる検証は行いません。
大きな許可済みXMLを扱うなら、iterparseなどのストリーミング処理を検討します。処理済み要素の解放や出力件数の制御も必要です。本例は小さなツリーをメモリ上に構築する方式であり、一定メモリでの処理や、解析速度の優位性を実証するものではありません。
実際のデータソースへ切り替えるには、何を変更しますか?
先に取得先の構造と項目の意味に合わせて抽出処理を修正し、その後でURLを変更します。サンプルのセレクターや名前空間は、実在サイトや任意のXMLで使える共通設定ではありません。
次の順序で進めます。
- アクセス許可とリクエスト上限を確認し、テストに使えるレスポンスを保存します。
- 形式、文字コード、ルート要素またはコンテナー、レコードの識別方法を記録します。
- 必須項目と欠損値の扱いを決め、対応する解析関数を修正します。
- 保存したレスポンスで結果を確認してから、ネットワーク取得へ切り替えます。
以下のURLは、コマンドの形を示すための無効なサンプルです。.invalidのままでは実際の取得はできません。解析処理を対応させたうえで、許可のある取得先へ置き換えてください。
Windows:
.\.venv\Scripts\python.exe markup_scraper.py --format html --url "https://catalog.example.invalid/approved-products" --approved --out approved-html.csv
.\.venv\Scripts\python.exe markup_scraper.py --format xml --url "https://catalog.example.invalid/approved-export.xml" --approved --out approved-xml.csv
macOS/Linux:
.venv/bin/python markup_scraper.py --format html --url "https://catalog.example.invalid/approved-products" --approved --out approved-html.csv
.venv/bin/python markup_scraper.py --format xml --url "https://catalog.example.invalid/approved-export.xml" --approved --out approved-xml.csv
ネットワークモードでは、指定済みのHTML/XMLのMIMEタイプだけを受け付け、自動リダイレクトは行いません。application/xhtml+xml、RSS固有のMIMEタイプ、別の要素構造を使うXMLは、明示的に対応方針を決める必要があります。
さらに、この例には次の制限があります。
- 対象は、取得許可のある公開HTTPSデータソースです。
- localhostとプライベートIPの直接指定は拒否しますが、一般的なSSRF対策やDNSリバインディング対策を網羅していません。
- 取得するのは1つの文書です。商品リンクの巡回、ページ送り、JavaScript実行、非公開APIの探索は行いません。
- 相対商品URLの基準は取得元URLです。HTMLの
base要素には対応していないため、必要なら取得先ごとに処理します。 - エラーを表すXMLも整形式になり得ます。MIMEタイプだけでなく、ルート要素と必須項目も確認します。
PythonでSOCKS5プロキシを設定するにはどうすればよいですか?
Socks5.IOは、取得処理に特定の接続経路や出口地域が必要なときに組み込みます。プロキシは文書を取得する経路を変えますが、その後のHTMLセレクターやXML名前空間の処理は変わりません。
小規模な許可済み収集で経路の要件がなければ、直接接続で十分な場合もあります。住宅回線の出口や特定市場からの確認が必要なら、Socks5.IOのローテーション型レジデンシャルプロキシを検討します。
利用前には、対象地域、認証方法、セッションの挙動、通信量の有効期限、購入条件を確認してください。IPプールの規模だけでは、特定都市の出口が使えることや対象サイトにアクセスできることを判断できません。プロキシを使っても、取得先の許可するリクエスト数は増えません。
1. ダッシュボードから接続情報を取得します
Socks5.IOのユーザー名・パスワード認証ガイドを参照し、実際の画面と契約製品の選択肢に合わせて操作します。
- Rotating Residential IP → Proxy Setupを開きます。
- アカウント認証を選び、使用するプロキシユーザーを指定します。
- 地域とセッションの要件を確認し、対応するプロトコルを選択します。
- 接続情報を生成し、ホスト、ポート、プロキシ用のユーザー名とパスワードを控えます。
必要なら、Sub Account Managementからプロキシユーザーを作成します。生成した認証用ユーザー名は、ダッシュボードへのログイン名とは別です。

2. 環境変数を設定して実行します
専用のPowerShellプロセスで、生成した接続情報を入力します。
$env:SOCKS5IO_HOST = Read-Host 'Issued proxy host'
$env:SOCKS5IO_PORT = Read-Host 'Issued proxy port'
$env:SOCKS5IO_USER = Read-Host 'Issued proxy username'
$markupProxySecret = Read-Host 'Issued proxy password' -AsSecureString
$env:SOCKS5IO_PASSWORD = [System.Net.NetworkCredential]::new('', $markupProxySecret).Password
取得先への対応とアクセス許可を確認したうえで、--proxyを付けて実行します。
.\.venv\Scripts\python.exe markup_scraper.py --format xml --url "https://catalog.example.invalid/approved-export.xml" --approved --proxy --out proxy-xml.csv
macOS/Linuxでは、Bashで次のコマンドを実行します。パスワードをコマンドへ直接書かず、対話入力してから仮想環境のPythonを呼び出します。
read -r -p 'Issued proxy host: ' SOCKS5IO_HOST
read -r -p 'Issued proxy port: ' SOCKS5IO_PORT
read -r -p 'Issued proxy username: ' SOCKS5IO_USER
read -r -s -p 'Issued proxy password: ' SOCKS5IO_PASSWORD; printf '\n'
export SOCKS5IO_HOST SOCKS5IO_PORT SOCKS5IO_USER SOCKS5IO_PASSWORD
.venv/bin/python markup_scraper.py --format xml --url "https://catalog.example.invalid/approved-export.xml" --approved --proxy --out proxy-xml.csv
unset SOCKS5IO_PASSWORD
readのオプションはBash向けです。別のシェルを使っている場合は、Bashへ切り替えて実行してください。
完全版では認証情報をパーセントエンコードし、socks5hを使って接続先ドメインの名前解決をプロキシ側で行います。詳細はRequestsのSOCKSドキュメントで確認できます。クライアント側では、プロキシゲートウェイの名前解決と接続が必要です。
通信設定には、次の前提もあります。
- TLS証明書の検証は有効のままです。
trust_env=Falseにより、環境変数で設定されたプロキシや証明書バンドルの指定は使いません。- プロキシ接続に失敗しても、自動的に直接接続へ切り替えません。
- 独自CAが必要な組織では、承認された証明書バンドルを明示的に設定します。
SOCKS5がHTTP(S)より常に高速、匿名性が高い、またはXMLに適しているわけではありません。エンドポイントとクライアントの対応状況で選びます。詳しくはSOCKS5とHTTPプロキシの違いを参照してください。
ブラウザーへの組み込みは別途検証が必要です。Requestsの認証設定を、そのままChromiumに適用できるとは限りません。
3. 収集に使うクライアントで出口IPを確認します
ネットワークモードでは、1つのSession内で、ipifyの公開JSON確認サービス、取得先、再びipifyの順にリクエストします。第三者サービスの利用条件とプライバシーへの影響を確認したうえで使ってください。確認サービスへの接続に失敗すると収集を停止します。
実行記録のbefore、after、same_observed_ipを確認し、必要なら許可された直接接続の結果と比較します。地域の確認が必要な場合は、別途検証します。
前後の確認でIPが一致しても、その間の取得先へのリクエストが同じ出口を使ったとは断定できません。 リクエストごとに出口が変わる設定もあり、Sessionを使うだけでは固定できないためです。
出口の継続性が必要なら、製品が対応する固定IPやスティッキーセッションの設定を確認し、その要件を別途検証します。別のIP確認サービスへ変更する場合も、実際のエンドポイントと返却項目に合わせて修正してください。未確認のSocks5.IO専用検出APIを前提にしないでください。
4. ログと認証情報を適切に扱います
CSVには、データソースから取得した商品URLをそのまま保存します。一方、実行記録の取得元URLからは、クエリ文字列とフラグメントを除きます。
再現のために完全なURLが必要なら、アクセスを制限した別の記録に保存し、run_idで関連付けます。予約トークン、アカウント用リンク、個人情報、プロキシ認証情報を公開ファイルに含めないでください。
PowerShellでは、作業後にパスワードを削除します。
Remove-Item Env:SOCKS5IO_PASSWORD
Remove-Variable markupProxySecret
エラーの原因を切り分け、収集規模を広げるには?
アクセス拒否、レスポンス形式、解析、項目検証、通信を分けて調べます。原因を確認せずにIPを切り替えたり、並列数を増やしたりしても、正しいデータは得られません。
このプログラムは、生の例外メッセージ、URL、認証情報ではなく、あらかじめ定義した診断コードを出力します。HTTP_STATUS_403とHTTP_STATUS_429では応答の種類を区別し、XML_NAMESPACEではルート要素や名前空間の不一致を示します。不明なエラーは汎用カテゴリに分類します。

| 診断コード・症状 | 確認する内容 |
|---|---|
HTTP_STATUS_401、HTTP_STATUS_403 |
処理を止め、認証やアクセス条件を確認します |
HTTP_STATUS_429 |
配額・レート制限、Retry-After、許可された再試行方法を確認します |
RESPONSE_MIME |
同意画面、検証画面、エラー、リダイレクトなど、想定外の内容が返っていないか確認します |
INPUT_SIZE |
許可された仕様の範囲で文書を小さくするか、別の処理方式を検討します |
XML_NAMESPACE、XML_SYNTAX、XML_DTD |
要素構造、名前空間、構文、DTDの有無を確認します |
FIELD_AMOUNT、FIELD_CURRENCY |
価格と通貨の欠損、形式、値の条件を確認します |
FIELD_IDENTITY、FIELD_CARDINALITY |
必須項目、SKUの重複、要素の一致件数を確認します |
NETWORK |
接続、プロキシ認証、TLSを確認します |
OUTPUT_IO |
保存先、書き込み権限、出力ファイルの状態を確認します |
CONFIG_FIELD |
必須設定やIP確認サービスの返却項目を確認します |
| 要素が見つからない | パーサー、パス、名前空間、文書構造を確認します。在庫ゼロとは判断しません |
| メモリや処理時間が増える | 入力サイズと、資源を制限したストリーミング処理を検討します |
詳細なレスポンスは、公開ログではなく管理された環境で確認します。アクセス拒否を避けるための自動IP切り替えや、429に対する無制限な並列実行は行いません。
取得先ごとに全ワーカー共通のリクエスト予算を設け、キャッシュは許可された範囲で使います。並列数、再試行回数、総処理時間も制限してください。コードのtimeout=(5, 20)は接続と読み取りのタイムアウトであり、ジョブ全体の制限時間ではありません。
HTTPの成功と有効レコード数を別々に測ります
1つのレスポンスに複数の商品が含まれるため、レコード数をリクエスト数で割っても、リクエスト成功率にはなりません。次の項目を分けて記録します。
- 想定した内容を返したレスポンスの数
- 構造変更による解析エラーの数
- 項目の再確認が必要なレコードの割合
- 重複レコードと識別子の衝突
- データの取得から経過した時間
- 処理時間、タスクに対応する通信量・料金、保守コスト
ネットワーク経路や費用の比較には、スクレイピング向けプロキシの選び方も参考になります。プロキシの選定とは別に、解析結果の品質を検証する必要があります。
ローカル検証と実際のネットワーク検証を区別します
英語原稿の検証記録では、21項目のチェックを実施し、失敗・エラーは0と記載しています。ネットワークの動作にはモックを使っており、実際のSocks5.IOエンドポイントの性能は測定していません。

活用例:HTMLの商品ページとXMLの仕入先データを照合します
複数ソースの価格を比較する前に、商品・バリエーションの識別子、通貨、価格の意味をそろえます。空の抽出結果を、そのまま欠品や商品削除と判断してはいけません。
たとえば、小売事業者が自社のHTMLカタログと、利用許可を得た仕入先のXMLエクスポートを毎日照合するとします。対応関係を確認済みの20SKUを、それぞれのソースで調べます。
計画上は「取得元とSKUの組み合わせ」を40件確認しますが、40回のリクエストや40件の一致レコードを保証する数字ではありません。1文書に複数SKUが含まれる場合もあれば、必要なレコードが取得できない場合もあります。
運用では、次のように扱います。
- HTMLとXMLで抽出処理を分け、共通の出力項目にそろえます。
- 通貨、価格の定義、商品とバリエーションの識別子を確認します。
- 在庫が不明なら確認待ちにし、在庫切れ通知を出しません。
- XMLの名前空間変更は抽出処理の不一致として扱い、全商品が削除されたとは判断しません。
- HTMLがHTTP 200で返っても、同意画面であれば有効な商品データとして扱いません。
- 地域を指定した取得が必要な場合だけ、対応するプロキシを設定して検証します。
規模を広げる前に、有効な商品情報をどれだけ取得できたかと、保守にかかるコストを評価します。これは運用設計の例であり、顧客の実績、実測の削減額、アクセス成功率を示すものではありません。
まとめ:データを検証してから、必要な通信経路を選びます
XMLとHTMLのスクレイピングは、取得元の構造を把握し、形式ごとに解析して、共通のルールで検証することから始めます。Socks5.IOの設定は、その処理に必要なネットワーク要件が明確になってから追加します。
まず2つのローカルサンプルを動かし、CSVの項目と確認フラグを比べてください。次に、利用許可のある取得元へ抽出処理を対応させます。特定の経路が必要なら、発行された代理認証情報を使い、解析とは別に接続を検証します。
アクセス条件、レコードの品質、リソース上限、費用が要件を満たしていることを確認してから、収集規模を広げます。






