免責聲明:我們盡力確保本文的正確性,但本文不代表任何投資的建議,我們也無法擔保因使用本文的內容所造成的任何損失。如對本文內容有疑問,請詢問財經相關的專家。
在先前的範例中,我們撰寫運行在命令列的爬蟲程式;這樣的程式易寫,但使用上稍嫌不便,因為不是每個使用者都習慣操作命令列環境。在本文中,我們撰寫附帶圖形介面的爬蟲程式;雖然這個程式會比等效的命令列程式長一些,但對 (非技客的) 使用者來說更方便。由於 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);