معجون بهشتی Selenium, PhantomJS و Requests برای web scraping
این روزها خودکار کردن کارهای تکراری روزمرهمان یک جورهایی برگ برندهٔ کاربران، توسعهدهندگان و یا حتی تیمهای فعال در حوزهٔ نرمافزار محسوب میشود. از پتانسیل بالای درآمدزاییاش در بعضی شرایط اگر بگذریم، کاهش زمان مورد نیاز انجام کارها به میزان زیاد و قابلیت اطمینان بسیار بیشتر نسبت به شرایطی که نیروی انسانی وظیفهٔ به پایان رساندن کار را بر عهده دارد، از جمله دلایلی است که میتواند زمان و هزینهٔ مورد نیاز جهت خودکار کردن اکثر کارها را به راحتی توجیح کند.
یکی از زمینههای هدف همچین فرآیندهایی میتواند سرویسهای تحت وب باشند. روندهایی تکراری که لازم است در زمان مشخص و با دقت کافی انجام گیرد. مثالهای زیادی از این دست میتوان نام برد. مثلا، انتخاب واحد دانشگاهها… که حقیقتش الان مطمئن نیستم که چقدر بهتر شده، ولی زمانی که من دانشجو بودم، از زمان اعلامی شروع انتخاب واحد، اگر موفق میشدیم طی پنج دقیقهٔ اول انتخابهایمان را انجام دهیم که هیچ، و اگرنه، همین انتخاب واحد ساده (وارد کردن چند کد واحد درسی و زدن کلید ثبت) میتوانست ساعتها وقتمان را تلف کند. یا به عنوان مثالی دیگر از این دست میتوان به روند خرید سهامها از روی درگاههای کارگزاریها اشاره کرد. عموما در شرایط حساس، جدای از سرعت اینترنت کافی (اصلا اگر بتوانید)، سرمایه و سیاست لازم جهت انتخاب و خرید سهم مورد نظر، گاها به یک سرعت عمل خارقالعاده نیاز دارید که تا به خود بجنبید، از قافله عقب نمانده باشید. ناسلامتی چنین شرایطی از مزایای ایرانی بودنمان است.
البته محدودهٔ مسائل ممکن به چنین مثالهایی ختم نمیشود. پیشنهاد میکنم ویدئوی زیر را در این زمینه از دست ندهید:
خوشبختانه امروزه سرویسهای زیادی را میتوان در اینترنت یافت که سعی در کمک به خودکار کردن هر چه بیشتر کارهای تکراریمان دارند. IFTTT و Zapier شاید از معروفترینهای این امر باشند. اما طبیعتا همهٔ شرایط و سرویسهای مورد نظر ما را شامل نمیشوند. گاها هم پیش میآید که سرویس مورد نظر ما APIای دقیقا به منظور انجام هدف ما در اختیارمان میگذارد. اما هنوز سرویسها، و مسائل بسیار بیشتر وجود دارند که به این راحتیها قابل خودکار شدن نیستند.
در چنین شرایطی میتوان Web Scraping را راهی معقول برای پیادهسازی دانست. کتابخانهها، ابزارها و شیوههای مختلفی، برای زبانهای مختلف در این زمینه موجود است که هر شخص میتواند بسته به تواناییهای شخصی و مسئلهٔ مورد نظر از میان آنها انتخاب کند.
چند روز پیش، قرار شد نمونهای از همین نوع خودکارسازی را در تیم FoundersBuddy انجام دهیم. مساله از این قرار بود که میخواستیم به صورت روزانه از دیتای کاربران موجود در حساب Customer.io یکی از استارتآپها پشتیبان تهیه کنیم. و خوب خود Customer، هیچ APIای در این زمینه ارائه نمیداد.
کل فرآیند تهیه پشتیبان از طریق مرورگر با انجام مراحل زیر از روی وبسایت قابل انجام بود:
- لاگین به حساب کاربری.
- رفتن به صفحهٔ اطلاعات کاربران.
- کلیک روی دکمهٔ Export to CSV.
- رفتن به صفحهٔ پشتیبانهای موجود.
- انتظار تا تکمیل درخواست پشتیبانگیری (در حدود چند ثانیه).
- کلیک روی لینک دانلود و دانلود فایل.
بدیهی است که تمام مراحل فوق باید در شرایط لاگین بودن کاربر انجام میشد. خوب این روند تقریبا ساده به نظر میرسد. خصوصا اگر با Selenium آشنا باشید. Selenium یک webdriver است. یک جورهایی با ارائهٔ کتابخانهای در زبان هدفتان، امکان تعامل با یک مرورگر وب را به صورت نرمافزاری برایتان فراهم میکند که البته کاربرد زیادی هم در زمینهٔ تست نرمافزار دارد. بزرگترین ویژگی Selenium برای من در مقابل انتخابهای دیگر، سر راست بودن انتخاب روندها و شباهتش به رفتار کاربر است.
مثلا سابقا در پروژهای از robobrowser استفاده کرده بودیم. و خدا نمیکرد اگر میخواستید روی دکمهای کلیک کنید و نتیجهٔ کلیک اجرای تابعی جاوااسکریپت و ارسال یک post request بود. در این شرایط دو راه داشتید، یا آن تابع جاوااسکریپت را خودتان پیدا کنید و به طور مناسب مقدار دهی و فراخوانیاش کنید. یا یک فرم Fake بسازید و درخواست را شبیهسازی کنید که بسته به شرایط هر کدام از این روشها به نحوی اذیت میکردند. حال آن که با Selenium کافیست متد click را روی المنت مورد نظر اجرا کنیم.
به هر حال، Selenium برای اجرا نیاز به یک مرورگر دارد. سیستم هدفی که قصد اجرای این Scrapper روی آن را داریم، یک سرور لینوکسی بدون رابط کاربری است، پس انتخاب معقول باید از پس همچین شرایطی بر بیاید. ترکیب PhantomJS با یک صفحه نمایش مجازی XVFB میتواند این مشکل را حل کند. خصوصا که کتابخانهٔ Pyvirtualdisplay پایتون میتواند در مدیریت صفحه نمایشهای XVFB کمک کند.
حالا میتوانیم روش انجام مراحل مورد نیازمان را بررسی کنیم:
شروع فرآیند
در ادامه به تعریفهای زیر نیاز خواهیم داشت:
import time
import requests
from selenium import webdriver
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.common.exceptions import TimeoutException
from selenium.webdriver.common.by import By
display = Display(visible=0, size=(800, 600))
display.start()
driver = webdriver.PhantomJS()
ایمپورتهای انجام شده که طبیعتا ابزارهای مورد نیاز از کتابخانهٔ Seleniumاند. ولی بخش مهم این تکه کد، تعریف display و driver است. display توسط pyvirtualdisplay روی xvfb با مشخصات ارائه شده ایجاد میشود و driver که حکم مرورگر ما را دارد از رابط PhantomJS استفاده میکند.
لاگین به حساب کاربری
برای این کار کافیست صفحهٔ مورد نظر را باز کنیم:
driver.get('https://fly.customer.io')
فیلدهای فرم مورد نظر را جهت لاگین و دکمهٔ سابمیت را انتخاب کنیم:
username = driver.find_element_by_id("user_email")
password = driver.find_element_by_id("user_password")
submit = driver.find_element_by_xpath("//input[@name='commit']")
و نهایتا فیلدها را تکمیل و روی سابمیت کلیک کنیم:
username.send_keys("shahinism")
password.send_keys("mypassword")
submit.click()
احتمالا اگر قدری HTML و پایتون بدانید، دستورات به قدر کافی گویا هستند. اگر هم که نه میتوانید به مستندات کتابخانه پایتون Selenium مراجعه کنید.
رفتن به صفحهٔ اطلاعات کاربر
این صفحه بسته به حساب کاربری، URL متفاوتی دارد و به همین دلیل نمیتوان از driver.get استفاده کرد. در عوض باید دنبال لینک حاوی آن بگردیم و رویش کلیک کنیم:
people = driver.find_element_by_id('people-nav-link')
people.click()
شبیه به مراحل قبل. اما این کد در اکثر مواقع به درستی کار نخواهد کرد. آن هم به دلیل این که به خاطر سرعت زیاد پردازش این خطوط، قبل از این که المنت با شناسه آیدی مورد نظر به صفحه اضافه شود، ما به دنبالش میگردیم ولی چیزی پیدا نمیکنیم و نتیجهاش میشود یک Exception.
البته این را اضافه کنم که اگر با JavaScript و خصوصا JQuery کار کرده باشید، ممکن است فکر کنید که مساله مشابه چیزی است که document.ready برایمان انجام میدهد. ولی خوشبختانه خود Selenium کار خیلی خوبی در این زمینه انجام میدهد و تا زمانی که کد کل صفحه بارگذاری نشده باشد، این خطوط را اجرا نمیکند.
مشکل اینجاست که رابط کاربری کاستومر، با Ember نوشته شده، و المنت مورد نظر ما بعد از لود صفحه به آن اضافه میشوند (دقیقا داخل همان document.ready). این است که ما باید منتظر اضافه شدن این المنت بمانیم. ولی چطور؟
def wait_and_get(query, by, delay=5):
try:
element_present = EC.presence_of_element_located((by, query))
WebDriverWait(driver, delay).until(element_present)
except TimeoutException:
print("Loading took too much time!")
close_browser()
return driver.find_element(by, query)
در تابع فوق، سعی میکنیم از درایور بخواهیم به مدت ۵ ثانیه در انتظار اضافه شدن این المنت بماند و اگر اضافه شد آن را به ما برگرداند و در غیر این صورت، خارج شود. (آن تکهٔ close_browser را در آخر شرح میدهم).
مقدار query در این تابع یک سلکتور متنی است که by مشخص میکند که از چه نوعی است. مثلا در مثال فوق، ما میخواهیم منتظر المنتی با شناسه آیدی، people-nav-link بمانیم، پس مقدار query میشود people-nav-link و مقدار by میشود، By.ID:
people = wait_and_get('people-nav-link', By.ID)
کلیک روی دکمهٔ Export to CSV
این دکمه متاسفانه هیچ ID یا Class سر راستی ندارد که بتوان از آن برای پیدا کردنش استفاده کرد و از قرار همان شناسههای موجود هم بسته به ساختار صفحه عوض میشوند. ولی خوب یک چیز ثابت در همهٔ شرایط وجود دارد. و آن این که اسم این دکمه همیشه Export to CSV است. پس میتوان به همین صورت هم انتخابش کرد:
print("Requesting new export...")
export = wait_and_get('//button[text()="Export to CSV"]', By.XPATH)
export.click()
رفتن به صفحهٔ پشتیبانهای موجود
این تغییر صفحه برخلاف قبلی خیلی آسان است:
driver.get("https://fly.customer.io/account/exports")
انتظار تا تکمیل درخواست پشتیبانگیری
این روند به این صورت است که پس از درخواست، چند ثانیهای طول میکشد تا پشتیبان حاضر شود و این چند ثانیه با نمایش متن Processing به جای دکمهٔ دانلود مشخص میشود. احتمالا راههای متفاوتی برای این کار وجود دارد، ولی من سادهترینش به نظر خودم را انتخاب کردم:
while 'Processing' in driver.page_source:
print("Processing...")
time.sleep(2)
driver.refresh()
خیلی ساده به درایور میگویم تا زمانی که عبارت Processing در صفحه وجود دارد، ۲ ثانیه استراحت کن و بعد صفحه را رفرش کن و آنقدر ادامه بده تا این عبارت حذف شود :)
کلیک روی لینک دانلود و دانلود فایل
لینک دانلود را به صورت زیر پیدا میکنیم:
latest_download = driver.find_element_by_css_selector(
'a.fly-btn.fly-btn--sm')
latest_link = latest_download.get_attribute('href')
ولی متاسفانه PhantomJS با همهٔ خوبیهایش، در حال حاضر امکان دانلود فایل ندارد. و خوب راستش را بخواهید وقتی به اینجای مساله رسیدم، دیگر خیلی دیر شده بود :)
برای دانلود، نیاز است که به عنوان یک کاربر لاگین شناسایی شویم، در غیر این صورت لینک دانلود استخراجی، به صفحهٔ لاگین ریدایرکت میشود. اینجاست که کتابخانهٔ محبوب پایتونیستها Requests وارد میشود. requests امکان استفاده از session در هنگام ارسال درخواستها را دارد. و درایور هم یک کپی از کوکیهای مورد نیاز لاگین را در اختیار دارد پس:
session = requests.Session()
cookies = driver.get_cookies()
for cookie in cookies:
session.cookies.set(cookie['name'], cookie['value'])
حالا requests میتواند لینک را دانلود کند:
response = session.get(latest_link)
with open(result_file, 'wb') as result_file:
result_file.write(response.content)
بستن مرورگر
احتمالا دیدید در موقع ایجاد مشکل، تابع close_browser را صدا میکردم. دلیلش هم این بود که مطمئن شوم فرآیندهای ایجاد شده PhantomJS و XVFB به درستی بسته میشوند.
def close_browser():
driver.quit()
display.stop()
نتیجهگیری
همانطور که گفتم، ابزارهای زیادی در این زمینه وجود دارد که هر کدام بسته به شرایط مورد نیاز، عملکرد مناسبتری دارند. مثلا سر قضیهٔ مشکل دانلود فایل PhantomJS، اکثر منابع اشاره به CasperJS کرده بود که این امکان را فراهم میکرد. ولی متاسفانه ریکوئست به دامنههای SSL تایماوت میگرفت. و در مورد کاستومر حتی دستور زیر هم نتیجهای نداشت:
casperjs --ssl-protocol=tlsv1 --ignore-ssl-errors=yes index.js
فعلا برای من این مجموعه، در مورد مسائل مشابه، انتخاب مناسبی به نظر میرسد.