說明

由於上班如果要看股票就只能透過網頁或很明顯的應用程式切來切去又怕被老闆看到,或著是一直盯看手機又不好意思開提醒的音效,所以就想說寫一個隱藏在畫面右下角可以即時更新股價的程式,可以不用一直切換畫面且半透明不易被發現又不會打擾到工作,當然以上需求都是我朋友提出來的,我只負責開發。

開發

定位於畫面右下角

先在MainWindow.xaml

  1. 設定 Window 屬性讓視窗可以低調一點
  2. 加入一個 Grid 用來放置股票編號跟成交價
  3. 再於右上角加入一個 Image 用以關閉程式
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<Window x:Class="Stock.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="MainWindow" Loaded="Window_Loaded"
Height="40" Width="70" WindowStyle="None" ShowInTaskbar="False"
ResizeMode="NoResize" Topmost="True" AllowsTransparency="True"
Opacity="0.5">
<Grid x:Name="gridStocks">
<Grid.RowDefinitions>
<RowDefinition Height="2*"/>
<RowDefinition Height="3*"/>
</Grid.RowDefinitions>
<Image HorizontalAlignment="Right" Height="14" Cursor="Hand" Margin="0,2,2,0"
VerticalAlignment="Top" Width="14" Source="close.png" MouseUp="Image_MouseUp" />
</Grid>
</Window>

接著在MainWindow.xaml.cs的 Window_Loaded 設定視窗的初始位置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
private void Window_Loaded(object sender, RoutedEventArgs e)
{
Rect workAreaRect = SystemParameters.WorkArea;
this.Left = workAreaRect.Right - (60 + 10) - 2;
this.Top = workAreaRect.Bottom - 40 - 2;

TextBlock txtCode = new TextBlock();
txtCode.Text = "0050";
txtCode.HorizontalAlignment = System.Windows.HorizontalAlignment.Left;
txtCode.TextAlignment = TextAlignment.Center;
txtCode.FontSize = 10;
txtCode.Width = 50;
txtCode.Height = 14;
txtCode.Name = "txtCode0050";
txtCode.Margin = new Thickness(i * 50, 2, 0, 0);
txtCode.SetValue(Grid.RowProperty, 0);

TextBlock txtPrice = new TextBlock();
txtPrice.Text = "Wait..";
txtPrice.HorizontalAlignment = System.Windows.HorizontalAlignment.Left;
txtPrice.TextAlignment = TextAlignment.Center;
txtPrice.FontSize = 16;
txtPrice.Width = 50;
txtPrice.Height = 24;
txtPrice.Name = "txtPrice0050";
txtPrice.Margin = new Thickness(i * 50, 2, 0, 0);
txtPrice.SetValue(Grid.RowProperty, 1);

gridStocks.Children.Add(txtCode);
gridStocks.Children.Add(txtPrice);
}

設定好後執行程式應該在畫面右下角像下圖的結果

即時於Yahoo Stock抓成交值

利用 Html Agility Pack 透過 XPath 去 parse 網站內容,配合 HAP Explorer 找到網站中各節點 XPath 的寫法。

先從管理 NuGet 中找到 「HtmlAgilityPack」 並安裝

建立一個StockHelper.cs並撰寫以下的 GetHtmlContent 取得 HtmlDocument 本體,先引用以下 namespace

1
2
3
using HtmlAgilityPack;
using System.Net;
using System.Text;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public static HtmlDocument GetHtmlContent(string url)
{
HtmlDocument content = new HtmlDocument();
HttpWebRequest request = (HttpWebRequest)WebRequest.Create(url);
WebProxy proxy = new WebProxy();
proxy.UseDefaultCredentials = true;
request.Proxy = proxy;
request.Timeout = 5000;
using (HttpWebResponse reponse = (HttpWebResponse)request.GetResponse())
{
string coder = ((HttpWebResponse)reponse).CharacterSet;
Stream streamReceive = reponse.GetResponseStream();
content.Load(streamReceive, Encoding.GetEncoding(coder));
}
return content;
}

透過 GetQuotedMarketPrice 取得成交價

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public static double GetQuotedMarketPrice(string stockCode)
{

// 使用預設編碼讀入 HTML
HtmlDocument doc = GetHtmlContent("https://tw.stock.yahoo.com/q/q?s=" + stockCode);
// 裝載第一層查詢結果
HtmlDocument hdc = new HtmlDocument();
// XPath 來解讀它
hdc.LoadHtml(doc.DocumentNode.SelectSingleNode("/html[1]/body[1]/center[1]/table[2]/tr[1]/td[1]/table[1]").InnerHtml);
// 取得個股標頭
HtmlNodeCollection htnode = hdc.DocumentNode.SelectNodes("./tr[1]/th");
// 取得個股數值
string[] txt = hdc.DocumentNode.SelectSingleNode("./tr[2]").InnerText.Trim().Split('\n');
doc = null;
hdc = null;
double result = -1;
if (!double.TryParse(txt[2].Trim(),out result))
{
result = -1;
}
return result;
}

接著在MainWindow.xaml.cs中直接呼叫 StockHalper.GetQuotedMarketPrice(“0050”) 就可以取得成交價

1
double quotedMarketPrice = StockHelper.GetQuotedMarketPrice("0050");

並設定「txtPrice0050」的值就可以在畫面右下角看到該股及成交價

設定多組

利過 ini 檔達到多組設定的方式

1
2
3
4
5
[Stock1]
Code=0050

[Stock2]
Code=0056

建立一個IniHelper.cs並撰寫以下的 Code 來協助 ini 檔的讀寫

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
public class IniHelper
{

public static bool GetProfileBool(string iniFile, string section, string key, bool defaultValue)
{
string value = GetProfileString(iniFile, section, key, "").Trim();
bool result = false;
if (value == "1" || string.Compare(value, "true", true) == 0 || string.Compare(value, "on", true) == 0)
{
result = true;
}
else if (value == "0" || string.Compare(value, "false", true) == 0 || string.Compare(value, "off", true) == 0)
{
result = false;
}
else
{
result = defaultValue;
}
return result;
}

/// <summary>
/// 取得Setting.ini的Enum資料
/// </summary>
/// <param name="enumType">要取回Enum的Type</param>
/// <param name="section"></param>
/// <param name="key"></param>
/// <param name="defaultValue"></param>
/// <returns></returns>
public static object GetProfileEnum(Type enumType, string iniFile, string section, string key, string defaultValue)
{
object result = null;
string profileValue = GetProfileString(iniFile, section, key, defaultValue);
try
{
result = Enum.Parse(enumType, profileValue);
}
catch
{
result = Enum.Parse(enumType, defaultValue);
}
return result;
}

/// <summary>
/// 取得INI的Int資料
/// </summary>
/// <param name="iniFile">INI檔的Path</param>
/// <param name="section">INI檔的Section</param>
/// <param name="key">INI檔中Section的Key</param>
/// <param name="defaultValue">取不到值的預設值</param>
/// <returns></returns>
public static int GetProfileInt(string iniFile, string section, string key, int defaultValue)
{
string value = GetProfileString(iniFile, section, key, "");
int result = int.MinValue;
if (!int.TryParse(value, out result))
{
result = defaultValue;
}
return result;
}

/// <summary>
/// 取得INI的String資料
/// </summary>
/// <param name="iniFile">INI檔的Path</param>
/// <param name="section">INI檔的Section</param>
/// <param name="key">INI檔中Section的Key</param>
/// <param name="defaultValue">取不到值的預設值</param>
/// <returns></returns>
public static string GetProfileString(string iniFile, string section, string key, string defaultValue)
{
StringBuilder result = new StringBuilder(512);
GetPrivateProfileString(section, key, defaultValue, result, 512, iniFile);
return (result.ToString());
}

public static bool WriteProfileSection(string iniFile, string section, string value)
{
bool result = false;
if (WritePrivateProfileSection(section, value, iniFile) > 0)
result = true;
return (result);
}

public static bool WriteProfileString(string iniFile, string section, string key, string value)
{
bool result = false;
if (WritePrivateProfileString(section, key, value, iniFile) > 0)
result = true;
return (result);
}

[DllImport("KERNEL32.DLL", EntryPoint = "GetPrivateProfileString")]
private static extern int GetPrivateProfileString(string appName,
string keyName, string defaultString,
StringBuilder returnedString, int size,
string fileName);

[DllImport("KERNEL32.DLL")]
private static extern long WritePrivateProfileSection(string section,
string value,
string fileName);

[DllImport("KERNEL32.DLL")]
private static extern long WritePrivateProfileString(string section,
string key,
string value,
string fileName);
}

建立一個StockItem.cs來專門承接 ini 的各 section 項目

1
2
3
4
5
6
7
8
internal class StockItem
{
public StockItem(string code)
{
Code = code;
}
public string Code { get; set; }
}

並在MainWindow.xaml.cs中撰寫以下方法來取得所有 ini 的內容並回傳一個 StockItem 的 List

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
private List<StockItem> GetAllSections()
{
List<StockItem> stockItems = new List<StockItem>();
List<string> sectionNames = new List<string>();
string fileName = System.IO.Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Setting.ini");
if (!File.Exists(fileName))
{
return stockItems;
}
using (StreamReader sr = new StreamReader(fileName, System.Text.Encoding.Default))
{
while (!sr.EndOfStream)
{
string line = sr.ReadLine();
if (line.StartsWith("[Stock") && line.EndsWith("]"))
{
sectionNames.Add(line.Replace("[", string.Empty).Replace("]", string.Empty));
}
}
}
foreach (string sectionName in sectionNames)
{
string code = IniHelper.GetProfileString(fileName, sectionName, "Code", "0050");
StockItem stockItem = new StockItem(code);
stockItems.Add(stockItem);
}
return stockItems;
}

接著就可以將MainWindow.xaml.cs的 Window_Loaded 改成可以長成多個的版面(stockItems為一個 List 的全域變數,在 MainWindow structure 中透過上面的 GetAllSections 給值)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
private void Window_Loaded(object sender, RoutedEventArgs e)
{
Rect workAreaRect = SystemParameters.WorkArea;
this.Left = workAreaRect.Right - ((60 * stockItems.Count) + 10) - 2;
this.Top = workAreaRect.Bottom - 40 - 2;
this.Width = (60 * stockItems.Count) + 10;

for (int i = 0; i < stockItems.Count; i++)
{
TextBlock txtCode = new TextBlock();
txtCode.Text = stockItems[i].Code;
txtCode.HorizontalAlignment = System.Windows.HorizontalAlignment.Left;
txtCode.TextAlignment = TextAlignment.Center;
txtCode.FontSize = 10;
txtCode.Width = 50;
txtCode.Height = 14;
txtCode.Name = "txtCode" + stockItems[i].Code;
txtCode.Margin = new Thickness(i * 50, 2, 0, 0);
txtCode.SetValue(Grid.RowProperty, 0);

TextBlock txtPrice = new TextBlock();
txtPrice.Text = "Wait..";
txtPrice.HorizontalAlignment = System.Windows.HorizontalAlignment.Left;
txtPrice.TextAlignment = TextAlignment.Center;
txtPrice.FontSize = 16;
txtPrice.Width = 50;
txtPrice.Height = 24;
txtPrice.Name = "txtPrice" + stockItems[i].Code;
txtPrice.Margin = new Thickness(i * 50, 2, 0, 0);
txtPrice.SetValue(Grid.RowProperty, 1);

gridStocks.Children.Add(txtCode);
gridStocks.Children.Add(txtPrice);
}
}

接著 MainWindow structure 中讓程式一執行就可以開始即時的一直取得最新成交值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
public MainWindow()
{
InitializeComponent();

stockItems = GetAllSections();
foreach (StockItem stockItem in stockItems)
{
BackgroundWorker getStock = new BackgroundWorker();
getStock.DoWork += (s, o) =>
{
double openPrice = StockHelper.GetOpenPrice(stockItem.Code);
TextBlock txtPrice = null;
// 停止的 Flag (全域變數)
while (mGetStockStopFlag)
{
try
{
if (txtPrice == null)
{
App.Current.Dispatcher.Invoke((Action)delegate
{
// 取得給成交價的 TextBlock 控制項
txtPrice = FindVisualChildByName<TextBlock>(gridStocks, "txtPrice" + stockItem.Code);
});
System.Threading.Thread.Sleep(1000);
continue;
}
else
{
double quotedMarketPrice = StockHelper.GetQuotedMarketPrice(stockItem.Code);
txtPrice.Text = quotedMarketPrice.ToString();
}
}
catch
{

}
}
};
getStock.RunWorkerAsync();
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/// <summary>
/// Find Control
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="parent">parent control</param>
/// <param name="name">child name</param>
/// <returns></returns>
public T FindVisualChildByName<T>(DependencyObject parent, string name) where T : DependencyObject
{
for (int i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++)
{
var child = VisualTreeHelper.GetChild(parent, i);
string controlName = child.GetValue(Control.NameProperty) as string;
if (controlName == name)
{
return child as T;
}
else
{
T result = FindVisualChildByName<T>(child, name);
if (result != null)
return result;
}
}
return null;
}

最後程式執行的結果如下

Source Code

代碼包含基本功能外,額外包含

  • 警報功能
  • 成交價上色

Download

參考

Html Agility Pack
使用HtmlAgilityPack(1) 擷取網頁上的股票
SystemParameters.WorkArea 屬性
WPF 获取屏幕分辨率(获取最大宽高)