مقدمه
در این مقاله یک راهنمای عملی و فنی برای ساخت یک اسپايدر تولیدی با Python Scrapy بهمنظور اسکریپ کردن آگهیهای شغلی از سایت Indeed ارائه میکنم. هدف این راهنما این است که بعد از خواندن آن بتوانید یک سیستم کشف آگهی (discovery crawler) و یک اسکرِیپر صفحات شغل بسازید، خروجیها را به CSV یا پایگاهداده منتقل کنید، با مکانیزمهای ضدربات مقابله کنید و عملیات را پایش و زمانبندی کنید.
آنچه یاد میگیرید:
- معماری پیشنهادی برای اسکریپ Indeed
- نحوهٔ کشف نتایج جستجو، صفحهبندی و استخراج jobkey
- خواندن JSON پنهان در تگهای <script> و پارس کردن آن
- ارسال درخواستها از طریق پراکسی، مدیریت هدرها و مقابله با بلاک شدن
- ذخیرهسازی، مانیتورینگ و زمانبندی اجراها
معماری کلی و تصمیمگیریها
انتخاب معماری وابسته است به هدف، فرکانس اجرای اسکریپ، حجم داده و پیچیدگی تحلیلی که میخواهید انجام دهید. برای یک نمونهٔ میانرده (صدها کلیدواژه، پایش منظم) معماری پیشنهادی ساده و خودمحور است:
- یک اسپایدر Scrapy که هم کشف آگهی (جستجو و جمعآوری jobkeyها) و هم اسکرِیپ صفحهٔ شغل را انجام میدهد.
- استفاده از استخراج JSON پنهان در تگهای <script id="mosaic-data"> و window._initialData برای کاهش نیاز به XPath/CSS پیچیده.
- ذخیرهسازی اولیه در CSV یا FEEDS، و نمونههایی برای ارسال به دیتابیس/ S3.
- در محیط تولید: پراکسیهای چرخان، مانیتورینگ و زمانبندی اجراها.
مزایا/معایب این طراحی:
- مزایا: ساده برای پیادهسازی، دادهٔ خروجی پاک بهدلیل JSON داخلی، کمبود نیاز به پارس HTML پیچیده.
- معایب: نیاز به مدیریت ضدربات (پراکسی/تغییر UA/ریتاری)، وابستگی به فرمت JSON داخلی که ممکن است تغییر کند.
بخش اول — ساخت Crawler برای صفحهٔ جستجو
هدف این بخش ساخت یک crawler است که برای هر کلیدواژه و مکان، صفحهٔ جستجوی Indeed را درخواست دهد، JSON پنهان را بیرون بکشد و jobkeyها را استخراج کند. ابتدا ساختار URLهای جستجو را باید بشناسیم. پارامترهای مهم:
- q: عبارت جستجو (مثلاً software engineer)
- l: مکان (location)
- start: آفست برای صفحهبندی
ایدهٔ کلّی: با یک تابع کمکی URL درست میکنیم، صفحهٔ اول را میزنیم، از تگ window.mosaic.providerData["mosaic-provider-jobcards"] JSON استخراج میکنیم، عدد نتایج را میگیریم و سپس برای هر صفحهٔ بعدی درخواست میفرستیم.
نمونهٔ سادهٔ تابع تولید URL و اسکلت اسپایدر:
import re
import json
import scrapy
from urllib.parse import urlencode
class IndeedJobSpider(scrapy.Spider):
name = "indeed_jobs"
def get_indeed_search_url(self, keyword, location, offset=0):
params = {"q": keyword, "l": location, "filter": 0, "start": offset}
return "https://www.indeed.com/jobs?" + urlencode(params)
def start_requests(self):
keyword_list = ['python']
location_list = ['texas']
for keyword in keyword_list:
for location in location_list:
url = self.get_indeed_search_url(keyword, location)
yield scrapy.Request(url=url, callback=self.parse_search_results, meta={'keyword': keyword, 'location': location, 'offset': 0})
def parse_search_results(self, response):
pass
توضیح ورودی/خروجی و نقش توابع:
- get_indeed_search_url(keyword, location, offset): ورودیها عبارت جستجو، مکان و آفست هستند؛ خروجی یک URL کامل برای صفحه جستجو است.
- start_requests(): لیست کلیدواژهها و مکانها را پیمایش میکند و درخواست اولیه را میسازد؛ متادیتا شامل keyword/location/offset همراه با درخواست فرستاده میشود.
- parse_search_results(response): پردازش پاسخ صفحهٔ جستجو؛ در ادامه آن را کامل میکنیم.
استخراج JSON پنهان و صفحهبندی
در صفحات جستجو، دادهٔ نتایج داخل یک تگ <script id="mosaic-data"> و در متغیر window.mosaic.providerData["mosaic-provider-jobcards"] قرار دارد. از یک الگوی regex میتوان این بلاک JSON را جدا کرد و سپس با json.loads پارس کرد. الگوی معمولی (non-greedy) شکل زیر است:
pattern = r'window.mosaic.providerData\["mosaic-provider-jobcards"\]=(\{.+?\});'نمونهٔ کاملتر برای parse_search_results که هم صفحهبندی را محاسبه میکند و هم jobkeyها را استخراج میکند:
def parse_search_results(self, response):
location = response.meta['location']
keyword = response.meta['keyword']
offset = response.meta['offset']
script_tag = re.findall(r'window.mosaic.providerData\["mosaic-provider-jobcards"\]=(\{.+?\});', response.text)
if not script_tag:
return
json_blob = json.loads(script_tag[0])
# صفحهبندی: در صفحهٔ اول تعداد نتایج را محاسبه میکنیم
if offset == 0:
meta_data = json_blob['metaData']['mosaicProviderJobCardsModel']['tierSummaries']
num_results = sum(category['jobCount'] for category in meta_data)
num_results = min(num_results, 50) # برای مثال محدود به 50 میکنیم
for off in range(10, num_results + 10, 10):
url = self.get_indeed_search_url(keyword, location, off)
yield scrapy.Request(url=url, callback=self.parse_search_results, meta={'keyword': keyword, 'location': location, 'offset': off})
# استخراج لیست آگهیها
jobs_list = json_blob['metaData']['mosaicProviderJobCardsModel']['results']
for index, job in enumerate(jobs_list):
if job.get('jobkey'):
job_url = 'https://www.indeed.com/m/basecamp/viewjob?viewtype=embedded&jk=' + job.get('jobkey')
yield scrapy.Request(url=job_url, callback=self.parse_job, meta={'keyword': keyword, 'location': location, 'page': round(offset/10)+1 if offset>0 else 1, 'position': index, 'jobKey': job.get('jobkey')})
توضیح خطبهخط (خلاصه):
- ابتدا با regex تگ JSON را پیدا میکنیم و آن را با json.loads به دیکشنری تبدیل میکنیم.
- در آفست صفر، تعداد کل نتایج را از tierSummaries محاسبه میکنیم و بر اساس آن برای صفحات بعدی درخواست میسازیم.
- برای هر آیتم در results بررسی میکنیم که jobkey وجود داشته باشد و سپس URL صفحه شغل را تولید و درخواست میزنیم.
بخش دوم — ساخت Job Scraper (صفحهٔ جاب)
صفحهٔ هر شغل نیز دادهٔ اصلی را داخل یک تگ <script> در متغیر window._initialData نگهداری میکند. همینجا میتوانیم JSON را استخراج و فیلدهای مورد نیاز را خوانده و yield کنیم (که در Scrapy به خروجی تبدیل میشود).
def parse_job(self, response):
keyword = response.meta.get('keyword')
location = response.meta.get('location')
page = response.meta.get('page')
position = response.meta.get('position')
script_tag = re.findall(r'_initialData=(\{.+?\});', response.text)
if not script_tag:
return
json_blob = json.loads(script_tag[0])
job = json_blob['jobInfoWrapperModel']['jobInfoModel']
header = job.get('jobInfoHeaderModel', {})
sanitized = job.get('sanitizedJobDescription')
yield {
'keyword': keyword,
'location': location,
'page': page,
'position': position,
'company': header.get('companyName'),
'jobkey': response.meta.get('jobKey'),
'jobTitle': header.get('jobTitle'),
'jobDescription': sanitized,
}
نکات عملی دربارهٔ parse_job:
- چون JSON خیلی بزرگ است، فقط فیلدهایی را که لازم داریم استخراج کنید تا حافظه و زمان ذخیره کاهش یابد.
- sanitizedJobDescription معمولا HTML تمیز شده است؛ میتوانید آن را مستقیماً ذخیره یا با BeautifulSoup برای متن خام پاکسازی کنید.
- در صورت نبودن تگ JSON یا تغییر فرمت، باید fallbackهایی برای استخراج از HTML در نظر بگیرید.
ذخیرهسازی خروجی: FEEDS به CSV / ارسال به S3 یا دیتابیس
برای شروع ساده، از قابلیت Feed Exports در Scrapy استفاده کنید. در settings.py میتوانید مسیر داینامیک بسازید تا هر اجرا یک فایل جدید تولید شود:
# settings.py
FEEDS = {
'data/%(name)s_%(time)s.csv': {
'format': 'csv',
},
}
نکات و بهترینروشها:
- برای محیط تولید بهتر است خروجی را به S3 یا یک دیتابیس مانند Postgres ارسال کنید — در این حالت از pipeline یا اتصال مستقیم DB استفاده کنید.
- برای S3، از کتابخانههایی مثل boto3 یا پیکربندی FEEDS با یک مسیر s3:// استفاده کنید و کلیدهای AWS را امن نگه دارید.
- اطمینان حاصل کنید که اسکیما (نام فیلدها) یکنواخت است تا آنالیز بعدی ساده باشد.
مقابله با مکانیزمهای ضدربات
اگر چند بار اسکریپ را اجرا کنید احتمال دیدن صفحهٔ بلاک یا CAPTCHA وجود دارد. راهکارهای معمول:
- استفاده از پراکسیهای چرخان (rotating proxies) و پراکسیهایی با تشخیص بن (ban detection).
- چرخاندن User-Agent و هدرهای مرتبط، تنظیم زمانبندی ریت درخواستها و افزودن تاخیر تصادفی.
- استفاده از رندر سرور-ساید یا headless browser در صورت نیاز به اجرای جاوااسکریپت.
- بکآپ: اگر سایت JSON را ارسال میکند، درخواست مستقیم API-مانند را از طریق پراکسی ارسال کنید تا رندر کامل صفحه لازم نباشد.
مثالی از مسیر کردن درخواست از طریق یک پراکسی (تابع کمکی) و استفادهٔ آن در Requestها:
def get_scrapeops_url(self, url, api_key):
# api_key باید از محیط یا vault خوانده شود، نه هاردکد
from urllib.parse import urlencode
payload = {'api_key': api_key, 'url': url, 'bypass': 'cloudflare_level_1'}
return 'https://proxy.scrapeops.io/v1/?' + urlencode(payload)
# استفاده در زمان ساخت درخواست:
# yield scrapy.Request(url=self.get_scrapeops_url(job_url, SCRAPEOPS_API_KEY), ...)
نکات امنیتی و عملی:
- هرگز کلیدهای API را در سورسکد عمومی هاردکد نکنید. از متغیرهای محیطی یا secret manager استفاده کنید.
- ریِتریها و backoff را تنظیم کنید تا در مواجهه با خطای موقتی بیش از حد درخواست نزنید.
- قوانین و اخلاق: از robots.txt و شرایط استفادهٔ سایت آگاه باشید و دادههای حساس را ذخیره نکنید.
نصب و فعالسازی میدلور و مانیتورینگ
در محیط تولید، اگر از یک SDK پراکسی یا سامانهٔ مانیتورینگ استفاده میکنید، معمولاً باید یک Downloader Middleware و/یا Extension را در settings.py فعال کنید. مثال نمونهٔ پیکربندی برای فعال شدن میدلور و یک extension مانیتورینگ:
# settings.py (نمونه)
SCRAPEOPS_API_KEY = 'YOUR_API_KEY'
SCRAPEOPS_PROXY_ENABLED = True
DOWNLOADER_MIDDLEWARES = {
'scrapeops_scrapy_proxy_sdk.scrapeops_scrapy_proxy_sdk.ScrapeOpsScrapyProxySdk': 725,
}
EXTENSIONS = {
'scrapeops_scrapy.extension.ScrapeOpsMonitor': 500,
}
DOWNLOADER_MIDDLEWARES.update({
'scrapeops_scrapy.middleware.retry.RetryMiddleware': 550,
'scrapy.downloadermiddlewares.retry.RetryMiddleware': None,
})
توضیح: این تنظیمات باعث میشود درخواستها از طریق پراکسی SDK ارسال شوند و یک Extension ساده، متریکها و لاگهای اجرا را به داشبورد مانیتورینگ ارسال کند.
نکات مربوط به پایداری و مانیتورینگ
- لاگینگ ساختارمند: از JSON logging یا exporter برای ذخیره لاگها استفاده کنید تا خطاها و نرخ شکست قابل تحلیل باشند.
- آلارمها: نرخ خطا، تعداد CAPTCHAها، تعداد نتایج صفحاتی که ساختارشان تغییر کرده و تاخیرهای غیرعادی را مانیتور کنید.
- مکانیسم retry/backoff: بین retries افزایش فاصله دهید و در صورت تکرار خطا IP یا پراکسی را تعویض کنید.
زمانبندی و اجرای اسکرِیپر در کلاود
برای اجراهای منظم (مثلاً روزانه یا هفتگی) چند گزینه دارید: سرور اختصاصی، container در سرویسهای ابری یا استفاده از سرویسهای زمانبندی Jobs. نکات عملی:
- اگر از کانتینر استفاده میکنید، خروجی را مستقیماً به S3 یا دیتابیس ارسال کنید تا مقیاسپذیری ساده باشد.
- برای سربار کمتر، هر کلیدواژه را در jobهای موازی اما کنترلشده اجرا کنید و از صفها (مثلاً Celery یا یک job scheduler) برای مدیریت نرخ استفاده کنید.
- در زمانبندی، قوانین نرخسنجی سایت را رعایت کنید تا از بن شدن جلوگیری شود.
جمعبندی و توصیههای نهایی
خلاصهٔ مراحل عملی برای ساخت یک Indeed Spider قابلاستفاده در تولید:
- معماری ساده: ترکیب کشف آگهی و اسکریپ صفحهٔ شغل داخل یک اسپایدر.
- استخراج دادهها از JSON پنهان داخل تگهای <script> با regex و json.loads برای کاهش پیچیدگی پارس HTML.
- مدیریت صفحهبندی و تولید Requests برای هر jobkey.
- استفاده از پراکسی، تغییر UA، و مکانیزمهای retry برای مقابله با ضدربات.
- ذخیرهٔ خروجی با FEEDS یا pipeline به CSV/S3/DB و فعالسازی مانیتورینگ برای دیدن سلامت اجراها.
توصیههای عملی سریع:
- ابتدا نسخهٔ کوچک (smoke test) با چند کلیدواژه اجرا کنید و خروجی را بررسی کنید.
- کلیدهای API و اسرار را در محیط امن نگهداری کنید.
- تستهای واحد برای بخشهایی مثل پارس JSON و تبدیل دادهها بنویسید تا تغییرات فرمت سایت سریعتر معلوم شود.
اگر بخواهید میتوانم نمونهٔ کاملتر پروژهٔ Scrapy با فایلهای settings.py، pipelines و مثالهایی برای ارسال به Postgres/S3 را هم تهیه کنم یا نسخهای از همین اسکریپ را بهصورت قابل اجرا برای شما بازنویسی کنم.




