Python——為熱愛旅行的人尋找便宜機票的助手

我們今天發布的這篇文章的翻譯,其作者表示,其目標是討論使用 Selenium 在 Python 中開發網絡爬蟲,用於搜索機票價格。 搜索門票時,使用靈活日期(相對於指定日期+- 3 天)。 抓取工具將搜索結果保存在 Excel 文件中,並向運行搜索的人員發送一封電子郵件,其中包含他們所發現內容的摘要。 該項目的目標是幫助旅行者找到最優惠的價格。

Python——為熱愛旅行的人尋找便宜機票的助手

如果在理解材料時您感到迷失,請看一下 文章。

我們要尋找什麼?

您可以根據需要自由使用此處描述的系統。 例如,我用它來搜索週末旅遊和去我家鄉的門票。 如果您真的想找到有利可圖的門票,您可以在服務器上運行該腳本(簡單 服務器,每月 130 盧布,非常適合這個)並確保它每天運行一次或兩次。 搜索結果將通過電子郵件發送給您。 此外,我建議進行所有設置,以便腳本將包含搜索結果的 Excel 文件保存在 Dropbox 文件夾中,這樣您就可以隨時隨地查看此類文件。

Python——為熱愛旅行的人尋找便宜機票的助手
我還沒有發現有錯誤的關稅,但我認為這是可能的

正如已經提到的,搜索時使用“靈活日期”;腳本查找給定日期三天內的報價。 雖然運行該腳本時,它僅搜索一個方向的報價,但很容易對其進行修改,以便它可以收集多個飛行方向的數據。 在它的幫助下,您甚至可以查找錯誤的關稅;這樣的發現可能非常有趣。

為什麼需要另一個網絡抓取工具?

當我第一次開始網絡抓取時,老實說我對此並不是特別感興趣。 我想在預測建模、財務分析領域做更多的項目,也可能在分析文本的情感色彩領域做更多的項目。 但事實證明,弄清楚如何創建一個從網站收集數據的程序是非常有趣的。 當我深入研究這個主題時,我意識到網絡抓取是互聯網的“引擎”。

你可能認為這個說法過於大膽。 但考慮一下 Google 是從 Larry Page 使用 Java 和 Python 創建的網絡爬蟲開始的。 谷歌機器人一直在探索互聯網,試圖為用戶的問題提供最佳答案。 網絡抓取有無窮無盡的用途,即使您對數據科學中的其他內容感興趣,您也需要一些抓取技能來獲取需要分析的數據。

我發現這裡使用的一些技術非常棒 這本書 關於我最近獲得的網絡抓取。 它包含許多簡單的示例和想法,可幫助您實際應用所學知識。 另外,還有一個非常有趣的章節是關於繞過 reCaptcha 檢查的。 這對我來說是個新聞,因為我什至不知道有特殊的工具甚至完整的服務可以解決此類問題。

你喜歡旅行嗎?!

對於本節標題中提出的簡單且無害的問題,您經常可以聽到肯定的答案,並附有一些來自被問者旅行中的故事。 我們大多數人都同意,旅行是讓自己沉浸在新的文化環境和開闊視野的好方法。 然而,如果你問某人是否喜歡搜索機票,我相信答案不會那麼肯定。 事實上,Python 在這里為我們提供了幫助。

在創建機票信息搜索系統的過程中,我們需要解決的第一個任務是選擇一個合適的平台來獲取信息。 解決這個問題對我來說並不容易,但最終我選擇了Kayak服務。 我嘗試過Momondo、Skyscanner、Expedia等一些服務,但這些資源上的機器人保護機制是堅不可摧的。 經過幾次嘗試,期間我不得不處理交通燈、人行橫道和自行車,試圖讓系統相信我是人類,我決定 Kayak 最適合我,儘管事實是即使加載了太多頁面很快,檢查也開始了。 我設法讓機器人每隔 4 到 6 小時向網站發送請求,一切都運行良好。 使用 Kayak 時,有時會出現困難,但如果他們開始用檢查來糾纏您,那麼您需要手動處理它們,然後啟動機器人,或者等待幾個小時,檢查就會停止。 如有必要,您可以輕鬆地將代碼改編為另一個平台,如果您這樣做,您可以在評論中報告。

如果您剛剛開始使用網絡抓取,並且不知道為什麼有些網站會遇到困難,那麼在您開始該領域的第一個項目之前,請幫自己一個忙,在 Google 上搜索“網絡抓取禮儀” 。 如果您不明智地進行網絡抓取,您的實驗可能會比您想像的更快結束。

入門

以下是我們的網絡抓取代碼中將發生的情況的總體概述:

  • 導入所需的庫。
  • 打開 Google Chrome 標籤。
  • 調用啟動機器人的函數,並向其傳遞搜索門票時將使用的城市和日期。
  • 此函數獲取第一個搜索結果,按最佳排序,然後單擊按鈕加載更多結果。
  • 另一個函數從整個頁面收集數據並返回一個數據框。
  • 前兩個步驟是使用按機票價格(便宜)和飛行速度(最快)的排序類型來執行的。
  • 該腳本的用戶會收到一封包含票價摘要(最便宜的門票和平均價格)的電子郵件,並且包含按上述三個指標排序的信息的數據框將保存為 Excel 文件。
  • 以上所有動作在指定時間後循環執行。

應該注意的是,每個 Selenium 項目都是從 Web 驅動程序開始的。 我用 Chrome驅動程序,我使用 Google Chrome,但還有其他選擇。 PhantomJS 和 Firefox 也很流行。 下載驅動程序後,需要將其放置在相應的文件夾中,這樣就完成了使用的準備工作。 我們腳本的第一行打開一個新的 Chrome 選項卡。

請記住,在我的故事中,我並不是想打開新的視野來尋找特價機票。 有更高級的方法來搜索此類優惠。 我只是想為本文的讀者提供一種簡單但實用的方法來解決這個問題。

這是我們上面討論的代碼。

from time import sleep, strftime
from random import randint
import pandas as pd
from selenium import webdriver
from selenium.webdriver.common.keys import Keys
import smtplib
from email.mime.multipart import MIMEMultipart

# Используйте тут ваш путь к chromedriver!
chromedriver_path = 'C:/{YOUR PATH HERE}/chromedriver_win32/chromedriver.exe'

driver = webdriver.Chrome(executable_path=chromedriver_path) # Этой командой открывается окно Chrome
sleep(2)

在代碼的開頭,您可以看到整個項目中使用的包導入命令。 所以, randint 用於讓機器人在開始新的搜索操作之前“入睡”隨機的秒數。 通常,沒有一個機器人可以做到這一點。 如果運行上述代碼,將打開一個 Chrome 窗口,機器人將使用該窗口來處理網站。

讓我們做一個小實驗,在單獨的窗口中打開 kayak.com 網站。 我們將選擇我們要起飛的城市、我們想要到達的城市以及航班日期。 選擇日期時,請確保使用 +-3 天的範圍。 我編寫代碼時考慮了網站響應此類請求而生成的內容。 例如,如果您只需要搜索指定日期的門票,那麼您很可能必須修改機器人代碼。 當我談論代碼時,我會提供適當的解釋,但如果您感到困惑,請告訴我。

現在單擊搜索按鈕並查看地址欄中的鏈接。 它應該類似於我在下面的示例中聲明變量的鏈接 kayak,存儲的是URL,使用的方法 get 網絡驅動程序。 單擊搜索按鈕後,結果應顯示在頁面上。

Python——為熱愛旅行的人尋找便宜機票的助手
當我使用命令時 get 幾分鐘內不止兩三次被要求使用reCaptcha完成驗證。 您可以手動通過此檢查並繼續試驗,直到系統決定運行新的檢查。 當我測試腳本時,似乎第一個搜索會話總是順利進行,因此如果您想試驗代碼,只需定期手動檢查並讓代碼運行,在搜索會話之間使用較長的間隔。 而且,如果您考慮一下,人們不太可能需要在搜索操作之間每隔 10 分鐘收到的票價信息。

使用 XPath 處理頁面

因此,我們打開一個窗口並加載該網站。 要獲取定價和其他信息,我們需要使用 XPath 技術或 CSS 選擇器。 我決定堅持使用 XPath,並且覺得不需要使用 CSS 選擇器,但很有可能以這種方式工作。 使用 XPath 瀏覽頁面可能會很棘手,即使您使用我在 文章涉及從頁面代碼複製相應的標識符,我意識到這實際上不是訪問必要元素的最佳方式。 順便說一下,在 本書對使用 XPath 和 CSS 選擇器處理頁面的基礎知識進行了精彩的描述。 這就是相應的 Web 驅動程序方法的樣子。

Python——為熱愛旅行的人尋找便宜機票的助手
那麼,讓我們繼續研究機器人。 讓我們使用該程序的功能來選擇最便宜的機票。 在下圖中,XPath 選擇器代碼以紅色突出顯示。 為了查看代碼,您需要右鍵單擊您感興趣的頁面元素,然後從出現的菜單中選擇“檢查”命令。 可以為不同的頁面元素調用此命令,其代碼將在代碼查看器中顯示並突出顯示。

Python——為熱愛旅行的人尋找便宜機票的助手
查看頁面代碼

為了證實我關於從代碼複製選擇器的缺點的推理,請注意以下功能。

這是複制代碼後得到的結果:

//*[@id="wtKI-price_aTab"]/div[1]/div/div/div[1]/div/span/span

為了複製類似的內容,您需要右鍵單擊您感興趣的代碼部分,然後從出現的菜單中選擇“複製”>“複製 XPath”命令。

這是我用來定義最便宜按鈕的內容:

cheap_results = ‘//a[@data-code = "price"]’

Python——為熱愛旅行的人尋找便宜機票的助手
複製命令 > 複製 XPath

很明顯,第二個選項看起來簡單得多。 使用時,它會搜索具有屬性的元素 a data-code, 平等的 price。 使用第一個選項時,將搜索該元素 id 這等於 wtKI-price_aTab,元素的 XPath 路徑看起來像 /div[1]/div/div/div[1]/div/span/span。 像這樣對頁面的 XPath 查詢就可以解決問題,但只能執行一次。 我現在可以說 id 將在下次加載頁面時更改。 字符序列 wtKI 每次加載頁面時都會動態更改,因此使用它的代碼在下一個頁面重新加載後將毫無用處。 因此,請花一些時間來了解 XPath。 這些知識將對你很有幫助。

但是,應該注意的是,在處理相當簡單的站點時,複製 XPath 選擇器可能很有用,如果您對此感到滿意,那麼它沒有任何問題。

現在讓我們考慮一下,如果您需要在列表中的多行中獲取所有搜索結果,該怎麼辦。 很簡單。 每個結果都位於一個具有類的對象內 resultWrapper。 加載所有結果可以在類似於下面所示的循環中完成。

需要注意的是,如果你理解了上面的內容,那麼你應該很容易理解我們將要分析的大部分代碼。 當此代碼運行時,我們使用某種路徑指定機制 (XPath) 訪問我們需要的內容(實際上是包裝結果的元素)。 這樣做是為了獲取元素的文本並將其放置在可以讀取數據的對像中(首先使用 flight_containers, 然後 - flights_list).

Python——為熱愛旅行的人尋找便宜機票的助手
顯示前三行,我們可以清楚地看到我們需要的一切。 然而,我們有更有趣的獲取信息的方式。 我們需要分別從每個元素中獲取數據。

開始工作吧!

編寫函數的最簡單方法是加載附加結果,所以這就是我們開始的地方。 我希望最大限度地增加程序接收信息的航班數量,而不引起對導致檢查的服務的懷疑,因此每次顯示頁面時我都會單擊“加載更多結果”按鈕。 在此代碼中,您應該注意塊 try,我添加它是因為有時按鈕無法正確加載。 如果您也遇到這種情況,請在函數代碼中註釋掉對該函數的調用 start_kayak,我們將在下面查看。

# Загрузка большего количества результатов для того, чтобы максимизировать объём собираемых данных
def load_more():
    try:
        more_results = '//a[@class = "moreButton"]'
        driver.find_element_by_xpath(more_results).click()
        # Вывод этих заметок в ходе работы программы помогает мне быстро выяснить то, чем она занята
        print('sleeping.....')
        sleep(randint(45,60))
    except:
        pass

現在,經過對這個函數的長時間分析(有時我可能會得意忘形),我們準備聲明一個將抓取頁面的函數。

我已經收集了以下函數所需的大部分內容 page_scrape。 有時返回的路徑數據是合併的,所以我用一個簡單的方法將其分開。 例如,當我第一次使用變量時 section_a_list и section_b_list。 我們的函數返回一個數據框 flights_df,這使我們能夠將不同數據排序方法獲得的結果分開,然後將它們組合起來。

def page_scrape():
    """This function takes care of the scraping part"""
    
    xp_sections = '//*[@class="section duration"]'
    sections = driver.find_elements_by_xpath(xp_sections)
    sections_list = [value.text for value in sections]
    section_a_list = sections_list[::2] # так мы разделяем информацию о двух полётах
    section_b_list = sections_list[1::2]
    
    # Если вы наткнулись на reCaptcha, вам может понадобиться что-то предпринять.
    # О том, что что-то пошло не так, вы узнаете исходя из того, что вышеприведённые списки пусты
    # это выражение if позволяет завершить работу программы или сделать ещё что-нибудь
    # тут можно приостановить работу, что позволит вам пройти проверку и продолжить скрапинг
    # я использую тут SystemExit так как хочу протестировать всё с самого начала
    if section_a_list == []:
        raise SystemExit
    
    # Я буду использовать букву A для уходящих рейсов и B для прибывающих
    a_duration = []
    a_section_names = []
    for n in section_a_list:
        # Получаем время
        a_section_names.append(''.join(n.split()[2:5]))
        a_duration.append(''.join(n.split()[0:2]))
    b_duration = []
    b_section_names = []
    for n in section_b_list:
        # Получаем время
        b_section_names.append(''.join(n.split()[2:5]))
        b_duration.append(''.join(n.split()[0:2]))

    xp_dates = '//div[@class="section date"]'
    dates = driver.find_elements_by_xpath(xp_dates)
    dates_list = [value.text for value in dates]
    a_date_list = dates_list[::2]
    b_date_list = dates_list[1::2]
    # Получаем день недели
    a_day = [value.split()[0] for value in a_date_list]
    a_weekday = [value.split()[1] for value in a_date_list]
    b_day = [value.split()[0] for value in b_date_list]
    b_weekday = [value.split()[1] for value in b_date_list]
    
    # Получаем цены
    xp_prices = '//a[@class="booking-link"]/span[@class="price option-text"]'
    prices = driver.find_elements_by_xpath(xp_prices)
    prices_list = [price.text.replace('$','') for price in prices if price.text != '']
    prices_list = list(map(int, prices_list))

    # stops - это большой список, в котором первый фрагмент пути находится по чётному индексу, а второй - по нечётному
    xp_stops = '//div[@class="section stops"]/div[1]'
    stops = driver.find_elements_by_xpath(xp_stops)
    stops_list = [stop.text[0].replace('n','0') for stop in stops]
    a_stop_list = stops_list[::2]
    b_stop_list = stops_list[1::2]

    xp_stops_cities = '//div[@class="section stops"]/div[2]'
    stops_cities = driver.find_elements_by_xpath(xp_stops_cities)
    stops_cities_list = [stop.text for stop in stops_cities]
    a_stop_name_list = stops_cities_list[::2]
    b_stop_name_list = stops_cities_list[1::2]
    
    # сведения о компании-перевозчике, время отправления и прибытия для обоих рейсов
    xp_schedule = '//div[@class="section times"]'
    schedules = driver.find_elements_by_xpath(xp_schedule)
    hours_list = []
    carrier_list = []
    for schedule in schedules:
        hours_list.append(schedule.text.split('n')[0])
        carrier_list.append(schedule.text.split('n')[1])
    # разделяем сведения о времени и о перевозчиках между рейсами a и b
    a_hours = hours_list[::2]
    a_carrier = carrier_list[1::2]
    b_hours = hours_list[::2]
    b_carrier = carrier_list[1::2]

    
    cols = (['Out Day', 'Out Time', 'Out Weekday', 'Out Airline', 'Out Cities', 'Out Duration', 'Out Stops', 'Out Stop Cities',
            'Return Day', 'Return Time', 'Return Weekday', 'Return Airline', 'Return Cities', 'Return Duration', 'Return Stops', 'Return Stop Cities',
            'Price'])

    flights_df = pd.DataFrame({'Out Day': a_day,
                               'Out Weekday': a_weekday,
                               'Out Duration': a_duration,
                               'Out Cities': a_section_names,
                               'Return Day': b_day,
                               'Return Weekday': b_weekday,
                               'Return Duration': b_duration,
                               'Return Cities': b_section_names,
                               'Out Stops': a_stop_list,
                               'Out Stop Cities': a_stop_name_list,
                               'Return Stops': b_stop_list,
                               'Return Stop Cities': b_stop_name_list,
                               'Out Time': a_hours,
                               'Out Airline': a_carrier,
                               'Return Time': b_hours,
                               'Return Airline': b_carrier,                           
                               'Price': prices_list})[cols]
    
    flights_df['timestamp'] = strftime("%Y%m%d-%H%M") # время сбора данных
    return flights_df

我嘗試命名變量,以便代碼易於理解。 請記住,變量以 a 屬於道路的第一階段,並且 b - 到第二個。 讓我們繼續討論下一個函數。

支持機制

我們現在有一個函數可以加載額外的搜索結果,還有一個函數可以處理這些結果。 本文本來可以到此結束,因為這兩個函數提供了抓取您可以自己打開的頁面所需的一切。 但我們還沒有考慮上面討論的一些輔助機制。 例如,這是發送電子郵件和其他一些事情的代碼。 這一切都可以在函數中找到 start_kayak,我們現在將考慮這一點。

為了使此功能發揮作用,您需要有關城市和日期的信息。 使用此信息,它在變量中形成鏈接 kayak,用於將您帶到一個頁面,其中包含按與查詢的最佳匹配排序的搜索結果。 第一次抓取會話後,我們將使用頁面頂部表格中的價格。 即,我們將找到最低票價和平均價格。 所有這些以及該網站發布的預測都將通過電子郵件發送。 在頁面上,相應的表格應該位於左上角。 順便說一下,使用此表可能會在使用精確日期進行搜索時導致錯誤,因為在這種情況下,該表不會顯示在頁面上。

def start_kayak(city_from, city_to, date_start, date_end):
    """City codes - it's the IATA codes!
    Date format -  YYYY-MM-DD"""
    
    kayak = ('https://www.kayak.com/flights/' + city_from + '-' + city_to +
             '/' + date_start + '-flexible/' + date_end + '-flexible?sort=bestflight_a')
    driver.get(kayak)
    sleep(randint(8,10))
    
    # иногда появляется всплывающее окно, для проверки на это и его закрытия можно воспользоваться блоком try
    try:
        xp_popup_close = '//button[contains(@id,"dialog-close") and contains(@class,"Button-No-Standard-Style close ")]'
        driver.find_elements_by_xpath(xp_popup_close)[5].click()
    except Exception as e:
        pass
    sleep(randint(60,95))
    print('loading more.....')
    
#     load_more()
    
    print('starting first scrape.....')
    df_flights_best = page_scrape()
    df_flights_best['sort'] = 'best'
    sleep(randint(60,80))
    
    # Возьмём самую низкую цену из таблицы, расположенной в верхней части страницы
    matrix = driver.find_elements_by_xpath('//*[contains(@id,"FlexMatrixCell")]')
    matrix_prices = [price.text.replace('$','') for price in matrix]
    matrix_prices = list(map(int, matrix_prices))
    matrix_min = min(matrix_prices)
    matrix_avg = sum(matrix_prices)/len(matrix_prices)
    
    print('switching to cheapest results.....')
    cheap_results = '//a[@data-code = "price"]'
    driver.find_element_by_xpath(cheap_results).click()
    sleep(randint(60,90))
    print('loading more.....')
    
#     load_more()
    
    print('starting second scrape.....')
    df_flights_cheap = page_scrape()
    df_flights_cheap['sort'] = 'cheap'
    sleep(randint(60,80))
    
    print('switching to quickest results.....')
    quick_results = '//a[@data-code = "duration"]'
    driver.find_element_by_xpath(quick_results).click()  
    sleep(randint(60,90))
    print('loading more.....')
    
#     load_more()
    
    print('starting third scrape.....')
    df_flights_fast = page_scrape()
    df_flights_fast['sort'] = 'fast'
    sleep(randint(60,80))
    
    # Сохранение нового фрейма в Excel-файл, имя которого отражает города и даты
    final_df = df_flights_cheap.append(df_flights_best).append(df_flights_fast)
    final_df.to_excel('search_backups//{}_flights_{}-{}_from_{}_to_{}.xlsx'.format(strftime("%Y%m%d-%H%M"),
                                                                                   city_from, city_to, 
                                                                                   date_start, date_end), index=False)
    print('saved df.....')
    
    # Можно следить за тем, как прогноз, выдаваемый сайтом, соотносится с реальностью
    xp_loading = '//div[contains(@id,"advice")]'
    loading = driver.find_element_by_xpath(xp_loading).text
    xp_prediction = '//span[@class="info-text"]'
    prediction = driver.find_element_by_xpath(xp_prediction).text
    print(loading+'n'+prediction)
    
    # иногда в переменной loading оказывается эта строка, которая, позже, вызывает проблемы с отправкой письма
    # если это прозошло - меняем её на "Not Sure"
    weird = '¯_(ツ)_/¯'
    if loading == weird:
        loading = 'Not sure'
    
    username = '[email protected]'
    password = 'YOUR PASSWORD'

    server = smtplib.SMTP('smtp.outlook.com', 587)
    server.ehlo()
    server.starttls()
    server.login(username, password)
    msg = ('Subject: Flight Scrapernn
Cheapest Flight: {}nAverage Price: {}nnRecommendation: {}nnEnd of message'.format(matrix_min, matrix_avg, (loading+'n'+prediction)))
    message = MIMEMultipart()
    message['From'] = '[email protected]'
    message['to'] = '[email protected]'
    server.sendmail('[email protected]', '[email protected]', msg)
    print('sent email.....')

我使用 Outlook 帳戶 (hotmail.com) 測試了此腳本。 我還沒有測試過它是否可以與 Gmail 帳戶一起正常工作,這個電子郵件系統非常流行,但有很多可能的選擇。 如果您使用 Hotmail 帳戶,那麼為了使一切正常工作,您只需將您的數據輸入到代碼中即可。

如果您想了解此函數的代碼的特定部分到底做了什麼,您可以復制它們並進行試驗。 試驗代碼是真正理解它的唯一方法。

就緒系統

現在我們已經完成了所討論的所有內容,我們可以創建一個調用函數的簡單循環。 該腳本向用戶請求有關城市和日期的數據。 當不斷重新啟動腳本進行測試時,您不太可能希望每次都手動輸入這些數據,因此在測試時,可以通過取消註釋下面的行來註釋掉相應的行,其中腳本是硬編碼的。

city_from = input('From which city? ')
city_to = input('Where to? ')
date_start = input('Search around which departure date? Please use YYYY-MM-DD format only ')
date_end = input('Return when? Please use YYYY-MM-DD format only ')

# city_from = 'LIS'
# city_to = 'SIN'
# date_start = '2019-08-21'
# date_end = '2019-09-07'

for n in range(0,5):
    start_kayak(city_from, city_to, date_start, date_end)
    print('iteration {} was complete @ {}'.format(n, strftime("%Y%m%d-%H%M")))
    
    # Ждём 4 часа
    sleep(60*60*4)
    print('sleep finished.....')

這就是腳本測試運行的樣子。
Python——為熱愛旅行的人尋找便宜機票的助手
測試運行腳本

結果

如果您已經做到了這一步,那麼恭喜您! 您現在已經有了一個可以工作的網絡抓取工具,儘管我已經看到了許多改進它的方法。 例如,它可以與 Twilio 集成,以便發送短信而不是電子郵件。 您可以使用 VPN 或其他方式同時接收來自多個服務器的結果。 還有一個週期性出現的問題,即檢查網站用戶是否是一個人,但這個問題也是可以解決的。 無論如何,現在你已經有了一個基地,如果你願意的話,你可以擴展它。 例如,確保將 Excel 文件作為電子郵件附件發送給用戶。

Python——為熱愛旅行的人尋找便宜機票的助手

只有註冊用戶才能參與調查。 登入, 請。

您使用網絡抓取技術嗎?

  • Да

  • 沒有

8 位用戶投票。 1 位用戶棄權。

來源: www.habr.com

添加評論