---
title: "پاک‌سازی داده‌های اسکرپینگ با pandas در Python"
description: "پاک‌سازی داده‌های اسکرپینگ با pandas: اول متن، بعد نوع ستون‌ها، ردیف‌های تکراری، مقادیر گمشده و داده‌های پرت، و اعتبارسنجی پیش از ذخیره؛ با اسکریپت آزموده."
url: https://proxynet.io/fa/blog/clean-scraped-data-with-pandas
date: 2026-09-28
author: "Acar Diveroli"
category: "آموزش‌ها, وب اسکرپینگ"
lang: fa
---

# پاک‌سازی داده‌های اسکرپینگ با pandas در Python

اسکرپر شما شب کارش را تمام کرده و فایل CSV در نگاه اول درست به نظر می‌رسد. بعد می‌خواهید میانگین قیمت‌ها را بگیرید و pandas نمی‌پذیرد، چون هر قیمت رشته‌ای است مثل `Â£51.77`. یک کتاب دو بار آمده است، چون هم در یکی از صفحه‌های کاتالوگ بوده و هم در صفحه یک دسته. یک عنوان به شکل `Shakespeareâ€™s` خوانده می‌شود، هفت کتاب در دسته‌ای به نام "Default" نشسته‌اند و یک ردیف اصلاً توضیح ندارد. هیچ‌کدام از این‌ها باگ اسکرپر نیست. داده خام وب همین شکلی است و پیش از آنکه کسی از آن نمودار بکشد، به یک دور پاک‌سازی نیاز دارد.

این راهنما آن دور پاک‌سازی را با pandas روی یک مجموعه داده واقعی انجام می‌دهد که از [books.toscrape.com](https://books.toscrape.com/) جمع شده است؛ سایتی آزمایشی که برای تمرین وب اسکرپینگ ساخته شده. ترمیم متن و یونیکد، مقادیر گمشده، تبدیل قیمت‌های متنی به عدد، عددهای دیگری که درون متن پنهان‌اند، تاریخ‌ها، برچسب‌های دسته، ردیف‌های تکراری، داده‌های پرت، بررسی‌های اعتبارسنجی و ذخیره نتیجه را پوشش می‌دهیم. اسکریپت کامل در 28 سپتامبر 2026 با pandas 3.0.6، Requests 2.34.2، ⁦beautifulsoup4 4.15.0⁩، pyarrow 25.0.1 و Python 3.13 اجرا شد.

> **نکته: پاسخ کوتاه**
>
> ردیف‌های خام را در یک DataFrame بارگذاری کنید و فایل خام را دست‌نخورده نگه دارید. اول متن را پاک کنید: فاصله‌های اضافه را بردارید، رشته‌های خالی را به مقدار گمشده تبدیل کنید و رمزگذاری خراب را ترمیم کنید. سپس ستون‌ها را با `pd.to_numeric(..., errors="coerce")`، `pd.to_datetime(..., utc=True)`، نوع nullable یعنی `Int64` و `category` به نوع‌های واقعی تبدیل کنید. بعد ردیف‌های تکراری را بر اساس یک کلید پایدار مثل کد محصول حذف کنید و تازه‌ترین نسخه را نگه دارید. داده‌های پرت را علامت بزنید نه اینکه حذفشان کنید، چند بررسی به سبک `assert` اجرا کنید که اگر چیزی اشتباه بود اسکریپت را متوقف کنند، و نتیجه را در CSV و Parquet ذخیره کنید.

## داده اسکرپینگ «تمیز» یعنی چه؟

داده تمیز برای هر چیز واقعی یک ردیف دارد، برای هر ستون یک نوع و برای هر مقدار یک شکل نوشتاری. ستون قیمت عدد نگه می‌دارد، نه متنی با نماد ارز. کتابی که دو بار اسکرپ شده فقط یک بار می‌آید. توضیحی که وجود ندارد به‌عنوان گمشده ثبت می‌شود، نه به شکل رشته‌ای خالی که پر به نظر می‌رسد. دسته‌ای به نام "Default" نامی می‌گیرد که به تحلیلگر بگوید هیچ اطلاعاتی در آن نیست.

تمیز به معنای کامل یا درست در همه جزئیات نیست. توضیحی را که صفحه هرگز نداشته نمی‌توان بازیابی کرد و اسکریپت پاک‌سازی نباید آن را از خودش بسازد. کار آن این است که هر مشکل را آشکار کند و هر مقدار باقی‌مانده را قابل اعتماد، تا گام بعدی (یک نمودار، یک مدل، یک هشدار قیمت) با واقعیت کار کند نه با خطاهای تجزیه.

## چرا داده‌های اسکرپینگ نامرتب‌اند؟

صفحه‌های وب برای آدم‌ها نوشته می‌شوند و اسکرپر همان بخش‌هایی را می‌خواند که برای چشم ساخته شده‌اند. بیشتر این آشفتگی از چند منبع می‌آید:

- **همه چیز متن است.** HTML نوع داده ندارد. `£51.77`، `In stock (22 available)` و `Three` (یک کلاس CSS برای امتیاز ستاره‌ای) همه به شکل رشته می‌رسند.
- **حدس رمزگذاری اشتباه از آب درمی‌آید.** books.toscrape.com هدر `Content-Type: text/html` را بدون charset می‌فرستد. در این حالت Requests از قاعده قدیمی `HTTP/1.1` پیروی می‌کند و صفحه را با ⁦ISO-8859-1⁩ رمزگشایی می‌کند ([مستندات Requests درباره رمزگذاری](https://requests.readthedocs.io/en/latest/user/advanced/#encodings))، و نماد پوند ⁦UTF-8⁩ به `Â£` تبدیل می‌شود. سازوکار آن در [خطاهای رمزگذاری یونیکد در پایتون](/fa/blog/python-unicode-encoding-errors) توضیح داده شده است.
- **یک کالا چند مسیر دارد.** یک محصول در کاتالوگ، در دسته خودش و گاهی در فهرست «جدیدها» یا «حراج» آمده است. اگر بیش از یکی از این‌ها را بپیمایید، آن را دو بار جمع می‌کنید. [صفحه‌بندی](/fa/blog/pagination-web-scraping) که میان اجراها جابه‌جا می‌شود هم همین کار را می‌کند.
- **قالب‌ها با هم فرق دارند.** از هر هزار صفحه یکی بلوکی را که بقیه دارند ندارد و انتخابگر شما `None` برمی‌گرداند.
- **داده خود سایت بی‌نقص نیست.** دسته‌های جای‌نگهدار، فاصله‌های سرگردان و دنباله‌های "...more" بخشی از منبع‌اند، نه اشتباه شما.

## یک دور پاک‌سازی چگونه کار می‌کند؟

ترتیب مهم است، چون هر گام به گام قبلی تکیه دارد:

1. **داده خام را نگه دارید.** دقیقاً همان چیزی را که اسکرپر جمع کرده در `books_raw.csv` ذخیره کنید. پاک‌سازی روی یک نسخه کار می‌کند، پس می‌توانید آن را با قواعد تازه دوباره اجرا کنید بدون آنکه دوباره اسکرپ کنید.
2. **متن را یکدست کنید.** فاصله‌های دو سر را بردارید، رشته‌های خالی را به مقدار گمشده تبدیل کنید، رمزگذاری خراب را ترمیم کنید و نرمال‌سازی یونیکد را اعمال کنید.
3. **مقادیر گمشده را بشمارید.** آن‌ها را در یک گزارش ثبت کنید و ستون نشانگر اضافه کنید؛ با حدس پرشان نکنید.
4. **قیمت‌ها را تبدیل کنید.** نماد ارز را به ستون جداگانه ببرید و باقی را به float تبدیل کنید.
5. **عددهای دیگر را بیرون بکشید.** موجودی انبار از `In stock (22 available)`، امتیاز از روی کلمه‌ها، تعداد نقدها از متن.
6. **تاریخ‌ها را تجزیه کنید** و به مهر زمانی دارای منطقه زمانی تبدیل کنید.
7. **دسته‌ها را یکدست کنید** تا هر مفهوم فقط یک برچسب داشته باشد.
8. **ردیف‌های تکراری را حذف کنید**، بر اساس یک کلید پایدار و پس از درست شدن نوع‌ها.
9. **داده‌های پرت را علامت بزنید**، با قاعده‌ای ساده که بتوانید توضیحش دهید.
10. **اعتبارسنجی و ذخیره کنید.** اگر یک بررسی شکست خورد متوقف شوید؛ وگرنه CSV و Parquet را بنویسید.

حذف ردیف‌های تکراری عمداً در انتها می‌آید. دو ردیف که فقط در یک فاصله انتهایی یا یک آپاستروف بد رمزگشایی‌شده فرق دارند یک کتاب‌اند، و `drop_duplicates` این را فقط پس از گام‌های 2 تا 7 می‌بیند.

## مقدار خام و تمیز کنار هم

این‌ها مقدارهای واقعی از اسکرپ و چیزی است که اسکریپت از آن‌ها می‌سازد:

| ستون | مقدار خام | مشکل | مقدار تمیز | ابزار pandas |
|---|---|---|---|---|
| `price` | `Â£51.77` | متن، رمزگذاری خراب | `51.77` (float64) + `currency` = `£` | `str.extract`، `pd.to_numeric` |
| `availability` | `In stock (22 available)` | عدد درون جمله | `in_stock` = `22` (Int64) | `str.extract` |
| `rating` | `Three` | کلمه به‌جای عدد | `3` (Int64) | `map` |
| `title` | `Shakespeareâ€™s Globe` | موجی‌باکه (mojibake) | `Shakespeare’s Globe` | `encode`/`decode`، `unicodedata` |
| `category` | `Default` | برچسب جای‌نگهدار | `Uncategorized` (category) | `replace`، `astype("category")` |
| `description` | `… and their affair begins. ...more` | تکه‌های صفحه درون متن | `… and their affair begins.` | `str.removesuffix` |
| `scraped_at` | `2026-09-28T14:51:11+00:00` | رشته | `datetime64[us, UTC]` | `pd.to_datetime` |
| `upc` | یک کد در دو ردیف | کتاب تکراری | یک ردیف، تازه‌ترین نسخه | `drop_duplicates` |

موجی‌باکه (mojibake) نام متن درهم‌ریخته‌ای است که وقتی بایت‌ها با رمزگذاری اشتباه رمزگشایی شوند پدید می‌آید، مثل `â€™` به‌جای `’`.

## در pandas 3.0 چه چیزی تغییر کرد؟

pandas 3.0.0 در 21 ژانویه 2026 منتشر شد و نسخه فعلی 3.0.6 است. سه تغییر از [یادداشت‌های انتشار pandas 3.0](https://pandas.pydata.org/docs/whatsnew/v3.0.0.html) مستقیماً بر کد پاک‌سازی اثر می‌گذارند:

- **رشته‌ها نوع مخصوص خود را دارند.** ستون‌های متنی حالا به‌جای `object` به‌صورت `str` شناخته می‌شوند و مقادیر گمشده در آن‌ها مثل دیگر نوع‌های پیش‌فرض `NaN` است. به همین دلیل اسکریپت ستون‌های متنی را با `select_dtypes(include=["object", "string"])` انتخاب می‌کند: هم در pandas 2 کار می‌کند و هم در 3.
- **Copy-on-Write تنها حالت است.** انتساب زنجیره‌ای مثل `df["price"][0] = 10` دیگر `df` را تغییر نمی‌دهد؛ pandas 3.0.6 فقط هشدار `ChainedAssignmentError` چاپ می‌کند. از `df.loc[0, "price"] = 10` استفاده کنید. هشدار قدیمی `SettingWithCopyWarning` حذف شده و فراخوانی‌های احتیاطی `.copy()` که برای ساکت کردن آن نوشته می‌شدند دیگر لازم نیستند.
- **تاریخ‌های تجزیه‌شده به‌طور پیش‌فرض میکروثانیه‌ای‌اند.** `pd.to_datetime` روی رشته‌ها حالا به‌جای نانوثانیه `datetime64[us]` برمی‌گرداند؛ همان چیزی که در خروجی پایین می‌بینید.

pandas 3 همچنین به Python 3.11 یا جدیدتر نیاز دارد. `pd.to_numeric` فقط `errors="raise"` یا `errors="coerce"` را می‌پذیرد ([مرجع to_numeric](https://pandas.pydata.org/docs/reference/api/pandas.to_numeric.html))؛ قطعه‌کدهای قدیمی که `errors="ignore"` می‌دهند شکست می‌خورند.

## اسکریپت کامل: اسکرپ books.toscrape.com و پاک‌سازی آن

کتابخانه‌ها را در یک محیط مجازی نصب کنید. `pyarrow` برای فایل Parquet لازم است:

```bash
python -m venv .venv
.venv/bin/pip install pandas requests beautifulsoup4 pyarrow   # Windows: .venv\Scripts\pip
```

اسکرپر دو صفحه از کاتالوگ و دو صفحه دسته (Poetry و Classics) را می‌خواند، روی هم 78 کتاب، با یک ثانیه مکث میان درخواست‌ها و تلاش مجدد خودکار برای پاسخ‌های 429 و ⁦5xx⁩. تجزیه HTML همان روش [آموزش BeautifulSoup](/fa/blog/beautifulsoup-tutorial) ما را دنبال می‌کند. اجرای دوم `books_raw.csv` را می‌خواند و فقط پاک‌سازی را تکرار می‌کند.

```python
"""Scrape books.toscrape.com, then clean the result with pandas."""
import time
import unicodedata
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urljoin

import pandas as pd
import requests
from bs4 import BeautifulSoup
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

BASE = "https://books.toscrape.com/"
START_URLS = [
    BASE + "catalogue/page-1.html",                             # main catalogue
    BASE + "catalogue/category/books/poetry_23/index.html",     # one category
    BASE + "catalogue/category/books/classics_6/index.html",    # another one
]
MAX_PAGES = 2          # list pages per start URL
DELAY = 1.0            # seconds between requests
RAW_FILE = Path("books_raw.csv")

session = requests.Session()
session.headers["User-Agent"] = "pandas-cleaning-tutorial/1.0 (contact: you@example.com)"
retry = Retry(total=4, backoff_factor=1, status_forcelist=[429, 500, 502, 503, 504])
session.mount("https://", HTTPAdapter(max_retries=retry))
# session.proxies = {"https": "http://user:pass@pr.proxynet.io:8000"}

def get(url):
    time.sleep(DELAY)
    resp = session.get(url, timeout=30)
    resp.raise_for_status()
    return resp.text   # decoded with Requests' guess, on purpose: see step 2

def parse_book(url):
    soup = BeautifulSoup(get(url), "html.parser")
    table = {th.get_text(): td.get_text() for th, td in
             zip(soup.select("table.table th"), soup.select("table.table td"))}
    desc = soup.select_one("#product_description + p")
    return {
        "url": url,
        "title": soup.h1.get_text(),
        "category": soup.select("ul.breadcrumb a")[-1].get_text(),
        "price": table.get("Price (incl. tax)"),
        "availability": table.get("Availability"),
        "rating": soup.select_one("p.star-rating")["class"][1],
        "upc": table.get("UPC"),
        "reviews": table.get("Number of reviews"),
        "description": desc.get_text() if desc else None,
        "scraped_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
    }

def scrape():
    rows = []
    for start in START_URLS:
        url = start
        for _ in range(MAX_PAGES):
            soup = BeautifulSoup(get(url), "html.parser")
            for a in soup.select("article.product_pod h3 a"):
                rows.append(parse_book(urljoin(url, a["href"])))
            nxt = soup.select_one("li.next a")
            if not nxt:
                break
            url = urljoin(url, nxt["href"])
    df = pd.DataFrame(rows)
    df.to_csv(RAW_FILE, index=False, encoding="utf-8")
    return df

def clean(raw):
    df = raw.copy()
    report = {"raw_rows": len(df)}

    # 1. Strip whitespace in every text column; empty strings become missing
    text_cols = df.select_dtypes(include=["object", "string"]).columns
    for col in text_cols:
        df[col] = df[col].str.strip().replace("", pd.NA)

    # 2. Unicode: repair mojibake, then normalise to NFKC
    def fix_text(s):
        if pd.isna(s):
            return s
        try:
            s = s.encode("latin-1").decode("utf-8")   # "Â£" -> "£", "Ã©" -> "é"
        except (UnicodeEncodeError, UnicodeDecodeError):
            pass                                        # already correct
        return unicodedata.normalize("NFKC", s)

    for col in ["title", "category", "description", "price"]:
        df[col] = df[col].map(fix_text)

    # 3. Missing values: count them, flag them, don't invent them
    report["missing_before"] = df.isna().sum()[lambda s: s > 0].to_dict()
    df["description"] = df["description"].str.removesuffix("...more").str.strip()
    df["has_description"] = df["description"].notna()

    # 4. Price strings to numbers
    df["currency"] = df["price"].str.extract(r"([£$€])", expand=False)
    df["price"] = pd.to_numeric(
        df["price"].str.replace(r"[^\d.]", "", regex=True), errors="coerce"
    )

    # 5. Other numbers hidden in text
    df["in_stock"] = pd.to_numeric(
        df["availability"].str.extract(r"(\d+)\s+available", expand=False),
        errors="coerce",
    ).astype("Int64")
    df["rating"] = df["rating"].map(
        {"One": 1, "Two": 2, "Three": 3, "Four": 4, "Five": 5}
    ).astype("Int64")
    df["reviews"] = pd.to_numeric(df["reviews"], errors="coerce").astype("Int64")

    # 6. Dates
    df["scraped_at"] = pd.to_datetime(df["scraped_at"], utc=True, errors="coerce")

    # 7. Categories: one spelling per value, junk labels merged
    df["category"] = (df["category"]
                      .replace({"Default": "Uncategorized",
                                "Add a comment": "Uncategorized"})
                      .astype("category"))

    # 8. Duplicates: same UPC = same book; keep the newest copy
    report["duplicate_rows"] = int(df.duplicated("upc").sum())
    df = (df.sort_values("scraped_at")
            .drop_duplicates("upc", keep="last")
            .reset_index(drop=True))

    # 9. Outliers: flag, don't delete
    q1, q3 = df["price"].quantile([0.25, 0.75])
    iqr = q3 - q1
    df["price_outlier"] = ~df["price"].between(q1 - 1.5 * iqr, q3 + 1.5 * iqr)
    report["price_outliers"] = int(df["price_outlier"].sum())

    df = df.drop(columns=["availability"])
    report["clean_rows"] = len(df)
    return df, report

def validate(df):
    problems = []
    if df["upc"].duplicated().any():
        problems.append("duplicate UPCs")
    if df["price"].isna().any():
        problems.append("prices that did not parse")
    if not df["price"].dropna().between(0, 1000).all():
        problems.append("prices outside 0-1000")
    if not df["rating"].between(1, 5).fillna(False).all():
        problems.append("ratings missing or outside 1-5")
    if df["title"].isna().any():
        problems.append("rows without a title")
    if problems:
        raise ValueError("Validation failed: " + ", ".join(problems))

if __name__ == "__main__":
    raw = pd.read_csv(RAW_FILE) if RAW_FILE.exists() else scrape()
    clean_df, report = clean(raw)
    validate(clean_df)
    clean_df.to_csv("books_clean.csv", index=False, encoding="utf-8-sig")
    clean_df.to_parquet("books_clean.parquet", index=False)
    print(report)
    print(clean_df.dtypes)
    print(clean_df[["title", "category", "price", "rating", "in_stock"]].head())
```

اجرای اول حدود دو دقیقه طول کشید که بیشترش همان مکث مؤدبانه میان درخواست‌ها بود. خط گزارشی که چاپ کرد:

```text
{'raw_rows': 78, 'missing_before': {'description': 1}, 'duplicate_rows': 5, 'price_outliers': 0, 'clean_rows': 73}
```

پنج کتاب Poetry هم در صفحه‌های کاتالوگ بودند و هم در صفحه Poetry، پس 78 ردیف به 73 رسید. یک عنوان از Classics یعنی *Alice in Wonderland* در صفحه خود بلوک توضیح ندارد. هفت کتاب دسته "Default" داشتند. قیمت‌ها از 12.84 تا 58.63 بودند، پس قاعده داده پرت چیزی را علامت نزد؛ که برای این سایت پاسخ درستی است. خروجی `dtypes` برای متن `str`، برای قیمت `float64`، برای شمارش‌ها `Int64`، برای دسته `category` و برای مهر زمانی `datetime64[us, UTC]` را نشان می‌دهد.

## هر گام چه می‌کند و چرا

### متن و یونیکد

`str.strip()` فاصله‌ها و شکست‌های خطی را که چیدمان HTML دور مقدارها جا می‌گذارد برمی‌دارد. تبدیل `""` به مقدار گمشده مهم‌تر از آن است که به نظر می‌رسد: `isna()` رشته‌های خالی را نمی‌شمارد، پس بدون این کار یک فیلد خالی پر حساب می‌شود.

ترمیم رمزگذاری حدس اشتباه را برمی‌گرداند. `s.encode("latin-1").decode("utf-8")` نویسه‌هایی را که Requests ساخته به بایت‌های اصلی برمی‌گرداند و آن بایت‌ها را با ⁦UTF-8⁩ رمزگشایی می‌کند. رشته‌ای که از قبل درست بوده یا بدون تغییر از این مرحله می‌گذرد (ASCII ساده) یا در یکی از این دو فراخوانی خطا می‌دهد و دست‌نخورده می‌ماند. راه‌حل تمیزتر در مبدأ است: `response.content` را به BeautifulSoup بدهید یا پیش از خواندن `response.text` مقدار `response.encoding = "utf-8"` را تنظیم کنید. با این حال، دانستن این ترمیم برای فایل‌هایی که قبلاً ذخیره کرده‌اید ارزشمند است. پس از آن، `unicodedata.normalize("NFKC", s)` نویسه‌های سازگاری مثل لیگاتورها و فاصله‌های نشکن را به شکل ساده‌شان برمی‌گرداند ([مستندات unicodedata](https://docs.python.org/3/library/unicodedata.html))، تا دو شکل نوشتاری یک عنوان برابر شمرده شوند.

### مقادیر گمشده

اسکریپت مقادیر گمشده هر ستون را می‌شمارد و ستون نشانگر `has_description` را اضافه می‌کند. در جای خالی «بدون توضیح» نمی‌نویسد، چون این رشته بعدها شبیه متن واقعی به نظر می‌رسد. برای عددها، پر کردن جاهای خالی با میانگین یا میانه کار مرحله تحلیل است، جایی که تحلیلگر می‌تواند آن را روی نمودار بگوید، نه کار مرحله پاک‌سازی.

### عددها، امتیازها و تاریخ‌ها

`pd.to_numeric(..., errors="coerce")` هر چیزی را که نتواند تجزیه کند به‌جای توقف به `NaN` تبدیل می‌کند. برای یک دور پاک‌سازی این پیش‌فرض درستی است، به شرطی که یک بررسی اعتبارسنجی پس از آن این `NaN`ها را بشمارد. نوع nullable یعنی `Int64` (با I بزرگ) عددهای صحیح را حتی وقتی مقداری گم است صحیح نگه می‌دارد؛ `int64` معمولی نمی‌تواند `NaN` داشته باشد و بی‌صدا به float تبدیل می‌شود. تاریخ‌ها با `utc=True` تجزیه می‌شوند تا مهرهای زمانی اجراها و ماشین‌های مختلف درست با هم مقایسه شوند.

### دسته‌ها و ردیف‌های تکراری

`astype("category")` هر برچسب را یک بار ذخیره می‌کند و برای هر ردیف یک کد کوچک نگه می‌دارد. جدا از صرفه‌جویی در حافظه، فهرست برچسب‌ها را صریح می‌کند: `df["category"].cat.categories` با یک نگاه نشان می‌دهد که آیا "Poetry" و "poetry " هر دو باقی مانده‌اند یا نه.

برای ردیف‌های تکراری، کلیدی انتخاب کنید که خود چیز را شناسایی کند، نه ردیف را. اینجا آن کلید UPC است که در صفحه هر کتاب چاپ شده. در این سایت URL هم کار می‌کند، اما در فروشگاه‌های واقعی یک محصول اغلب چند URL دارد (پارامترهای ردیابی، مسیرهای دسته). مرتب کردن بر اساس `scraped_at` پیش از `drop_duplicates(keep="last")` تازه‌ترین نسخه را نگه می‌دارد؛ همان چیزی که وقتی قیمت‌ها میان اجراها تغییر می‌کنند می‌خواهید.

### داده‌های پرت و اعتبارسنجی

قاعده دامنه میان‌چارکی (IQR) مقدارهایی را علامت می‌زند که بیش از 1.5 برابر IQR پایین‌تر از چارک اول یا بالاتر از چارک سوم باشند. توضیح آن ساده است و توزیع نرمال را فرض نمی‌گیرد. اسکریپت به‌جای حذف ردیف‌ها ستون `price_outlier` را اضافه می‌کند: قیمت 0.99 در یک فروشگاه واقعی ممکن است یک حراج واقعی باشد یا خطای تجزیه‌ای که "0.99" را از "10.99" خوانده است. پیش از حذف هر چیزی، یک نفر باید آن را ببیند.

`validate()` آخرین خط دفاع است. وقتی یک قیمت خام را به `N/A` و یک امتیاز را به `Six` تغییر دادیم، اجرا به‌جای نوشتن یک فایل خراب با پیام `Validation failed: prices that did not parse, ratings missing or outside 1-5` متوقف شد. این بررسی‌ها را به قواعد واقعی داده خود نزدیک نگه دارید؛ بررسی‌ای که هرگز شکست نمی‌خورد از چیزی محافظت نمی‌کند.

## داده اسکرپینگ تمیز کجا به کار می‌رود

- **پایش قیمت:** قیمت‌های عددی همراه با مهر زمانی همان چیزی است که تاریخچه قیمت و هشدار قیمت لازم دارند. [پایش قیمت](/fa/price-monitoring) و [رصد قیمت رقبا](/fa/blog/competitor-price-tracking) را ببینید.
- **تحقیقات بازار:** سهم دسته‌ها، توزیع امتیازها و سطح موجودی در کل یک کاتالوگ. [تحقیقات بازار](/fa/market-research) را ببینید.
- **داده‌کاوی و مدل‌سازی:** مدل‌ها به ورودی‌های نوع‌دار و بدون تکرار نیاز دارند. زمینه را در [داده‌کاوی](/fa/blog/what-is-data-mining) بخوانید.
- **پایپ‌لاین‌ها:** این اسکریپت گام «تبدیل» یک کار ETL کوچک است؛ الگوی کامل در [ETL چیست؟](/fa/blog/what-is-etl) آمده است.
- **ذخیره‌سازی:** وقتی ستون‌ها نوع‌دار شدند، انتخاب میان CSV، JSON و SQLite در [ذخیره داده‌های اسکرپینگ در CSV، JSON و SQLite](/fa/blog/save-scraped-data-csv-json-sqlite) بررسی شده است.
- **جمع‌آوری در مقیاس بزرگ:** پروژه‌های اسکرپینگ بزرگ‌تر و زیرساخت پیرامون آن‌ها در صفحه [جمع‌آوری داده](/fa/data-scraping) ما آمده است.

## اشتباهات رایج

- **پاک‌سازی تنها نسخه.** بازنویسی فایل خام یعنی هر تغییر قاعده به یک اسکرپ تازه نیاز دارد.
- **حذف ردیف‌های تکراری در ابتدا.** `drop_duplicates` مقدارهای دقیق را مقایسه می‌کند، پس اگر آن را پیش از برداشتن فاصله‌ها اجرا کنید `"Olio"` و `"Olio "` هر دو باقی می‌مانند.
- **`astype(float)` روی قیمت‌های متنی.** روی اولین `£` شکست می‌خورد. نویسه‌های غیرعددی را بردارید، سپس `pd.to_numeric` را با `errors="coerce"` به کار ببرید و `NaN`های حاصل را بشمارید.
- **پر کردن جاهای خالی با صفر.** موجودی گمشده به معنای موجودی صفر نیست. از `Int64` استفاده کنید و مقدار را گمشده بگذارید.
- **انتساب زنجیره‌ای.** در pandas 3، `df[df.price > 50]["flag"] = True` چیزی را تغییر نمی‌دهد و فقط هشدار می‌دهد. از `df.loc[df.price > 50, "flag"] = True` استفاده کنید.
- **ذخیره برای Excel بدون BOM.** Excel در Windows یک CSV ساده ⁦UTF-8⁩ را با یک صفحه‌کد قدیمی می‌خواند و `£` دوباره خراب می‌شود؛ `encoding="utf-8-sig"` جلوی این را می‌گیرد.
- **اسکرپ بیشتر به‌جای پاک‌سازی بهتر.** اگر داده به این دلیل گم شده که درخواست‌ها شکست خورده‌اند، جمع‌آوری را درست کنید: به [robots.txt](/fa/blog/robots-txt) احترام بگذارید، در برابر [⁦HTTP 429⁩](/fa/blog/http-429-too-many-requests) آهسته‌تر پیش بروید و هر جا API رسمی هست آن را ترجیح دهید.

## راهنمای انتخاب

| نیاز | توصیه |
|---|---|
| عددهایی که به شکل متن ذخیره شده‌اند | نمادها را بردارید، `pd.to_numeric(errors="coerce")`، سپس `NaN`ها را بشمارید |
| عدد صحیح با جای خالی | `Int64` از نوع nullable، نه `int64` یا float |
| متن دارای `Â£`، `â€™` یا `Ã©` | رمزگشایی را در مبدأ درست کنید؛ فایل‌های ذخیره‌شده را با رفت‌وبرگشت `latin-1` ترمیم کنید |
| یک کالا از چند صفحه | `drop_duplicates` روی یک شناسه پایدار، با نگه‌داشتن تازه‌ترین نسخه |
| چند قیمت عجیب | با قاعده IQR علامت بزنید و بازبینی کنید؛ خودکار حذف نکنید |
| برچسب‌های تکرارشونده مثل دسته‌ها | `astype("category")` و یک نگاشت صریح برای برچسب‌های جای‌نگهدار |
| مهرهای زمانی از اجراهای مختلف | `pd.to_datetime(..., utc=True)` |
| حفظ نوع‌ها میان گام‌ها | Parquet را کنار CSV ذخیره کنید |
| اسکرپ‌های بزرگ یا تکرارشونده | محدودیت نرخ، تلاش مجدد و هر جا حجم کار لازم داشت [پروکسی چرخشی](https://proxynet.io/fa/rotating-proxy) از طریق `pr.proxynet.io:8000` |

## پرسش‌های متداول

### داده را هنگام اسکرپ پاک کنم یا بعد از آن؟

هنگام اسکرپ حداقل کار را انجام دهید (متن را بگیرید و فاصله‌های اضافه‌اش را بردارید) و باقی را بعداً در pandas. نگه‌داشتن فایل خام به شما امکان می‌دهد قواعد پاک‌سازی را تغییر دهید و در چند ثانیه دوباره اجرا کنید، بی‌آنکه حتی یک درخواست تازه به سایت بفرستید.

### چرا ستون قیمت من به‌جای `£` نماد `Â£` را نشان می‌دهد؟

سرور charset را اعلام نکرده بود، پس Requests بایت‌های ⁦UTF-8⁩ را با ⁦ISO-8859-1⁩ رمزگشایی کرد. `response.content` را به تجزیه‌گر خود بدهید یا `response.encoding = "utf-8"` را تنظیم کنید. برای داده‌ای که قبلاً ذخیره شده، `s.encode("latin-1").decode("utf-8")` این اشتباه را برمی‌گرداند.

### آیا `dropna()` راه درست برخورد با مقادیر گمشده است؟

فقط وقتی ردیفِ بدون آن فیلد برای پرسش شما بی‌فایده است. `dropna()` بدون آرگومان هر ردیفی را که در هر ستونی مقدار گمشده داشته باشد حذف می‌کند، و این می‌تواند بیشتر یک مجموعه داده اسکرپ‌شده را به خاطر یک فیلد اختیاری از بین ببرد. برای ستون‌هایی که واقعاً لازم دارید از `dropna(subset=[...])` استفاده کنید و بقیه را علامت بزنید.

### ستون مناسب برای حذف ردیف‌های تکراری را چگونه انتخاب کنم؟

از شناسه‌ای استفاده کنید که روی صفحه چاپ شده است، مثل UPC، SKU، ISBN یا شناسه محصول. URL فقط در سایت‌هایی کار می‌کند که هر کالا دقیقاً یک URL دارد. اگر شناسه‌ای نیست، چند فیلد یکدست‌شده را با هم ترکیب کنید، مثلاً عنوان و برند.

### آیا این کد با pandas 2 کار می‌کند؟

ما آن را فقط با pandas 3.0.6 آزمودیم، اما هر فراخوانی که به کار می‌برد در ⁦pandas 2.x⁩ هم وجود دارد و انتخاب ستون‌های متنی هم نوع قدیمی `object` و هم نوع جدید `str` را پوشش می‌دهد. انتظار تفاوت‌های کوچکی در خروجی چاپ‌شده را داشته باشید، مثل `object` به‌جای `str` و مهرهای زمانی نانوثانیه‌ای به‌جای میکروثانیه‌ای. کدی که به انتساب زنجیره‌ای تکیه دارد اصلی‌ترین بخشی است که میان این دو نسخه رفتار متفاوتی دارد.

### آیا برای پاک‌سازی داده به پروکسی نیاز دارم؟

نه. پاک‌سازی روی دستگاه شما اجرا می‌شود و هیچ ترافیکی نمی‌فرستد. پروکسی فقط برای جمع‌آوری اهمیت دارد، آن هم فقط در حجمی که یک نشانی IP از محدودیت نرخ یک سایت فراتر برود. کد پاک‌سازی هر شبکه‌ای که صفحه‌ها از آن آمده باشند یکسان می‌ماند.

## خلاصه

داده اسکرپینگ به شکل متنی می‌رسد که برای خواننده انسانی قالب‌بندی شده است. یک دور پاک‌سازی قابل اعتماد فایل خام را نگه می‌دارد، اول متن و رمزگذاری را درست می‌کند، هر ستون را به نوع واقعی تبدیل می‌کند، ردیف‌های تکراری را بر اساس یک کلید پایدار حذف می‌کند، مقدارهای عجیب را به‌جای حذف علامت می‌زند و اگر بررسی‌ای شکست بخورد پیش از ذخیره متوقف می‌شود. اسکریپت بالا همه این کارها را روی 78 ردیف واقعی، پس از دانلود صفحه‌ها، در کمتر از یک ثانیه انجام می‌دهد. وقتی جمع‌آوری شما از یک سایت تمرینی فراتر رفت، پلن‌های صفحه [پروکسی](/fa/proxy) ما بخش شبکه را پوشش می‌دهند.
