位元詩人 [Selenium] 程式設計教學:如何用 Java Swing 建立圖形化的網路爬蟲

Facebook Twitter 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);
關於作者

身為資訊領域碩士,位元詩人 (ByteBard) 認為開發應用程式的目的是為社會帶來價值。如果在這個過程中該軟體能成為永續經營的項目,那就是開發者和使用者雙贏的局面。

位元詩人喜歡用開源技術來解決各式各樣的問題,但必要時對專有技術也不排斥。閒暇之餘,位元詩人將所學寫成文章,放在這個網站上和大家分享。