---
title: "ذخیره داده‌های اسکرپینگ در CSV، JSON و SQLite"
description: "داده‌های اسکرپ‌شده را برای صفحه‌گسترده در CSV، برای لاگ‌های رو به رشد در JSON Lines و برای اجراهای بدون تکرار در SQLite ذخیره کنید. همراه با کد آزموده Python."
url: https://proxynet.io/fa/blog/save-scraped-data-csv-json-sqlite
date: 2026-09-28
author: "Acar Diveroli"
category: "آموزش‌ها, وب اسکرپینگ"
lang: fa
---

# ذخیره داده‌های اسکرپینگ در CSV، JSON و SQLite

اسکرپر شما ده صفحه را پیمایش می‌کند و صد رکورد تمیز چاپ می‌کند. صبح روز بعد دوباره اجرایش می‌کنید و فایل CSV دویست ردیف دارد که نیمی از آن‌ها تکراری است. همکارتان فایل را در Excel باز می‌کند و هر گیومه خمیده به شکل `â€œ` نمایش داده می‌شود. یک هفته بعد، کار در میانه نوشتن از کار می‌افتد و یک فایل JSON باقی می‌گذارد که هیچ تجزیه‌گری نمی‌تواند بازش کند. هیچ‌کدام از این‌ها مشکل اسکرپینگ نیست؛ همه را چند خط ذخیره‌سازی در انتهای اسکریپت تعیین می‌کند.

این راهنما سه قالبی را پوشش می‌دهد که بیشتر اسکرپرهای Python در آن‌ها می‌نویسند: CSV با ماژول `csv` و pandas، JSON و JSON Lines، و SQLite با یک upsert که رکوردهای تکراری را میان اجراها حذف می‌کند. خواهید دید چگونه داده را با خیال راحت به انتهای فایل اضافه کنید، Excel چه کدگذاری‌ای انتظار دارد، حجم هر قالب چقدر است و چه زمانی باید از SQLite به PostgreSQL بروید. همه نمونه‌ها در 28 سپتامبر 2026 روی [quotes.toscrape.com](https://quotes.toscrape.com/)، سایتی آزمایشی که برای تمرین اسکرپینگ ساخته شده، با ⁦Python 3.13⁩، ⁦Requests 2.34⁩، ⁦beautifulsoup4 4.15⁩، ⁦pandas 3.0⁩ و ⁦SQLite 3.50⁩ اجرا شدند. بخش تجزیه در [آموزش BeautifulSoup](/fa/blog/beautifulsoup-tutorial) آمده است؛ این‌جا از لحظه‌ای شروع می‌کنیم که ردیف‌ها آماده‌اند.

> **نکته: پاسخ کوتاه**
>
> وقتی قرار است دیگران داده را در صفحه‌گسترده باز کنند، CSV بنویسید: از `csv.DictWriter` استفاده کنید، فایل را با `newline=""` باز کنید و `encoding="utf-8-sig"` را به کار ببرید تا Excel نویسه‌ها را درست بخواند. وقتی رکوردها تودرتو هستند یا در هر اجرا داده اضافه می‌کنید، JSON Lines بنویسید (در هر خط یک شیء JSON). وقتی اسکرپر بارها اجرا می‌شود، SQLite بنویسید: به هر رکورد یک کلید پایدار بدهید، آن را کلید اصلی تعریف کنید و با `INSERT ... ON CONFLICT DO UPDATE` درج کنید تا اجرای دوم ردیف‌ها را به‌روز کند و تکرارشان نکند. وقتی چند ماشین هم‌زمان می‌نویسند یا پایگاه داده باید روی سرور باشد، به PostgreSQL بروید.

## برای ذخیره داده‌های اسکرپینگ چه گزینه‌هایی وجود دارد؟

دو خانواده وجود دارد. **فایل‌های تخت** (CSV، JSON، JSON Lines، XLSX) یک فایل واحدند که از ابتدا تا انتها نوشته می‌شوند. به سرور نیاز ندارند و در ابزارهای آشنا باز می‌شوند، اما هیچ تصوری از رکورد تکراری ندارند. **پایگاه‌های داده** (SQLite، PostgreSQL، MySQL) ردیف‌ها را در جدول‌هایی با کلید و قید نگه می‌دارند: تکراری‌ها را خودشان ادغام می‌کنند، با SQL به پرسش‌ها پاسخ می‌دهند و از خرابی در میانه نوشتن جان سالم به در می‌برند.

SQLite میان این دو قرار دارد. پایگاه داده است، اما کل پایگاه داده یک فایل معمولی روی دیسک است و Python ماژول `sqlite3` را در کتابخانه استاندارد خود دارد. برای اسکرپری که روی یک ماشین اجرا می‌شود، به‌سختی می‌توان گزینه‌ای مناسب‌تر از این ترکیب یافت.

## اسکرپر ردیف‌هایش را چگونه ذخیره می‌کند؟

قالب هر چه باشد، یک اسکرپر درست‌رفتار از همین پنج گام می‌گذرد:

1. **هر رکورد را به یک dict یکدست تبدیل کنید** که همیشه همان کلیدها را داشته باشد: `quote_id`، `text`، `author`، `tags` و یک مهر زمانی.
2. **به هر رکورد یک کلید پایدار بدهید.** اگر خود سایت شناسه دارد از آن استفاده کنید (SKU محصول، شناسه مقاله در URL). quotes.toscrape.com شناسه‌ای ندارد، پس از نام نویسنده و متن نقل‌قول یک هش می‌گیریم و کلیدی 16 نویسه‌ای می‌سازیم.
3. **حالت نوشتن را انتخاب کنید.** بازنویسی فایل (یک عکس فوری تازه)، افزودن به انتهای فایل (لاگی رو به رشد) یا upsert در یک جدول (یک ردیف برای هر کلید که در جای خود به‌روز می‌شود).
4. **در یک گام بنویسید.** یا کل دسته ثبت می‌شود یا هیچ‌چیز: یک تراکنش پایگاه داده، یا یک فایل موقت که پس از کامل‌شدن جای فایل قدیمی را می‌گیرد.
5. **دوباره بخوانید.** پیش از اعتماد به فایل، آن را با همان خواننده‌ای باز کنید که مصرف‌کننده به کار می‌برد (Excel، pandas، `json.loads`).

گام 2 همانی است که بیشتر اسکریپت‌ها از آن می‌گذرند، و دلیل پیدا شدن رکوردهای تکراری هم همین است.

## مقایسه CSV، JSON Lines و SQLite

| | CSV | JSON (یک آرایه) | JSON Lines | SQLite |
|---|---|---|---|---|
| فیلدهای تودرتو (فهرست برچسب‌ها) | باید تخت شوند، مثلاً `love\|life` | بومی | بومی | متن JSON در یک ستون، پرس‌وجو با `json_each` |
| افزودن در هر اجرا | بله، سرستون فقط یک بار | خیر، `]` پایانی باید بازنویسی شود | بله، یک خط برای هر رکورد | بله، با upsert |
| حذف ردیف‌های تکراری | خیر | خیر | خیر | بله، با کلید اصلی |
| باز شدن در Excel | بله، با BOM در `UTF-8` | خیر | خیر | خیر (اول خروجی بگیرید) |
| تاب‌آوری در برابر خرابی میانه نوشتن | ممکن است خط آخر ناقص بماند | ممکن است فایل خوانا نباشد | فقط خط آخر از دست می‌رود | بله، تراکنش‌ها برگردانده می‌شوند |
| پرس‌وجو بدون بارگذاری همه داده | خیر | خیر | خط به خط | بله، با SQL و ایندکس |
| حجم برای 100 نقل‌قول ما | ⁦25.9 KB⁩ | ⁦42.4 KB⁩ (با تورفتگی) | ⁦35.8 KB⁩ | ⁦49.2 KB⁩ (با یک ایندکس) |

این حجم‌ها از آزمایش خود ما به دست آمده‌اند. SQLite این‌جا بزرگ‌ترین است، چون داده را در صفحه‌های ثابت ⁦4 KB⁩ ذخیره می‌کند (12 صفحه برای این جدول و ایندکس آن)؛ این سربار با بزرگ‌تر شدن جدول کم می‌شود. برای مقایسه، همین داده در قالب XLSX حجمی برابر ⁦17.0 KB⁩ داشت، چون XLSX قالبی فشرده است.

## چگونه داده‌های اسکرپینگ را با Python در CSV ذخیره کنیم؟

ماژول `csv` کتابخانه استاندارد نقل‌قول‌گذاری، ویرگول‌های درون فیلد و شکست خط درون مقدارهای نقل‌قول‌دار را مدیریت می‌کند. `DictWriter` هر dict را بر اساس نام ستون به یک ردیف تبدیل می‌کند:

```python
import csv
from pathlib import Path

FIELDS = ["quote_id", "text", "author", "author_url", "tags", "page", "scraped_at"]

def append_csv(rows, path: Path) -> None:
    new_file = not path.exists() or path.stat().st_size == 0
    with path.open("a", newline="", encoding="utf-8-sig" if new_file else "utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=FIELDS)
        if new_file:
            writer.writeheader()
        for row in rows:
            writer.writerow({**row, "tags": "|".join(row["tags"])})
```

سه جزئیات در این تابع جلوی گلایه‌های همیشگی از CSV را می‌گیرد:

- **`newline=""`.** [مستندات csv در Python](https://docs.python.org/3/library/csv.html) آن را هم برای خواننده‌ها و هم برای نویسنده‌ها لازم می‌داند. بدون آن، شکست خط درون فیلدهای نقل‌قول‌دار اشتباه خوانده می‌شود و در Windows هر ردیف با یک `\r` اضافه تمام می‌شود که بسیاری از خواننده‌ها آن را به یک ردیف خالی پس از هر رکورد تبدیل می‌کنند.
- **`utf-8-sig` فقط در نخستین نوشتن.** این کدک یک نشانه ترتیب بایت یا BOM (`EF BB BF`) در ابتدای فایل می‌نویسد؛ [مستندات codecs](https://docs.python.org/3/library/codecs.html) آن را گونه‌ای از `UTF-8` معرفی می‌کند که Microsoft به کار می‌برد. Excel این نشانه را می‌خواند و فایل را به‌صورت `UTF-8` رمزگشایی می‌کند، پس `\u201c` همان `\u201c` می‌ماند. وقتی اسکریپت بعدها داده اضافه می‌کند، به `utf-8` ساده برمی‌گردد؛ وگرنه نشانه دوم وسط فایل می‌نشست. فایل آزمایشی ما پس از دو اجرا دقیقاً یک نشانه داشت.
- **سرستون فقط وقتی فایل تازه است.** اگر در هر اجرا سرستون اضافه شود، ردیف‌های سرگردان `quote_id,text,...` در میان داده‌ها باقی می‌ماند.

برچسب‌ها با `|` به هم وصل می‌شوند، جداکننده‌ای که هرگز در مقدارها نمی‌آید.

وقتی فایلی را می‌خوانید که BOM دارد، باز هم از `encoding="utf-8-sig"` استفاده کنید. با `utf-8` ساده، نام نخستین ستون به شکل `'\ufeffquote_id'` برمی‌گردد و `row["quote_id"]` خطای `KeyError` می‌دهد. ما در آزمایش به همین برخوردیم؛ دام‌های بیشتر کدگذاری در [خطاهای کدگذاری Unicode در Python](/fa/blog/python-unicode-encoding-errors) آمده است.

با pandas همین فایل در یک خط نوشته می‌شود، و `drop_duplicates` فایل CSV یا JSON Lines دارای رکورد تکراری را اصلاح می‌کند:

```python
import pandas as pd

df = pd.read_json("data/quotes.jsonl", lines=True, convert_dates=False)
df = df.drop_duplicates(subset="quote_id", keep="last")
df.assign(tags=df["tags"].str.join("|")).to_csv(
    "data/quotes_clean.csv", index=False, encoding="utf-8-sig")
df.assign(tags=df["tags"].str.join(", ")).to_excel(
    "data/quotes.xlsx", index=False, sheet_name="quotes")  # needs openpyxl
```

طبق [مشخصات و محدودیت‌های Microsoft](https://support.microsoft.com/en-us/office/excel-specifications-and-limits-1672b34d-7043-467e-8e27-269d656771c3)، هر کاربرگ Excel حداکثر 1,048,576 ردیف دارد. فایل CSV چنین محدودیتی ندارد، اما Excel فقط همین تعداد ردیف از آن را نشان می‌دهد.

## چگونه داده‌های اسکرپینگ را در JSON یا JSON Lines ذخیره کنیم؟

یک آرایه JSON واحد برای عکس فوری‌ای مناسب است که برنامه دیگری یک‌جا بارگذاری‌اش می‌کند. آن را با `ensure_ascii=False` بنویسید تا متن غیرانگلیسی خوانا بماند و به دنباله‌های گریز مثل `\u201c` تبدیل نشود، و آن را به‌صورت اتمی بنویسید: محتوا را در یک فایل موقت در همان پوشه بریزید و سپس با `os.replace` جایگزینش کنید. اگر فرایند در میانه کار از بین برود، فایل قدیمی سالم می‌ماند.

```python
import json, os, tempfile
from pathlib import Path

def write_json_atomic(data, path: Path) -> None:
    fd, tmp = tempfile.mkstemp(dir=path.parent, suffix=".tmp")
    with os.fdopen(fd, "w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False, indent=2)
    os.replace(tmp, path)
```

نقطه ضعف آرایه JSON افزودن داده است. فایل با `]` تمام می‌شود، پس افزودن یک رکورد یعنی خواندن و بازنویسی کل فایل. [JSON Lines](https://jsonlines.org/) از این مشکل پرهیز می‌کند: هر خط یک مقدار کامل JSON است، کدگذاری فایل `UTF-8` است و خط‌ها با `\n` تمام می‌شوند. افزودن یک نوشتن ساده است و خرابی فقط می‌تواند به خط آخر آسیب بزند:

```python
def append_jsonl(rows, path: Path) -> None:
    with path.open("a", encoding="utf-8") as f:
        for row in rows:
            f.write(json.dumps(row, ensure_ascii=False) + "\n")
```

pandas این قالب را با `pd.read_json(path, lines=True)` می‌خواند و `chunksize=` امکان می‌دهد فایل بزرگ را تکه‌تکه پردازش کنید. یک غافلگیری در آزمایش ما: [مستندات read_json](https://pandas.pydata.org/docs/reference/api/pandas.read_json.html) می‌گوید ستون‌هایی که نامشان به `_at` یا `_time` ختم می‌شود به‌طور پیش‌فرض تاریخ تفسیر می‌شوند. ستون `scraped_at` ما به یک datetime با منطقه زمانی تبدیل شد و `to_excel()` با پیام `Excel does not support datetimes with timezones` (یعنی Excel از تاریخ‌های دارای منطقه زمانی پشتیبانی نمی‌کند) متوقف شد. اگر می‌خواهید متن همان‌طور بماند، `convert_dates=False` را بدهید.

اگر فایلی با خطای `JSONDecodeError` بارگذاری نمی‌شود، علت‌های رایج آن (نوشتن ناقص، دو آرایه در یک فایل، خواندن JSON Lines به‌عنوان یک سند واحد) در [JSONDecodeError: Expecting value](/fa/blog/jsondecodeerror-expecting-value) آمده است.

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

کلید پایدار را کلید اصلی تعریف کنید و تصمیم میان درج و به‌روزرسانی را به پایگاه داده بسپارید. SQLite از نسخه ⁦3.24.0⁩ این کار را با عبارت upsert پشتیبانی می‌کند (درجی که اگر کلید از قبل وجود داشته باشد، به به‌روزرسانی تبدیل می‌شود)؛ در بخش `DO UPDATE`، پیشوند ویژه `excluded.` به مقدارهایی اشاره دارد که درج ردشده می‌خواست بنویسد ([مستندات UPSERT در SQLite](https://sqlite.org/lang_upsert.html)).

```sql
CREATE TABLE IF NOT EXISTS quotes (
    quote_id   TEXT PRIMARY KEY,
    text       TEXT NOT NULL,
    author     TEXT NOT NULL,
    author_url TEXT,
    tags       TEXT,              -- JSON array
    first_seen TEXT NOT NULL,
    last_seen  TEXT NOT NULL
);

INSERT INTO quotes (quote_id, text, author, author_url, tags, first_seen, last_seen)
VALUES (:quote_id, :text, :author, :author_url, :tags, :scraped_at, :scraped_at)
ON CONFLICT(quote_id) DO UPDATE SET
    tags       = excluded.tags,
    author_url = excluded.author_url,
    last_seen  = excluded.last_seen;
```

`first_seen` فقط وقتی نوشته می‌شود که ردیف تازه باشد؛ `last_seen` در هر اجرا جلو می‌رود. این جفت بی‌هزینه یک تاریخچه افزایشی به شما می‌دهد: ردیفی که `last_seen` آن از آخرین اجرا قدیمی‌تر است از سایت حذف شده، و ردیفی که `first_seen` آن با آخرین اجرا برابر است تازه است. ردیاب‌های قیمت و ابزارهای پایش تغییر دقیقاً بر همین الگو ساخته می‌شوند.

دو اجرای اسکریپت کامل پایین این خروجی را چاپ کرد:

```text
scraped 100 rows: 100 new, 0 already in the database
scraped 100 rows: 0 new, 100 already in the database
```

فایل‌های CSV و JSON Lines همین دو اجرا هر کدام 200 رکورد داشتند. پایگاه داده 100 رکورد داشت.

وقتی داده در SQLite باشد، پرسش‌ها به پرس‌وجو تبدیل می‌شوند. این پرس‌وجوها روی پایگاه داده آزمایشی ما اجرا شدند:

```python
import sqlite3

con = sqlite3.connect("data/quotes.db")

# Top authors
for author, n in con.execute(
        "SELECT author, COUNT(*) AS n FROM quotes GROUP BY author ORDER BY n DESC LIMIT 3"):
    print(author, n)

# Quotes tagged "love" (tags are stored as a JSON array)
print(con.execute("""
    SELECT COUNT(*) FROM quotes, json_each(quotes.tags)
    WHERE json_each.value = 'love'""").fetchone()[0])

# Rows that were not on the site during the latest run
print(con.execute("""
    SELECT COUNT(*) FROM quotes
    WHERE last_seen < (SELECT MAX(last_seen) FROM quotes)""").fetchone()[0])
con.close()
```

```text
Albert Einstein 10
J.K. Rowling 9
Marilyn Monroe 7
14
0
```

برای تحلیل، `pd.read_sql_query(sql, con)` نتیجه را به‌صورت DataFrame برمی‌گرداند. گام‌های پاک‌سازی که معمولاً پس از این می‌آیند در [پاک‌سازی داده‌های اسکرپینگ با pandas در Python](/fa/blog/clean-scraped-data-with-pandas) آمده است.

## اسکریپت کامل: اسکرپینگ و نوشتن در CSV، JSON Lines و SQLite

اسکریپت پیوند "Next" سایت را تا وقتی ناپدید شود دنبال می‌کند، در پاسخ‌های 429 و `5xx` با فاصله‌گذاری نمایی دوباره تلاش می‌کند (`Retry` در urllib3 سرآیند `Retry-After` را هم رعایت می‌کند)، میان صفحه‌ها یک ثانیه صبر می‌کند و برای هر اجرا فقط یک مهر زمانی می‌نویسد تا مقایسه‌های `last_seen` درست کار کنند. quotes.toscrape.com ده صفحه و 100 نقل‌قول دارد.

```bash
pip install requests beautifulsoup4 lxml pandas openpyxl
```

```python
"""Scrape quotes.toscrape.com and store the rows in CSV, JSON Lines and SQLite."""
import csv
import hashlib
import json
import sqlite3
import time
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urljoin

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

BASE = "https://quotes.toscrape.com/"
FIELDS = ["quote_id", "text", "author", "author_url", "tags", "page", "scraped_at"]

def make_session() -> requests.Session:
    retry = Retry(total=4, backoff_factor=1,
                  status_forcelist=[429, 500, 502, 503, 504],
                  allowed_methods=["GET"])
    session = requests.Session()
    session.mount("https://", HTTPAdapter(max_retries=retry))
    session.headers["User-Agent"] = "quotes-storage-demo/1.0 (contact: you@example.com)"
    return session

def quote_key(text: str, author: str) -> str:
    # The site has no quote IDs, so we derive a stable key from the content.
    return hashlib.sha1(f"{author}\n{text}".encode("utf-8")).hexdigest()[:16]

def scrape(session: requests.Session, delay: float = 1.0):
    url, page = BASE, 1
    run_at = datetime.now(timezone.utc).isoformat(timespec="seconds")  # one timestamp per run
    while url:
        resp = session.get(url, timeout=20)
        resp.raise_for_status()
        soup = BeautifulSoup(resp.content, "lxml")
        for q in soup.select("div.quote"):
            text = q.select_one("span.text").get_text(strip=True)
            author = q.select_one("small.author").get_text(strip=True)
            yield {
                "quote_id": quote_key(text, author),
                "text": text,
                "author": author,
                "author_url": urljoin(BASE, q.select_one("span a")["href"]),
                "tags": [t.get_text(strip=True) for t in q.select("a.tag")],
                "page": page,
                "scraped_at": run_at,
            }
        nxt = soup.select_one("li.next a")
        url = urljoin(url, nxt["href"]) if nxt else None
        page += 1
        time.sleep(delay)

def append_csv(rows, path: Path) -> None:
    new_file = not path.exists() or path.stat().st_size == 0
    with path.open("a", newline="", encoding="utf-8-sig" if new_file else "utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=FIELDS)
        if new_file:
            writer.writeheader()
        for row in rows:
            writer.writerow({**row, "tags": "|".join(row["tags"])})

def append_jsonl(rows, path: Path) -> None:
    with path.open("a", encoding="utf-8") as f:
        for row in rows:
            f.write(json.dumps(row, ensure_ascii=False) + "\n")

SCHEMA = """
CREATE TABLE IF NOT EXISTS quotes (
    quote_id   TEXT PRIMARY KEY,
    text       TEXT NOT NULL,
    author     TEXT NOT NULL,
    author_url TEXT,
    tags       TEXT,              -- JSON array
    first_seen TEXT NOT NULL,
    last_seen  TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_quotes_author ON quotes(author);
"""

UPSERT = """
INSERT INTO quotes (quote_id, text, author, author_url, tags, first_seen, last_seen)
VALUES (:quote_id, :text, :author, :author_url, :tags, :scraped_at, :scraped_at)
ON CONFLICT(quote_id) DO UPDATE SET
    tags       = excluded.tags,
    author_url = excluded.author_url,
    last_seen  = excluded.last_seen
"""

def save_sqlite(rows, path: Path) -> tuple[int, int]:
    con = sqlite3.connect(path)
    try:
        con.executescript(SCHEMA)
        before = con.execute("SELECT COUNT(*) FROM quotes").fetchone()[0]
        with con:  # one transaction: commit on success, roll back on error
            con.executemany(UPSERT, [{**r, "tags": json.dumps(r["tags"])} for r in rows])
        after = con.execute("SELECT COUNT(*) FROM quotes").fetchone()[0]
        return after - before, len(rows) - (after - before)
    finally:
        con.close()

if __name__ == "__main__":
    out = Path("data")
    out.mkdir(exist_ok=True)
    rows = list(scrape(make_session()))
    append_csv(rows, out / "quotes.csv")
    append_jsonl(rows, out / "quotes.jsonl")
    new, seen = save_sqlite(rows, out / "quotes.db")
    print(f"scraped {len(rows)} rows: {new} new, {seen} already in the database")
```

`with con:` عملیات upsert را در یک تراکنش واحد می‌پیچد: اگر هر ردیفی شکست بخورد، هیچ ردیفی نوشته نمی‌شود. این عبارت اتصال را نمی‌بندد، پس بلوک `finally` این کار را انجام می‌دهد. نگه‌دارنده‌های نام‌دار (`:quote_id`) باعث می‌شوند `executemany` همان dictهایی را بپذیرد که نویسنده CSV به کار می‌برد و نمی‌گذارند آپاستروف‌ها دستور SQL را خراب کنند.

برای یک سایت واقعی، همین ساختار را نگه دارید و سلکتورها، کلید و فاصله انتظار را تغییر دهید. پیش از هر چیز شرایط استفاده سایت و `robots.txt` آن را بررسی کنید ([توضیح robots.txt](/fa/blog/robots-txt)) و اگر API رسمی وجود دارد از آن استفاده کنید. همین فاصله یک‌ثانیه‌ای و همین تنظیمات تلاش دوباره است که کار را از [خطاهای ⁦HTTP 429⁩](/fa/blog/http-429-too-many-requests) دور نگه می‌دارد. وقتی اسکرپر به یک خزنده زمان‌بندی‌شده تبدیل می‌شود که درخواست‌هایش را میان چند IP خروجی پخش می‌کند، [پروکسی چرخشی](https://proxynet.io/fa/rotating-proxy) و [پروکسی مسکونی](https://proxynet.io/fa/residential-proxy) از طریق تنظیم `proxies` به همان `requests.Session` وصل می‌شوند؛ کد ذخیره‌سازی تغییری نمی‌کند.

## چه زمانی باید از SQLite به PostgreSQL رفت؟

[راهنمای خود پروژه SQLite](https://www.sqlite.org/whentouse.html) درباره محدودیت‌ها صریح است: در هر فایل پایگاه داده فقط یک نویسنده در هر لحظه پشتیبانی می‌شود، و وقتی داده و برنامه روی ماشین‌های جداگانه‌اند، SQLite انتخاب درستی نیست. در یک پروژه اسکرپینگ، این به سه نشانه ترجمه می‌شود:

- **چند worker هم‌زمان می‌نویسند.** فرایندهای خزنده روی سه سرور به یک سرور پایگاه داده نیاز دارند؛ چند فرایند روی یک ماشین می‌توانند نوبتی بنویسند.
- **پایگاه داده باید از راه شبکه در دسترس باشد.** داشبورد، API و اسکرپری که روی میزبان‌های جداگانه‌اند باید یک PostgreSQL مشترک داشته باشند، نه یک فایل `.db` روی پوشه اشتراکی شبکه.
- **دیگران به مجوز دسترسی نیاز دارند.** کاربران، نقش‌ها و دسترسی فقط‌خواندنی از قابلیت‌های سرور هستند.

حجم به‌تنهایی به‌ندرت دلیل مهاجرت است: حداکثر مستندشده حدود ⁦281 TB⁩ است، بسیار فراتر از آنچه یک اسکرپر تولید می‌کند. خود مهاجرت کوچک است. PostgreSQL همان نحو `INSERT ... ON CONFLICT (key) DO UPDATE` را با `EXCLUDED` به کار می‌برد، پس upsert بالا تقریباً بدون تغییر منتقل می‌شود. خط لوله‌هایی که به چند مرحله می‌رسند در [ETL چیست؟](/fa/blog/what-is-etl) بررسی شده‌اند.

## کاربردها

- **پایش قیمت:** یک ردیف برای هر محصول در هر روز، با کلید SKU و تاریخ، پشتوانه [پایش قیمت](/fa/price-monitoring) و [ردیابی قیمت رقبا](/fa/blog/competitor-price-tracking) است.
- **تشخیص تغییر:** `first_seen` و `last_seen` اقلام تازه و حذف‌شده را نشان می‌دهند، که هسته [پایش تغییرات وب‌سایت](/fa/blog/website-change-monitoring) است.
- **مجموعه‌داده‌های پژوهشی:** فایل‌های JSON Lines را به‌راحتی می‌توان به تحلیلگران داد و برای [تحقیقات بازار](/fa/market-research) در نوت‌بوک‌ها بارگذاری کرد.
- **خزش‌های بزرگ:** یک [خزنده وب](/fa/web-crawler) که هزاران URL را مرور می‌کند می‌تواند صف نشانی‌های در انتظار و نتایجش را در SQLite نگه دارد، همان‌طور که در [ساخت خزنده وب با Python](/fa/blog/python-web-crawler) آمده است.
- **کارهای اسکرپینگ تکرارشونده:** اجراهای زمان‌بندی‌شده [استخراج داده](/fa/data-scraping) که نباید ردیف‌های دیروز را تکرار کنند.

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

- **نبودن کلید پایدار.** بدون آن، هر اجرا کل مجموعه داده را دوباره اضافه می‌کند. از شناسه سایت استفاده کنید یا از فیلدهایی که رکورد را مشخص می‌کنند هش بگیرید.
- **باز کردن فایل‌های CSV بدون `newline=""`.** نویسه‌های `\r` اضافه در Windows و فیلدهای چندخطی خراب.
- **استفاده از `utf-8` ساده برای فایل CSV مخصوص Excel.** نویسه‌های دارای علامت و گیومه‌های خمیده به دنباله‌هایی مثل `Ã©` تبدیل می‌شوند.
- **نوشتن مستقیم روی فایل خروجی.** یک خرابی نیمی از فایل را باقی می‌گذارد. در یک فایل موقت بنویسید و با `os.replace` جایگزینش کنید.
- **یک commit برای هر ردیف در SQLite.** هر commit با دیسک همگام می‌شود. یک دسته را با `executemany` در یک تراکنش بپیچید.
- **ساختن SQL با f-string.** نقل‌قولی که آپاستروف دارد دستور را خراب می‌کند. از نگه‌دارنده‌ها استفاده کنید.
- **نگه داشتن همه‌چیز در حافظه در یک خزش بزرگ.** برای هر صفحه یا هر دسته بنویسید تا خرابی فقط یک صفحه را از بین ببرد، نه کل اجرا را.

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

| نیاز | پیشنهاد |
|---|---|
| فایلی که همکارتان در Excel باز می‌کند | CSV با `utf-8-sig`، یا XLSX از طریق pandas |
| رکوردهای تودرتو برای برنامه‌ای دیگر | JSON (یک آرایه)، به‌صورت اتمی نوشته‌شده |
| لاگی که فقط رو به رشد است و هر اجرا را ثبت می‌کند | JSON Lines |
| اجراهای تکراری بدون رکورد تکراری | SQLite با کلید اصلی و upsert |
| تاریخچه: هر قلم نخستین و آخرین بار کی دیده شد | SQLite با `first_seen` و `last_seen` |
| چند ماشین که هم‌زمان می‌نویسند | PostgreSQL با همان upsert مبتنی بر `ON CONFLICT` |
| تحلیل موردی | هر یک از گزینه‌های بالا، بارگذاری‌شده در pandas |

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

### کدام قالب برای ذخیره داده‌های اسکرپینگ مناسب‌تر است؟

بستگی دارد چه کسی آن را می‌خواند: CSV برای صفحه‌گسترده، JSON Lines برای رکوردهای تودرتو و لاگ‌های رو به رشد، SQLite برای اسکرپرهای زمان‌بندی‌شده‌ای که نباید ردیف تکراری بسازند. بسیاری از پروژه‌ها SQLite را منبع اصلی داده نگه می‌دارند و برای افراد خروجی CSV می‌گیرند.

### چرا فایل CSV اسکرپ‌شده من در Excel به هم ریخته نمایش داده می‌شود؟

وقتی فایل CSV نشانه ترتیب بایت (BOM) ندارد، Excel در Windows اغلب آن را به‌جای `UTF-8` با صفحه‌کد قدیمی سیستم رمزگشایی می‌کند، و هر نویسه چندبایتی به دو یا سه نویسه نادرست تبدیل می‌شود. فایل را با `encoding="utf-8-sig"` بنویسید، یا آن را از پنجره وارد کردن داده در Excel وارد کنید و `UTF-8` را برگزینید.

### چگونه داده را بدون تکرار سرستون به یک CSV موجود اضافه کنم؟

فایل را در حالت `"a"` باز کنید و سرستون را فقط وقتی بنویسید که فایل تازه یا خالی است، همان‌طور که `append_csv` در بالا انجام می‌دهد. افزودن رکوردهای تکراری را حذف نمی‌کند؛ برای این کار فایل را با pandas بارگذاری کنید و `drop_duplicates` را فراخوانی کنید، یا داده را با یک کلید در SQLite ذخیره کنید.

### تفاوت JSON و JSON Lines چیست؟

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

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

روی یک ماشین، معمولاً بله؛ محدودیت حجم بسیار بالاتر از چیزی است که یک اسکرپر تولید می‌کند. محدودیتی که اهمیت دارد هم‌زمانی است: در هر فایل فقط یک نویسنده در هر لحظه. اگر چند ماشین هم‌زمان بنویسند، به PostgreSQL نیاز دارید.

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

به هر رکورد یک کلید پایدار بدهید، آن را در SQLite کلید اصلی کنید و با `ON CONFLICT(key) DO UPDATE` درج کنید. در فایل‌های تخت، پس از بارگذاری با `drop_duplicates(subset="key")` تکراری‌ها را حذف کنید.

## خلاصه

ذخیره‌سازی تعیین می‌کند که اسکرپر پس از نخستین اجرا همچنان مفید بماند یا نه. CSV با `utf-8-sig` و `newline=""` قالب تحویل برای صفحه‌گسترده است و JSON Lines ساده‌ترین راه امن برای افزودن رکوردهای ساختاریافته. SQLite با یک کلید پایدار و upsert، اجراهای تکراری را به یک جدول واحد و قابل پرس‌وجو با تاریخچه نخستین و آخرین مشاهده تبدیل می‌کند؛ وقتی چند ماشین هم‌زمان می‌نویسند، PostgreSQL جای آن را می‌گیرد. اگر اجراهای شما به جایی برسد که یک نشانی IP دیگر برای جمع‌آوری محترمانه داده کافی نباشد، [پلن‌های پروکسی](/fa/proxy) Proxynet را ببینید.
