למה רוב ה-Scrapers נשברים בשקט
בואו נדבר על סיפור שכל מהנדס scraping מכיר. בניתם scraper מושלם. הוא רץ במשך שלושה חודשים, מביא דאטה נקי, כולם מרוצים. ואז, ביום בהיר אחד, הוא מפסיק לעבוד. אבל אף אחד לא שם לב. הלוגים מלאים ב-exceptions גנריות, הדאטהבייס פשוט לא מתעדכן, וההתראה היחידה מגיעה שבוע אחרי, כשמישהו מהביזנס שואל "למה הגרף הזה שטוח מאז יום שלישי?".
זו ברירת המחדל. רוב ה-scrapers לא מתים בפיצוץ, הם גוועים בשקט. הבעיה היא לא שהם נכשלים – כישלון הוא חלק מובנה ב-scraping – אלא איך הם נכשלים. טיפול שגיאות נאיבי, כמו `try/except Exception`, הוא כמו לשים פלסטר על פצע ירי. הוא מסתיר את הבעיה, לא פותר אותה.
במאמר הזה נצלול לעומק. נבנה מערכת שיודעת לצעוק כשהיא נפגעת, שמספרת לנו בדיוק איפה כואב לה, ושמאפשרת לנו לתקן בעיות לפני שהן הופכות לאסון דאטה.
מעבר ל-try/except: המוח מאחורי טיפול בשגיאות
האינסטינקט הראשון של מפתח הוא לעטוף קוד בעייתי בבלוק `try/except`. זה מונע קריסה, וזהו. אבל זה לא מספיק. כדי לטפל בשגיאות בצורה מקצועית, אנחנו חייבים קודם כל לסווג אותן. לא כל שגיאה נולדה שווה.
אני מחלק שגיאות scraping לארבע קטגוריות עיקריות:
- שגיאות רשת (Transient): כמו Timeout, DNS error, או 503 Service Unavailable. אלו שגיאות זמניות. הפתרון הוא כמעט תמיד retry עם backoff.
- שגיאות חסימה (Blocking): כאן נכנסות שגיאות כמו 403 Forbidden, 401 Unauthorized, או CAPTCHA. הן דורשות פעולה אקטיבית – החלפת IP, שינוי user-agent, או שימוש ב-solver. המשך עם אותה קונפיגורציה הוא בזבוז זמן. שגיאות 429 Too Many Requests נמצאות על הגבול, ודורשות גם הן אסטרטגיה חכמה.
- שגיאות Parser (Permanent for this version): ה-HTML השתנה, וה-CSS selector שלכם כבר לא מוצא את הכותרת. זו שגיאה קבועה עד שתתקנו את הקוד. אין טעם לנסות שוב. צריך התראה מיידית למפתח.
- שגיאות לוגיקה (Permanent): לדוגמה, ציפיתם לקבל מחיר כמספר וקיבלתם טקסט "Sold Out". זו לא שגיאת רשת או parser, אלא מקרה קצה שהלוגיקה שלכם לא טיפלה בו.
הסיווג הזה מאפשר לנו לבנות לוגיקת טיפול שונה לכל סוג שגיאה, במקום לזרוק את כולן לסל אחד.
# Define custom exceptions for clarity
class ScrapingException(Exception):
"""Base exception for our scraper."""
pass
class NetworkException(ScrapingException):
"""For transient network issues like timeouts or 5xx errors."""
pass
class BlockingException(ScrapingException):
"""For errors indicating we are blocked (403, CAPTCHA, etc.)."""
pass
class ParsingException(ScrapingException):
"""For when the page structure changes and selectors fail."""
pass
# Example usage
def fetch_page(url):
# ... logic to fetch page ...
if response.status_code == 503:
raise NetworkException("Server unavailable")
if "captcha" in response.text:
raise BlockingException("CAPTCHA detected")
# ...
try:
fetch_page("http://example.com")
except NetworkException as e:
print(f"Retrying due to network error: {e}")
# Add to retry queue with exponential backoff
except BlockingException as e:
print(f"Rotating proxy due to blocking error: {e}")
# Invalidate current proxy/session and get a new one
except ParsingException as e:
print(f"CRITICAL: Parser failed: {e}")
# Alert developer immediately, don't retry
לוגינג מובנה: איך להפסיק לחפש מחט בערימת שחת
אם הלוגים שלכם הם קובץ טקסט עם פקודות `print`, אתם עושים את זה לא נכון. נקודה. כשה-scraper שלכם רץ על אלפי כתובות בשעה, אתם צריכים יכולת לחפש, לסנן ולנתח שגיאות ביעילות. הפתרון הוא לוגינג מובנה (Structured Logging), בדרך כלל בפורמט JSON.
כל רשומת לוג צריכה להיות אובייקט JSON עם שדות קבועים. זה מאפשר לכם לשלוח את הלוגים למערכת כמו Elasticsearch או Datadog וליצור דשבורדים והתראות בקלות. במקום לחפש טקסט, אתם מריצים שאילתות.
מה חייב להיות בכל לוג שגיאה?
- timestamp: מתי זה קרה, ברמת המילישנייה.
- level: `ERROR`, `WARNING`, `INFO`.
- scraper_name: איזה סקרייפר נכשל?
- target_url: הכתובת המדויקת שניסינו לגשת אליה.
- error_type: סוג השגיאה שסיווגנו (למשל, `BlockingException`).
- error_message: הודעת השגיאה.
- proxy_address: אם אתם משתמשים בפרוקסי, איזה מהם נכשל? חיוני לזיהוי פרוקסים שרופים.
- job_id: מזהה ייחודי של הריצה, כדי לקשר את כל הלוגים מאותה משימה.
עם לוגים כאלה, שאלה כמו "תראה לי את כל שגיאות החסימה מספק פרוקסי X באתר Y בשעה האחרונה" הופכת לשאילתה פשוטה במקום שעות של `grep`.
דגרדציה חיננית: כשחלק מהמערכת נופל, השאר ממשיך לרוץ
תרחיש כשל קלאסי: אתם מריצים pipeline שאוסף מידע על 10,000 מוצרים. מוצר מספר 4,321 פתאום מחזיר מבנה HTML שונה שגורם ל-parser שלכם לקרוס עם `NoneType` error. אם לא טיפלתם בזה נכון, כל ה-pipeline נעצר. איבדתם 5,679 מוצרים בגלל אחד. זה לא מקצועי.
העיקרון של Graceful Degradation אומר שהמערכת צריכה להמשיך לתפקד גם כשחלקים ממנה נכשלים. בהקשר של scraping, זה אומר ששגיאה ב-URL בודד לא צריכה להפיל את כל הריצה. יש לעטוף את הלוגיקה של עיבוד הפריט הבודד בבלוק `try/except` משלו, לתעד את השגיאה הספציפית, ולהמשיך לפריט הבא.
אם אתם בונים ארכיטקטורת scraping בסקייל, זה קריטי עוד יותר. כל worker צריך להיות עצמאי. אם הוא נתקל בבעיה קריטית, הוא צריך לדווח עליה ולמות, בזמן שה-orchestrator ידאג להפעיל worker חדש במקומו, מבלי לעצור את שאר ה-99% מהעבודה שמתבצעת במקביל.
import logging
# Assume structured_logger is configured
def process_product(product_url):
try:
# 1. Fetch data
html = fetch_page(product_url)
# 2. Parse data
title_element = html.find(".product-title")
if not title_element:
# This is a specific parsing error for this item
raise ParsingException(f"Title selector not found for {product_url}")
title = title_element.text
# ... parse other fields ...
return {"url": product_url, "title": title}
except Exception as e:
# Log the specific failure but don't crash the whole process
logging.error(
"Failed to process item",
extra={
"target_url": product_url,
"error_type": type(e).__name__,
"error_message": str(e)
}
)
return None # Indicate failure for this specific item
def main_scraper(urls):
all_results = []
for url in urls:
result = process_product(url)
if result:
all_results.append(result)
# At the end, we have results from all successful items
print(f"Successfully scraped {len(all_results)} out of {len(urls)} items.")
התראות חכמות: מתי להעיר מישהו ב-3 בלילה?
קיבלתם שגיאת רשת אחת. האם צריך לשלוח התראת PagerDuty למפתח ה-on-call? ברור שלא. אבל אם 50% מהבקשות נכשלות עם שגיאות רשת בחמש הדקות האחרונות, זו כבר בעיה מערכתית שדורשת התערבות.
התראות צריכות להיות מבוססות על ספים (thresholds) ומגמות, לא על אירועים בודדים. הנה כמה דוגמאות להתראות שבאמת חשובות:
- רף שגיאות חסימה: אם מעל 5% מהבקשות לאתר מסוים נכשלות עם `BlockingException` ב-10 דקות, שלח התראה. זה כנראה אומר שהאתר שינה את מנגנון ההגנה שלו, או שמאגר הפרוקסים שלנו נשרף.
- רף שגיאות Parser: אם מעל 10 שגיאות `ParsingException` מאותו scraper מתקבלות בדקה, שלח התראה. זה סימן ברור לשינוי מבנה האתר. אין טעם להמשיך להריץ אותו.
- דאטה מת (Data Freshness): אם scraper שאמור לרוץ כל שעה לא הכניס נתונים חדשים לדאטהבייס ב-90 הדקות האחרונות, שלח התראה קריטית. יכול להיות שהוא נתקע או נכשל בשקט.
הכלים המודרניים כמו Prometheus ו-Grafana מאפשרים להגדיר חוקים מורכבים כאלה בקלות. המטרה היא להפחית רעש ולהתריע רק על מה שדורש פעולה אנושית מיידית.
כאוס מבוקר: לבדוק את החסינות של ה-Scraper
איך תדעו שהמערכת שלכם באמת עמידה? תנסו לשבור אותה. בכוונה. זה הרעיון מאחורי Chaos Engineering.
אפשר ליישם את זה על scrapers בצורה פשוטה יחסית. בסביבת ה-staging שלכם, תכניסו "קופים" שיחבלו בתהליך באופן אקראי:
- סימולציית רשת גרועה: הוסיפו רכיב שמכניס delay אקראי או זורק `TimeoutError` ב-1% מהבקשות. האם מנגנון ה-retry שלכם עובד?
- הזרקת HTML שבור: שנו את ה-response של בקשה כדי להסיר אלמנט קריטי. האם ה-scraper זיהה את שגיאת ה-parser, התריע, והמשיך לפריט הבא?
- "הרעלת" מאגר הפרוקסים: הכניסו למאגר שלכם כמה פרוקסים מתים או כאלה שמובילים ל-CAPTCHA. האם לוגיקת הרוטציה שלכם מזהה ומסירה אותם מהמאגר הפעיל?
התרגילים האלה אולי נראים כמו יצירת עבודה מיותרת, אבל הם חושפים חולשות במערכת שלכם בתנאים מבוקרים. עדיף לגלות שמנגנון ההתראות שלכם לא עובד ביום שלישי ב-11 בבוקר, ולא ביום שישי ב-3 לפנות בוקר כשהכל באמת עולה באש.
שאלות נפוצות
ההבחנה מתבססת על סיווג השגיאה כזמנית (transient) או קבועה (permanent). שגיאות רשת כמו 503 Service Unavailable או Timeout הן זמניות ודורשות retry, רצוי עם מנגנון exponential backoff. לעומת זאת, שגיאת parser, שבה מבנה ה-HTML השתנה, היא קבועה עד לתיקון קוד. ניסיון חוזר לא יעזור ויבזבז משאבים, ולכן יש לעצור את המשימה הספציפית ולהתריע למפתח באופן מיידי.
האסטרטגיה היעילה ביותר היא זיהוי, סיווג ובידוד. ראשית, יש לזהות את ה-CAPTCHA (למשל, על ידי חיפוש מילים כמו 'captcha' או סלקטורים של Cloudflare/hCaptcha). לאחר הזיהוי, יש לזרוק שגיאה מסוג `BlockingException`. הלוגיקה שתופסת אותה צריכה לבודד את הגורם החשוד – בדרך כלל ה-IP או ה-session. יש להשליך את ה-session הנוכחי, לבצע רוטציה ל-IP חדש (רצוי ממאגר של <a href="/blog/residential-proxy-guide">residential proxies</a>), ורק אז לנסות שוב את הבקשה. ניסיונות חוזרים עם אותו IP רק יחמירו את המצב.
תדירות הבדיקה תלויה ביציבות אתר היעד ובחשיבות הדאטה. ככלל אצבע, כדאי להריץ בדיקת תקינות (health check) בסיסית על ה-parsers מול דף HTML שמור פעם ביום, כחלק מ-CI/CD. בנוסף, חשוב להגדיר התראות מבוססות-סף. לדוגמה, אם יותר מ-5% מהדפים באתר מסוים מייצרים שגיאות parser במשך שעה, המערכת צריכה להתריע אוטומטית. זה מאפשר תגובה מהירה לשינויים באתר היעד מבלי להסתמך רק על בדיקות ידניות.
היתרון המרכזי של structured logging (כמו JSON) הוא שהלוגים הופכים למכונה קריאה. במקום קובץ טקסט שצריך לסרוק עם `grep`, כל שורת לוג היא אובייקט נתונים עם שדות מוגדרים כמו `proxy_ip`, `target_url`, `error_type`. זה מאפשר להזרים את הלוגים למערכות כמו Elasticsearch או Datadog, לבנות דשבורדים ויזואליים שמציגים שיעורי שגיאה לפי אתר או פרוקסי, ולהגדיר התראות מורכבות. למשל, אפשר בקלות להתריע רק אם שיעור השגיאות מסוג `BlockingException` עבר 10% בחמש הדקות האחרונות.
דגרדציה חיננית מבטיחה שכישלון בעיבוד פריט בודד לא יפיל את כל תהליך ה-scraping. במערכת ללא עיקרון זה, שגיאה במוצר אחד מתוך 10,000 יכולה לעצור את הריצה כולה. על ידי עטיפת הלוגיקה של כל פריט ב-try/except משלו, מתעדים את השגיאה הספציפית, מדלגים על הפריט הבעייתי וממשיכים הלאה. התוצאה היא שבסוף הריצה יהיו לנו נתונים מ-9,999 המוצרים המוצלחים, במקום אפס נתונים. זה קריטי למערכות גדולות שבהן כישלונות נקודתיים הם בלתי נמנעים.
