Selenium 網路爬蟲教學: 用 Java Swing 建立圖形化的小工具,以 Yahoo Finance 爬蟲為例

PUBLISHED ON NOV 1, 2018 — WEB
FacebookTwitter LinkedIn LINE Skype EverNote GMail Yahoo Email

    免責聲明:我們盡力確保本文的正確性,但本文不代表任何投資的建議,我們也無法擔保因使用本文的內容所造成的任何損失。如對本文內容有疑問,請詢問財經相關的專家。

    在先前的範例中,我們撰寫運行在命令列的爬蟲程式;這樣的程式易寫,但使用上稍嫌不便,因為不是每個使用者都習慣操作命令列環境。在本文中,我們撰寫附帶圖形介面的爬蟲程式;雖然這個程式會比等效的命令列程式長一些,但對 (非技客的) 使用者來說更方便。由於 Python 要包圖形介面程式相對不易,本例改用 Java Swing 函式庫來撰寫;之後我們可以把整個專案包成 fat jar,發布更容易。不過,Selenium 仍然要另外安裝。

    由於程式碼略長,我們把完整的程式碼放在這裡,有興趣的讀者可自行追蹤。本文會拆解這個範例。

    整個程式的 Java 虛擬碼如下:

    // Delcare the package name.
    
    // Import some packages.
    
    public class YahooFinanceCrawler {
        private final String site = "https://finance.yahoo.com/";
        
        public void run(String targetAsset, TimeSpan timeSpan, String downloadPath) {
            // Set default download path for Chrome.
            
            // Start a new Chrome instance.
            
            // Visit Yahoo Finance site.
            
            // Manipulate the web page.
            
            // Download the data.
            
            // Close the browser.
        }
        
        public static void main(String[] args) {
            YahooFinanceCrawler crawler = new YahooFinanceCrawler();
    
            // Implement the GUI here.
    
            // Add the event listener for submitBtn.
            submitBtn.addActionListener((ActionEvent e) -> {
                String targetAsset = targetAssetField.getText();
                String targetDuration = targetDurationList.getSelectedItem().toString();
                
                // Set `ts` (time span).
                
                crawler.run(targetAsset, ts, System.getProperty("user.home") + "/Downloads");
            });
    
            // Implement the GUI here.
        }
    }
    

    整個爬蟲分為兩個函式,run 函式是實際執行爬蟲的程式碼片段,而 main 主函式會建立圖形使用者介面並呼叫爬蟲程式。我們把呼叫爬蟲的過程綁定在 submitBtn 上,使用者按下該按鈕時會觸發爬行 Yahoo Finance 並抓取股票歷史交易數據的動作。

    設定預設下載路徑:

    // Set default download path for Chrome.
    ChromeOptions options = new ChromeOptions();
    Map<String, Object> prefs = new HashMap<>();
    prefs.put("download.default_directory", downloadPath);
    options.setExperimentalOption("prefs", prefs);
    

    如果沒設下載路徑,Chrome 會自動按造系統原本的設置放置下載的檔案,不會跳對話框。這裡只是要將下載位置固定住,當然還有其他的做法,像是用檔案對話框讓使用者選,讀者可自行嘗試看看。

    開啟適用於 Chrome 的 web driver:

    // Start a new Chrome instance.
    WebDriver driver = new ChromeDriver(options);
    

    造訪目標網站,此例為 Yahoo Finance:

    // Visit the website.
    driver.get(site);
            
    // Wait the page to refresh.
    TimeUtils.sleepFor(ThreadLocalRandom.current().nextInt(9, 13), TimeUnit.SECONDS);
    

    在搜尋框輸入目標資產並送出:

    // Send search target to the website.
    WebElement input = driver.findElement(By.cssSelector("#fin-srch-assist input"));
    input.sendKeys(targetAsset);
    input.submit();
            
    // Wait the page to refresh.
    TimeUtils.sleepFor(ThreadLocalRandom.current().nextInt(6, 9), TimeUnit.SECONDS);
    

    選取 Historical Data 所在的分頁:

    // Click on "Historical Data" subpage.
    List<WebElement> subpages = driver.findElements(By.cssSelector("a span"));
    for (WebElement subpage : subpages) {
        if (subpage.getText().equals("Historical Data")) {
            subpage.click();
            break;
        }
    }
    
    // Simulate idling.
    TimeUtils.sleepFor(ThreadLocalRandom.current().nextInt(1, 4), TimeUnit.SECONDS);
    

    開啟對話框:

    // Select the dialog.
    WebElement arrow = driver.findElement(By.cssSelector(".historical div div span svg"));
    arrow.click();
            
    // Simulate idling.
    TimeUtils.sleepFor(ThreadLocalRandom.current().nextInt(1, 4), TimeUnit.SECONDS);
    

    選取時距:

    // Select the duration.
    List<WebElement> durations = driver.findElements(By.cssSelector("[data-test=\"date-picker-menu\"] div span"));
    for (WebElement duration : durations) {
        if (duration.getText().equals(timeSpanToString(timeSpan))) {
            duration.click();
            break;
        }
    }
            
    // Simulate idling.
    TimeUtils.sleepFor(ThreadLocalRandom.current().nextInt(1, 4), TimeUnit.SECONDS);
    

    我們的時距是一個列舉 (enum),而非字串,透過私有的 tomeSpanToString 函式將列舉轉字串。因為在這個網頁中,時距 (time span) 的項目很少而且固定,使用列舉可以鎖定選項,省掉檢查字串是否合法相關的程式碼。這不是必要的步驟,讀者可自行取捨。

    時距的定義如下:

    public static enum TimeSpan {
        TS_1D,
        TS_5D,
        TS_3M,
        TS_6M,
        TS_YTD,  // Year to date.
        TS_1Y,
        TS_5Y,
        TS_Max,
    };
    

    按下確認按鈕,結束選取:

    // Select "Done" button.
    WebElement done = driver.findElement(By.cssSelector("[data-test=\"date-picker-menu\"] div button"));
    done.click();
            
    // Simulate idling.
    TimeUtils.sleepFor(ThreadLocalRandom.current().nextInt(1, 4), TimeUnit.SECONDS);
    

    按下 Apply 按鈕,讓選項生效:

    // Apply the change.
    List<WebElement> buttons = driver.findElements(By.cssSelector("button span"));
    for (WebElement button : buttons) {
        if (button.getText().equals("Apply")) {
            button.click();
            break;
        }
    }
    

    確認系統上是否有舊檔案,若有則刪除:

    try
    {
        Files.deleteIfExists(Paths.get(downloadPath, targetAsset + ".csv"));
    }
    catch(NoSuchFileException e) 
    { 
        System.out.println("No such file/directory exists"); 
    } 
    catch(DirectoryNotEmptyException e) 
    { 
        System.out.println("Directory is not empty."); 
    } 
    catch(IOException e) 
    { 
        System.out.println("Invalid permissions."); 
    }
    

    在預設情形下,Chrome 會在檔案名稱附加 (1)(2)(3) 等,但這樣比較難在後續確認檔案名稱,故我們在即將下載前將舊檔案刪去。

    下載檔案:

    // Download the data.
    List<WebElement> links = driver.findElements(By.cssSelector("a span"));
    for (WebElement link : links) {
        if (link.getText().equals("Download Data")) {
            link.click();
            break;
        }
    }
    
    // Simulate idling.
    TimeUtils.sleepFor(ThreadLocalRandom.current().nextInt(1, 4), TimeUnit.SECONDS);
    

    最後,關掉瀏覽器:

    // Close the browser.
    driver.quit();
    

    在主函式的部分,我們會建立 GUI 的部分。在本範例中,我們使用 Swing 而非 JavaFX,因為前者內建在 Java 平台中,不用另外包;此外,在小型 GUI 程式中,Swing 的效能也夠用了。

    我們先建立一個 JFrame 物件:

    // Create a new JFrame.
    JFrame frame = new JFrame("Yahoo Finance Crawler");
    frame.setSize(320, 150);
    

    有一些 Java 的初中階教材,會用繼承的方式使用 JFrame,其實這不是必要的動作。Java 是單一繼承的語言,對於父類別應該要慎選,使用 Swing 不代表要繼承 Swing 的類別。因此,本文以組合的方式使用 Swing 函式庫。

    設定視窗關閉時的行為:

    frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
    

    設置視窗的 layout:

    // Set the layout of the frame.
    Container cr = frame.getContentPane();
            
    Box bv = Box.createVerticalBox();
    
    // Add more components here.
            
    // Add bv (the vertical box) to cr (the content pane).
    cr.add(bv);
    

    Layouts 的用意是排列視窗內的元件,layout 像是隱形的線條,不會顯示出來,但會影響元件的排列方式。在這裡,我們取出 frame 物件的 content pane,加入 bv (vertical box) 元件。中間我們省略了一些設置元件的程式碼。

    建立上方的輸入框,並加入 bv

    // Create the stock panel.
    JPanel stockPanel = new JPanel(new FlowLayout(FlowLayout.LEFT));
    stockPanel.add(new JLabel("Target asset: "));
    JTextField targetAssetField = new JTextField(15);
    stockPanel.add(targetAssetField);
            
    // Add the stock panel into the container.
    bv.add(BorderLayout.WEST, stockPanel);
    

    建立選取時距的選單:

    // Create the duration panel.
    JPanel durationPanel = new JPanel(new FlowLayout(FlowLayout.LEFT));
    durationPanel.add(new JLabel("Target duration: "));
            
    String[] targetDurations = {
        "1 day (1D)",
        "5 days (5D)",
        "3 months (3M)",
        "6 months (6M)",
        "Year To Date (YTD)",
        "1 year (1Y)",
        "5 years (5Y)",
        "Maximal (Max)"
    };
    JComboBox targetDurationList = new JComboBox(targetDurations);
    targetDurationList.setSelectedIndex(6);
    durationPanel.add(targetDurationList);
            
    bv.add(BorderLayout.WEST, durationPanel);
    

    加入 Submit 按鈕:

    // Create submitBtn.
    JButton submitBtn = new JButton("Submit");
    
    // Add the event listener for submitBtn.
    submitBtn.addActionListener((ActionEvent e) -> {
        String targetAsset = targetAssetField.getText();
        String targetDuration = targetDurationList.getSelectedItem().toString();
                
        TimeSpan ts = TimeSpan.TS_5Y;
        switch (targetDuration) {
            case "1 day (1D)":
                ts = TimeSpan.TS_1D;
                break;
            case "5 days (5D)":
                ts = TimeSpan.TS_5D;
                break;
            case "3 months (3M)":
                ts = TimeSpan.TS_3M;
                break;
            case "6 months (6M)":
                ts = TimeSpan.TS_6M;
                break;
            case "Year To Date (YTD)":
                ts = TimeSpan.TS_YTD;
                break;
            case "1 year (1Y)":
                ts = TimeSpan.TS_1Y;
                break;
            case "5 years (5Y)":
                ts = TimeSpan.TS_5Y;
                break;
            case "Maximal (Max)":
                ts = TimeSpan.TS_Max;
                break;
        }
                
        crawler.run(targetAsset, ts, System.getProperty("user.home") + "/Downloads");
    });
    
    bv.add(BorderLayout.EAST, submitBtn);
    

    由於選單取得的項目是字串,我們要將其轉為列舉。雖然字串轉列舉、列舉再轉字串的過程看起來有點多餘,如果我們將這個 jar 當成函式庫使用時,使用列舉仍然有好處。

    最後不要忘了將 frame 設為可見的 (visible):

    // Make the frame visible.
    frame.setVisible(true);
    
    TAGS: SELENIUM