0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

LLMスクレイパーの作り方:取得・軽量化・抽出・検証の4層設計

0
Posted at

LLMスクレイパーの作り方:取得・軽量化・抽出・検証の4層設計

LLMスクレイパーとは、ページの内容をLLMに渡し、JSONスキーマに沿った構造化データを取り出すスクレイピングの方式です。CSSセレクタやXPathの代わりに、「商品名・税込価格・在庫をJSONで返す」といった指示とスキーマによって抽出します。

作り方は以下の4段階です。

  1. HTMLを取得する。
  2. script・nav等を除去し、Markdownやテキストに軽量化する。
  3. PydanticやZodのスキーマと一緒にLLMへ渡す。
  4. 返却されたJSONを検証する。

LLMが担当するのは3のみであり、1のブロック対策や動的ページのレンダリングは別途必要です。取得には Bright Data の Web Unlocker API を利用でき、主要サイトには1300以上の構築済みスクレイパーが用意されているため、そもそもLLM抽出が不要となる場合もあります。

LLMスクレイパーとは何か、従来のスクレイパーと何が異なるのか?

LLMスクレイパーは、テキストの「意味」を手がかりに値を取り出すスクレイピング手法の総称であり、以下の3つの形態で提供されています。

  • 取得基盤型:ブロック対策付きでHTMLやJSONを返却し、抽出処理は別層に任せる形態(例:Bright Data)。
  • API型:URLを送信した場合にMarkdownや構造化JSONが返却される形態(例:Firecrawl)。
  • ライブラリ型:自社環境で動作するコードとして組み込む形態(例:Crawl4AI、ScrapeGraphAI、llm-scraper)。

重要な前提として、LLMはログイン・CAPTCHA・JavaScriptレンダリング・アクセス制限のいずれも解決しません。ページ本文が手元に届かなければ、LLMには抽出する対象自体が存在しないためです。

比較軸 セレクタ型(従来) LLM抽出型
抽出方法 CSSセレクタ・XPathによる位置指定 自然言語の指示とスキーマによる意味指定
サイト構造変更への耐性 低い(クラス名変更で破損) 高い(文言が残っていれば抽出可能)
コスト ほぼ取得コストのみ 取得コストに加えてトークン課金が発生
速度 高速(HTML解析のみ) LLM呼び出し分のレイテンシが発生
再現性 高い(同一入力で同一出力) 揺らぎあり(検証層で補完が必要)
向く対象 構造が安定した大量ページ 構造がサイトごとに異なる少量〜中量の収集

LLMスクレイパー4層パイプラインとは何か?

「LLMスクレイパー4層パイプライン」は、スクレイピング処理を「取得」「軽量化」「抽出」「検証」の4層に分け、LLMの使用を抽出層のみに限定する設計です。各層の失敗原因が異なるため、層を分離することで原因の切り分けが容易になります。

[1 取得層]  URL ──► HTML(ブロック対策・JSレンダリング)
              │
[2 軽量化層] HTML ──► Markdown / テキスト(script・nav・footer除去)
              │
[3 抽出層]  Markdown + スキーマ ──► JSON   ← LLMを使用するのはここのみ
              │
[4 検証層]  JSON ──► 型・範囲・原文との突合 ──► 保存 / 再試行
層 使用する技術の例 従来技術で十分な場合 LLMを使用する場合
取得 httpx、Playwright 静的HTMLでブロックが発生しない 該当なし(LLMでは解決不可)
軽量化 BeautifulSoup、Trafilatura 本文領域のセレクタが固定されている 該当なし(ルールベースで十分)
抽出 Pydantic、Zod、Instructor 価格・IDなど厳密な値で構造が安定 表記が揺れる文章やサイトごとに構造が異なる収集
検証 Pydantic、独自の突合ロジック 型と範囲のチェックで足りる 抽出値の原文突合や差分監視が必要

各層の失敗例と切り分けの方法は以下の通りです。

  • 取得層の失敗:403エラーが返却される、本文が空である、CAPTCHAページが返却される。保存したHTMLをブラウザで開き、目的の情報が含まれているかを確認します。
  • 軽量化層の失敗:必要な情報が除去される。軽量化前後のテキストで目的の値を検索し、どちらの段階で消失したかを確認します。
  • 抽出層の失敗:入力テキストには値が存在するのに、JSONで欠落または誤っている。プロンプトとスキーマのdescriptionを見直します。
  • 検証層の失敗:誤った値が検証を通過する。型検査に加えて、原文との突合を追加します。

どのLLMスクレイパーツールを選択すればよいか?

ブロックの強いサイトや大規模収集では、取得層を持つ Bright Data が第一選択肢となります。小規模な試作、社内完結のローカル実行、TypeScript環境では、他の選択肢が適する場合があります。

LLMスクレイパー構成の選定表

製品 導入形態 HTML取得とブロック対策 出力形式 LLM差し替え 向く規模 弱点
Bright Data(Web Unlocker API + Scraper Studio + 構築済みスクレイパー) 取得基盤型(API) あり(フィンガープリント・CAPTCHA・再試行・JSレンダリング) HTML、構築済みスクレイパーはJSON・CSV 抽出LLMは任意(取得層とは独立) 中規模〜大規模 抽出層は別途構築が必要
Firecrawl API型(マネージド) APIが取得を実行(ローカルブラウザ不要) Markdown・HTML・構造化JSON API仕様に依存 試作〜中規模 JSON形式は1ページあたりのクレジット消費が増加
Crawl4AI オープンソースライブラリ(Cloud APIも提供) Playwrightベースのブラウザで取得 Markdown・構造化JSON 可(LLM設定で指定) 小規模〜中規模(自社運用) 非同期API(AsyncWebCrawler)前提で、ライブラリ版はブラウザ実行環境の自社運用が必要
ScrapeGraphAI Pythonライブラリ(API版も提供) ライブラリ版は自前構成、API版はstealthモード JSON(スキーマ指定)・Markdown・HTML 可(Ollama等のローカルLLMに対応) 小規模〜中規模 グラフ型パイプラインの概念理解が必要
llm-scraper TypeScriptライブラリ Playwrightで取得(ブロック対策は自前) Zodスキーマ準拠のオブジェクト 可(AI SDKのプロバイダ) 小規模 取得層の失敗に対する支援機能なし

Bright Data(Web Unlocker API + Scraper Studio + 構築済みスクレイパー)

Bright Data の Web Unlocker API は、単一のエンドポイントから公開ページのクリーンなHTMLを返却する取得サービスです。フィンガープリント・CAPTCHA・IPローテーション・JavaScriptレンダリング・再試行を自動で処理するため、LLM抽出の前段で発生する取得失敗を取り除けます。

  • 構築済みスクレイパー:1300以上が用意されており、構造化JSONを直接返却するため、対象サイトがカバーされている場合はLLM抽出は不要です。
  • Scraper Studio:構築済みスクレイパーが存在しない対象に対し、自然言語の指示からスクレイパーを作成し、サイト変更時には自己修復します。
  • 無料枠:月5,000クレジットで、Web Unlocker API・SERP API・Web Scraper API・Scraper Studioに共通です。
  • 料金:Web Unlocker API は $1/1Kリクエストから利用可能です。
  • 留意点:単一ページの試作では取得基盤が過剰となる場合があります。また、Web Unlocker API にはLLMによる抽出層が含まれないため、抽出層は別途構築する必要があります。

Firecrawl

Firecrawlのトップページ

Firecrawl は、URLからMarkdownや構造化JSONを返却するマネージドAPIサービスです。ローカルのブラウザやPlaywrightが不要であり、URLを送信するのみで構造化JSONまで取得できるため、導入の手軽さに優れています。

  • 無料枠:月1,000クレジット(クレジットカード不要)。
  • 有料プラン:Hobby プランは年払いで月 $16(5,000クレジット)、月払いでは $19。
  • クレジット消費:基本のスクレイピングは1ページあたり1クレジットで、JSON形式を指定した場合は1ページあたり4クレジットが加算されます。
  • 失敗時の扱い:結果が返却されないスクレイピングは課金されません。一方、403や404エラーのページは返却され、1クレジットが消費される点に留意する必要があります。

Crawl4AI

Crawl4AIのトップページ

Crawl4AI は、Webページを LLM 向けのMarkdownや構造化データへ変換するオープンソース(OSS)のクローラです。ローカルで実行でき、ページ単位の従量課金が発生しないため、社内で完結させたい用途に適しています。

  • API設計:非同期の AsyncWebCrawler を中心とした設計であり、asyncio を前提としたコードの記述が必要。
  • 抽出方式:LLMを使用しない抽出戦略とLLM抽出戦略の双方が用意されています。
  • Cloud API:無料枠は月1,000クレジット、Starter プランは月 $10(10,000クレジット)。
  • 留意点:ライブラリ版ではブラウザ実行環境を自社で運用する必要があり、運用負荷を避けたい場合はCloud APIの利用が選択肢となります。

ScrapeGraphAI

ScrapeGraphAIのトップページ

ScrapeGraphAI は、グラフ型パイプラインでスクレイピングを組み立てるPythonライブラリであり、API版も提供されています。ローカルLLM(Ollama)を使用できるため、データを社外へ送信できない環境でも抽出層を構築できます。

  • 規模感:GitHubのスター数は2.73万以上。
  • API版の消費量:抽出は1リクエストあたり5クレジット、Markdown取得は1クレジット。stealthモードを有効にした場合、1リクエストあたり5クレジットが加算されます。
  • 料金:無料プランは500クレジット(一回限り)、Starter プランは月 $20(10,000クレジット)。
  • 留意点:グラフ型の概念を理解する必要があり、単純な1ページ抽出ではコード量が増える場合があります。

llm-scraper

llm-scraper は、Zodスキーマを軸にPlaywrightで取得したページからデータを抽出するTypeScriptライブラリです。型定義とスキーマを一体で管理でき、Node.js環境のチームにとって導入の障壁が低い選択肢です。

  • 取得方式:Playwrightのページオブジェクトを渡す構成です。
  • 留意点:ブロック対策・CAPTCHA・再試行は組み込まれていないため、取得層は別途用意する必要があります。

用途別の推奨

  • ブロックの強いサイト・大規模収集:Bright Data の Web Unlocker API で取得し、抽出層を自社構築します。
  • 小規模な試作:Firecrawl で、1URLから構造化JSONを取得します。
  • 社内完結のローカル実行:Crawl4AI あるいは ScrapeGraphAI + Ollama を採用します。
  • TypeScript環境:llm-scraper を採用します。

PythonでLLMスクレイパーを自作する手順は?

最小実装に必要なライブラリは、httpx・beautifulsoup4・markdownify・pydantic・openai の5つです。JavaScriptでレンダリングされるページを自前で扱う場合のみ、playwright を追加します。

pip install httpx beautifulsoup4 markdownify pydantic openai
# JavaScript描画が必要な場合のみ
pip install playwright
playwright install chromium

以下のコードは4層のうち、取得(手順1)、軽量化(手順2)、抽出(手順3・4)、検証(手順5)を1ファイルにまとめたものです。環境変数 OPENAI_API_KEY を設定して使用します。

import re
import time
import unicodedata
from typing import Literal

import httpx
from bs4 import BeautifulSoup
from markdownify import markdownify as to_markdown
from openai import OpenAI
from pydantic import BaseModel, Field, ValidationError

MODEL_NAME = "gpt-4o-mini"
USER_AGENT = "Mozilla/5.0 (compatible; MyScraper/1.0)"


# 手順3:日本語のdescription付きスキーマを定義する
class Product(BaseModel):
    """抽出対象の商品情報を表すスキーマ。"""

    name: str = Field(description="商品名。型番・セール文言・店舗名は含めない")
    price_jpy: int = Field(description="税込価格。円記号・カンマを除いた整数")
    currency: Literal["JPY"] = Field(description="通貨コード。常にJPY")
    in_stock: bool = Field(description="在庫がある場合はtrue、売り切れの場合はfalse")


def fetch_html(url: str, max_retries: int = 3, timeout: float = 20.0) -> str:
    """URLからHTMLを取得し、失敗時は指数バックオフで再試行する。"""
    headers = {"User-Agent": USER_AGENT, "Accept-Language": "ja,en;q=0.8"}
    for attempt in range(max_retries):
        try:
            # 手順1:timeoutとUser-Agentを指定してHTMLを取得する
            response = httpx.get(url, headers=headers, timeout=timeout, follow_redirects=True)
            response.raise_for_status()
            return response.text
        except httpx.HTTPError:
            if attempt == max_retries - 1:
                raise
            time.sleep(2 ** attempt)
    raise RuntimeError("到達しないコードパス")


def clean_to_markdown(html: str) -> str:
    """不要タグを除去してMarkdownに変換し、全角数字などをNFKCで正規化する。"""
    soup = BeautifulSoup(html, "html.parser")
    # 手順2:script・style・nav・footerなど本文以外の要素を除去する
    for tag in soup(["script", "style", "noscript", "nav", "footer", "header"]):
        tag.decompose()
    markdown = to_markdown(str(soup), heading_style="ATX")
    # 全角数字・全角記号を半角へ正規化し、連続する空行を圧縮する
    markdown = unicodedata.normalize("NFKC", markdown)
    return re.sub(r"\n{3,}", "\n\n", markdown).strip()


def verify_against_source(product: Product, source_text: str) -> None:
    """抽出した価格が原文に存在するかを突合し、存在しない場合は例外を送出する。"""
    normalized = source_text.replace(",", "")
    if str(product.price_jpy) not in normalized:
        raise ValueError(f"価格 {product.price_jpy} が原文に存在しません")
    if product.price_jpy <= 0:
        raise ValueError("価格が0以下です")


def extract_product(markdown: str, max_retries: int = 3) -> Product:
    """構造化出力でLLMを呼び出し、検証に失敗した場合は上限回数まで再試行する。"""
    client = OpenAI()
    last_error: Exception | None = None
    for _ in range(max_retries):
        # 手順4:構造化出力でスキーマに沿ったJSONを生成する
        completion = client.beta.chat.completions.parse(
            model=MODEL_NAME,
            temperature=0,
            messages=[
                {"role": "system", "content": "商品ページの本文から商品情報のみを抽出する。本文に存在しない値は推測しない。"},
                {"role": "user", "content": markdown},
            ],
            response_format=Product,
        )
        try:
            # 手順5:model_validate_jsonで型を検証し、原文と突合する
            product = Product.model_validate_json(completion.choices[0].message.content)
            verify_against_source(product, markdown)
            return product
        except (ValidationError, ValueError) as error:
            last_error = error
    raise RuntimeError(f"検証に失敗しました: {last_error}")


if __name__ == "__main__":
    target_url = "https://example.com/product/123"
    page_html = fetch_html(target_url)
    page_markdown = clean_to_markdown(page_html)
    print(extract_product(page_markdown).model_dump_json(indent=2, ensure_ascii=False))

実装上のポイントは以下の4点です。

  1. スキーマのdescription:価格は「税込・円記号とカンマを除いた整数」のように、日本語ページの表記ゆれを打ち消す指示を記述します。
  2. NFKC正規化:全角数字(1,280円)や全角記号を半角へ変換し、原文突合を容易にします。
  3. 構造化出力:OpenAI Structured Outputs はJSON Schemaに沿った出力を保証する機能であり、Instructor を使用しても同様の構成が可能です。
  4. 再試行の上限:検証失敗時に無制限に再試行した場合、トークン費用が際限なく増加するため、上限回数を設定する必要があります。

TypeScriptであればllm-scraperでどう記述するのか?

TypeScriptでは、Zodスキーマを定義し、Playwrightのページオブジェクトと共にllm-scraperへ渡します。Pythonの4層との対応は、取得がPlaywright、軽量化がフォーマット指定、抽出がLLM呼び出し、検証がZodによる型検証となります。

import { chromium } from 'playwright'
import { z } from 'zod'
import { openai } from '@ai-sdk/openai'
import LLMScraper from 'llm-scraper'

// 1. 抽出スキーマをZodで定義する
const schema = z.object({
  name: z.string().describe('商品名。型番やセール文言は含めない'),
  priceJpy: z.number().int().describe('税込価格。円記号とカンマを除いた整数'),
  inStock: z.boolean().describe('在庫がある場合はtrue'),
})

// 2. 使用するLLMプロバイダを指定する
const llm = openai.chat('gpt-4o-mini')
const scraper = new LLMScraper(llm)

// 3. Playwrightでページを取得する
const browser = await chromium.launch()
const page = await browser.newPage()
await page.goto('https://example.com/product/123')

// 4. 抽出を実行し、Zodによる検証済みのデータを受け取る
const { data } = await scraper.run(page, schema, { format: 'markdown' })
console.log(data)
await browser.close()

フォーマットの選択は以下の基準で行います。

  • html:セレクタで取得できない属性値(data-*属性やリンク先URL)が必要な場合。
  • markdown:本文の意味を抽出する通常の用途で、トークン消費を削減する場合。
  • text:装飾が不要で、最小のトークン数に抑える場合。
  • 画像:レイアウト上の位置関係が意味を持つページや、テキストとして取得できない表示を扱う場合。

LLMプロバイダの切り替えは、LLMScraper に渡すモデルのインスタンスを変更するのみで行えます。AI SDKに対応したプロバイダであれば、OpenAIからOllama等のローカルLLMへの差し替えも同様の方法で可能です。

LLMスクレイピングは1ページあたりいくらかかるのか?

1ページあたりのコストは、取得コストとLLMのトークン課金の合計で計算できます。

1ページのコスト = 取得コスト
                + 入力トークン数 × 入力単価
                + 出力トークン数 × 出力単価
月額コスト = 1ページのコスト × 月間ページ数

各変数の求め方は以下の通りです。

  • 入力トークン数:軽量化後のMarkdownとプロンプト・スキーマの合計。tiktokenで計測します。
  • 出力トークン数:抽出するJSONのサイズ。項目数が少なければ小さくなります。
  • 単価:使用するモデルの公式料金表の値を使用します。
  • 取得コスト:Web Unlocker API は $1/1Kリクエストから利用可能であり、月5,000クレジットの無料枠(1リクエストあたり1クレジット)が適用されます。

日本語ページはトークナイザの仕様上、同じ文字数でも英語より入力トークン数が多くなりやすい傾向があります。また、生HTMLにはscript・style・属性値が大量に含まれるため、軽量化前後で入力トークン数は大きく異なります。自社の対象ページで計測する際は、以下のコードを使用します。

import tiktoken


def count_tokens(text: str, encoding_name: str = "o200k_base") -> int:
    """テキストのトークン数を計測して返却する。"""
    encoding = tiktoken.get_encoding(encoding_name)
    return len(encoding.encode(text))


# 1. 軽量化前後のトークン数を比較する
raw_tokens = count_tokens(page_html)
clean_tokens = count_tokens(page_markdown)
print(f"生HTML: {raw_tokens} / 軽量化後: {clean_tokens}")

月1万ページと10万ページの試算は、計測したトークン数と選択したモデルの単価を上記の式へ代入して算出します。取得コストが月間ページ数に比例して加算される点に留意する必要があります。

LLMコストが取得コストを上回るかどうかの境界は、主に入力トークン数によって決まります。軽量化によって入力トークン数を削減した場合、LLMコストは取得コストに近づきます。

なお、API型の製品では、構造化JSONで抽出する場合のクレジット消費が通常の取得より多くなります。Firecrawl ではJSON形式を指定した場合、基本の1クレジットに4クレジットが加算され、ScrapeGraphAI では抽出が1リクエストあたり5クレジット(Markdown取得は1クレジット)です。

ブロックされるサイトや動的ページではLLMスクレイパーをどう動かすのか?

取得層で失敗したページは、LLMでは抽出できません。403エラーページやCAPTCHA画面のHTMLをLLMに渡しても、返却されるのは「該当情報なし」か、場合によっては誤った推測値です。したがって、ブロックされるサイトでは取得層をWeb Unlocker APIに任せ、受け取ったHTMLを軽量化層以降へ渡す構成とします。

Web Unlocker API は、フィンガープリント・CAPTCHA・再試行・JavaScriptレンダリングを処理した上でHTMLを返却します。以下は、4層パイプラインの fetch_html を差し替える例です。

import os

import httpx


def fetch_html_via_unlocker(url: str, timeout: float = 60.0) -> str:
    """Web Unlocker APIにURLを送信し、ブロック対策済みのHTMLを返却する。"""
    response = httpx.post(
        os.environ["BRIGHTDATA_UNLOCKER_ENDPOINT"],
        headers={"Authorization": f"Bearer {os.environ['BRIGHTDATA_API_KEY']}"},
        json={"zone": os.environ["BRIGHTDATA_ZONE"], "url": url, "format": "raw"},
        timeout=timeout,
    )
    response.raise_for_status()
    return response.text


# 1. 取得結果をそのまま軽量化層・抽出層へ渡す
html = fetch_html_via_unlocker("https://example.com/product/123")
product = extract_product(clean_to_markdown(html))

複数ステップの操作(ログイン後の画面遷移、ボタンのクリック、無限スクロール等)が必要な場合のみ、Browser API を使用します。単純な取得であれば、Web Unlocker API で十分です。

BeautifulSoup単体ではレンダリングされないSPA(Single Page Application)ページの例を以下に示します。httpx で取得したHTMLには、JavaScript実行前の空の枠のみが含まれています。

<html>
  <body>
    <div id="root"></div>
    <script src="/static/app.js"></script>
  </body>
</html>

このHTMLから軽量化層でscriptを除去した場合、本文は空になります。LLMに渡しても抽出できないため、JavaScriptレンダリングを実行する取得層(Web Unlocker API、あるいはPlaywright等)が必要です。

LLM抽出とセレクタ抽出はどのように使い分けるべきか?

厳密な値が必要で構造が安定している場合はセレクタ、表現が揺れるテキストを扱う場合はLLMが適しています。

条件 推奨方式 理由
価格・IDなど厳密な値 セレクタ 同一入力で同一出力となり、誤抽出が発生しない
構造が安定したサイト セレクタ LLMコストが不要
数万ページ以上の収集 セレクタ トークン課金が月間ページ数に比例して増加する
レビュー・会社概要など表現が揺れる非構造テキスト LLM 文言の違いにスキーマで対応可能
サイトごとに構造が異なる収集 LLM サイト別のセレクタ作成・メンテナンスが不要
少量の収集 LLM セレクタ作成の工数のほうが大きい
Amazon・LinkedInなど構築済みスクレイパーが存在するサイト 構築済みスクレイパー 構造化JSONが直接返却され、LLMは不要
構築済みスクレイパーが存在しない対象 Scraper Studio 自然言語の指示から作成でき、サイト変更時に自己修復する

セレクタで取得し、LLMは分類や要約のみに使用するハイブリッド構成も有効です。以下は、価格をセレクタで取得し、レビューの感情分類のみをLLMに任せる例です。

from bs4 import BeautifulSoup


def extract_hybrid(html: str) -> dict:
    """価格はセレクタで取得し、レビュー文のみをLLMで分類する。"""
    soup = BeautifulSoup(html, "html.parser")
    # 1. 厳密な値はセレクタで取得する
    price_text = soup.select_one(".price").get_text(strip=True)
    price_jpy = int("".join(ch for ch in price_text if ch.isdigit()))
    # 2. 表現が揺れるレビュー文のみをLLMで分類する
    review_text = soup.select_one(".review").get_text(strip=True)
    sentiment = classify_sentiment(review_text)  # LLM呼び出し
    return {"price_jpy": price_jpy, "sentiment": sentiment}

LLMが誤って抽出した値はどのように検出するのか?

誤抽出の検出は、検証層で以下の5点を組み合わせて実施します。

  1. スキーマ検証:Pydanticによる型・必須項目・範囲のチェックです。価格が0以下、あるいは異常に大きい場合は、範囲チェックで除外します。
  2. 原文との突合:抽出した価格の数字が、軽量化後のテキストに含まれているかを確認します。全角数字やカンマの違いは、NFKC正規化によって吸収します。
  3. 差分チェックによるサンプル監視:一定割合のページをサンプルとして抜き出し、セレクタ抽出とLLM抽出の結果を比較します。乖離率が基準値を超えた場合に、プロンプトやスキーマを見直します。
  4. 決定論的な設定:temperatureを0に設定して出力の揺らぎを抑え、再試行しても検証を通過しないページは失敗として記録します。
  5. 構造変更時のアラート:検証失敗率・必須項目の欠落率・原文突合の失敗率を日次で集計し、急上昇した場合に通知します。サイト構造の変更は、これらの指標の変化として最初に現れます。

セレクタ抽出が壊れた場合は即座にエラーとなりますが、LLM抽出は誤った値を返却しても処理が継続する点に注意が必要です。そのため、検証層の指標を継続的に監視することが重要です。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?