w-dwdata-hub 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.editorconfig +9 -0
- package/.eslintignore +3 -0
- package/.eslintrc.js +55 -0
- package/.jsdoc +25 -0
- package/LICENSE +21 -0
- package/README.md +168 -0
- package/SECURITY.md +5 -0
- package/babel.config.js +16 -0
- package/dist/w-dwdata-hub.umd.js +7 -0
- package/dist/w-dwdata-hub.umd.js.map +1 -0
- package/docs/WDwdataHub.mjs.html +136 -0
- package/docs/decodeEntities.mjs.html +129 -0
- package/docs/extractDivContent.mjs.html +147 -0
- package/docs/fetchAiNewsAggregator.mjs.html +155 -0
- package/docs/fetchAisixiang.mjs.html +1038 -0
- package/docs/fetchCnyes.mjs.html +260 -0
- package/docs/fetchGuancha.mjs.html +1007 -0
- package/docs/fetchHackerNews.mjs.html +189 -0
- package/docs/fetchMoneydj.mjs.html +251 -0
- package/docs/fetchMops.mjs.html +385 -0
- package/docs/fetchRSS.mjs.html +160 -0
- package/docs/fetchStatementdog.mjs.html +213 -0
- package/docs/fetchTaifex.mjs.html +513 -0
- package/docs/fetchTpex3insti.mjs.html +278 -0
- package/docs/fetchTpexMargin.mjs.html +248 -0
- package/docs/fetchTpexStock.mjs.html +199 -0
- package/docs/fetchTwDataHoliday.mjs.html +236 -0
- package/docs/fetchTwseMargin.mjs.html +253 -0
- package/docs/fetchTwseStock.mjs.html +188 -0
- package/docs/fetchTwseT86.mjs.html +208 -0
- package/docs/fetchWithRetry.mjs.html +376 -0
- package/docs/fonts/Montserrat/Montserrat-Bold.eot +0 -0
- package/docs/fonts/Montserrat/Montserrat-Bold.ttf +0 -0
- package/docs/fonts/Montserrat/Montserrat-Bold.woff +0 -0
- package/docs/fonts/Montserrat/Montserrat-Bold.woff2 +0 -0
- package/docs/fonts/Montserrat/Montserrat-Regular.eot +0 -0
- package/docs/fonts/Montserrat/Montserrat-Regular.ttf +0 -0
- package/docs/fonts/Montserrat/Montserrat-Regular.woff +0 -0
- package/docs/fonts/Montserrat/Montserrat-Regular.woff2 +0 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.eot +0 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.svg +978 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.ttf +0 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.woff +0 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.woff2 +0 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.eot +0 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.svg +1049 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.ttf +0 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.woff +0 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.woff2 +0 -0
- package/docs/getOptFetch.mjs.html +185 -0
- package/docs/global.html +22649 -0
- package/docs/htmlToMarkdown.mjs.html +168 -0
- package/docs/index.html +84 -0
- package/docs/isYmd.mjs.html +114 -0
- package/docs/parseIntComma.mjs.html +100 -0
- package/docs/safeFilename.mjs.html +102 -0
- package/docs/scripts/collapse.js +39 -0
- package/docs/scripts/commonNav.js +28 -0
- package/docs/scripts/linenumber.js +25 -0
- package/docs/scripts/nav.js +12 -0
- package/docs/scripts/polyfill.js +4 -0
- package/docs/scripts/prettify/Apache-License-2.0.txt +202 -0
- package/docs/scripts/prettify/lang-css.js +2 -0
- package/docs/scripts/prettify/prettify.js +28 -0
- package/docs/scripts/search.js +99 -0
- package/docs/styles/jsdoc.css +776 -0
- package/docs/styles/prettify.css +80 -0
- package/docs/toDatetimeUTC8.mjs.html +143 -0
- package/docs/toRocDate.mjs.html +107 -0
- package/g.mjs +73 -0
- package/package.json +31 -0
- package/script.txt +17 -0
- package/src/WDwdataHub.mjs +64 -0
- package/src/decodeEntities.mjs +57 -0
- package/src/extractDivContent.mjs +75 -0
- package/src/fetchAiNewsAggregator.mjs +83 -0
- package/src/fetchAisixiang.mjs +966 -0
- package/src/fetchCnyes.mjs +188 -0
- package/src/fetchGuancha.mjs +935 -0
- package/src/fetchHackerNews.mjs +117 -0
- package/src/fetchMoneydj.mjs +179 -0
- package/src/fetchMops.mjs +313 -0
- package/src/fetchRSS.mjs +88 -0
- package/src/fetchStatementdog.mjs +141 -0
- package/src/fetchTaifex.mjs +441 -0
- package/src/fetchTpex3insti.mjs +206 -0
- package/src/fetchTpexMargin.mjs +176 -0
- package/src/fetchTpexStock.mjs +127 -0
- package/src/fetchTwDataHoliday.mjs +164 -0
- package/src/fetchTwseMargin.mjs +181 -0
- package/src/fetchTwseStock.mjs +116 -0
- package/src/fetchTwseT86.mjs +136 -0
- package/src/fetchWithRetry.mjs +304 -0
- package/src/getOptFetch.mjs +113 -0
- package/src/htmlToMarkdown.mjs +96 -0
- package/src/isYmd.mjs +42 -0
- package/src/parseIntComma.mjs +28 -0
- package/src/safeFilename.mjs +30 -0
- package/src/toDatetimeUTC8.mjs +71 -0
- package/src/toRocDate.mjs +35 -0
- package/test/WDwdataHub.test.mjs +63 -0
- package/test/decodeEntities.test.mjs +42 -0
- package/test/extractDivContent.test.mjs +48 -0
- package/test/fetchAiNewsAggregator.test.mjs +79 -0
- package/test/fetchAisixiang.test.mjs +225 -0
- package/test/fetchCnyes.test.mjs +61 -0
- package/test/fetchGuancha.test.mjs +190 -0
- package/test/fetchHackerNews.test.mjs +78 -0
- package/test/fetchMoneydj.test.mjs +82 -0
- package/test/fetchMops.test.mjs +106 -0
- package/test/fetchRSS.test.mjs +85 -0
- package/test/fetchStatementdog.test.mjs +69 -0
- package/test/fetchTaifex.test.mjs +99 -0
- package/test/fetchTpex3insti.test.mjs +90 -0
- package/test/fetchTpexMargin.test.mjs +94 -0
- package/test/fetchTpexStock.test.mjs +68 -0
- package/test/fetchTwDataHoliday.test.mjs +100 -0
- package/test/fetchTwseMargin.test.mjs +81 -0
- package/test/fetchTwseStock.test.mjs +80 -0
- package/test/fetchTwseT86.test.mjs +76 -0
- package/test/fetchWithRetry.test.mjs +174 -0
- package/test/htmlToMarkdown.test.mjs +71 -0
- package/test/isYmd.test.mjs +56 -0
- package/test/parseIntComma.test.mjs +38 -0
- package/test/safeFilename.test.mjs +35 -0
- package/test/toDatetimeUTC8.test.mjs +65 -0
- package/test/toRocDate.test.mjs +38 -0
- package/test/tools/encodeBig5.mjs +80 -0
- package/test/tools/fixtures.mjs +539 -0
- package/test/tools/hasChrome.mjs +39 -0
- package/test/tools/serverForTest.mjs +397 -0
- package/toolg/addVersion.mjs +4 -0
- package/toolg/cleanFolder.mjs +5 -0
- package/toolg/gDistRollup.mjs +49 -0
- package/toolg/modifyReadme.mjs +4 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import get from 'lodash-es/get.js'
|
|
2
|
+
import isestr from 'wsemi/src/isestr.mjs'
|
|
3
|
+
import isearr from 'wsemi/src/isearr.mjs'
|
|
4
|
+
import fetchWithRetry from './fetchWithRetry.mjs'
|
|
5
|
+
import getOptFetch from './getOptFetch.mjs'
|
|
6
|
+
import isYmd from './isYmd.mjs'
|
|
7
|
+
import parseIntComma from './parseIntComma.mjs'
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
//證交所網站根網址
|
|
11
|
+
let BASE_URL = 'https://www.twse.com.tw'
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
//本函數之預設抓取設定
|
|
15
|
+
let DFLT = {
|
|
16
|
+
timeout: 15000,
|
|
17
|
+
maxRetries: 10,
|
|
18
|
+
baseDelayMs: 5000,
|
|
19
|
+
maxDelayMs: 30000,
|
|
20
|
+
label: 'fetch-twse-margin',
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
//融資融券彙總表之文件化欄數, 下方固定index解析讀至row[15], 故欄數不足即視為API改版
|
|
25
|
+
let MIN_FIELDS = 16
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 抓取證交所(TWSE)上市融資融券
|
|
30
|
+
*
|
|
31
|
+
* 抓取指定日之上市個股融資融券餘額並轉為結構化資料, 可指定股票代號陣列過濾;
|
|
32
|
+
* 選表先以標題「融資融券彙總」精確比對, 失敗則退而以欄位同時含「融資」與「融券」比對, 提升schema改版容忍度
|
|
33
|
+
*
|
|
34
|
+
* @param {String} dateStr 輸入日期YYYYMMDD字串
|
|
35
|
+
* @param {Array} [stockCodes] 輸入股票代號字串陣列,省略或空陣列表示全市場
|
|
36
|
+
* @param {Object} [opt={}] 輸入設定物件,預設{}
|
|
37
|
+
* @param {String} [opt.baseUrl='https://www.twse.com.tw'] 輸入證交所根網址字串,供測試或改指向鏡像時覆寫
|
|
38
|
+
* @param {Integer} [opt.timeout=15000] 輸入單次請求逾時毫秒整數,預設15000
|
|
39
|
+
* @param {Integer} [opt.maxRetries=10] 輸入最大重試次數整數,含初始共執行maxRetries+1次,預設10
|
|
40
|
+
* @param {Integer} [opt.baseDelayMs=5000] 輸入重試之線性退避基礎毫秒整數,預設5000
|
|
41
|
+
* @param {Integer} [opt.maxDelayMs=30000] 輸入重試之線性退避上限毫秒整數,預設30000
|
|
42
|
+
* @param {Boolean} [opt.showLog=true] 輸入是否顯示過程訊息布林值,預設true
|
|
43
|
+
* @returns {Promise} 回傳Promise,resolve回傳結果物件{source,date,count,data},日期無效、API錯誤或指定個股查無資料時reject回傳錯誤物件
|
|
44
|
+
* @example
|
|
45
|
+
*
|
|
46
|
+
* import fetchTwseMargin from './src/fetchTwseMargin.mjs'
|
|
47
|
+
*
|
|
48
|
+
* let test = async () => {
|
|
49
|
+
*
|
|
50
|
+
* let r = await fetchTwseMargin('20260807', ['2330'])
|
|
51
|
+
* console.log(r.source, r.count, r.data[0].code, r.data[0].marginBalance)
|
|
52
|
+
* // => 'twse_margin' 1 '2330' 12345
|
|
53
|
+
*
|
|
54
|
+
* }
|
|
55
|
+
* await test()
|
|
56
|
+
* .catch((err) => {
|
|
57
|
+
* console.log(err)
|
|
58
|
+
* })
|
|
59
|
+
*
|
|
60
|
+
*/
|
|
61
|
+
async function fetchTwseMargin(dateStr, stockCodes, opt = {}) {
|
|
62
|
+
|
|
63
|
+
//check
|
|
64
|
+
if (!isestr(dateStr) || !/^\d{8}$/.test(dateStr)) {
|
|
65
|
+
throw new Error(`日期參數無效:格式須為 YYYYMMDD (收到 "${dateStr}")`)
|
|
66
|
+
}
|
|
67
|
+
if (!isYmd(dateStr)) {
|
|
68
|
+
throw new Error(`日期參數無效:不合法的日期 (${dateStr})`)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
//baseUrl
|
|
72
|
+
let baseUrl = get(opt, 'baseUrl')
|
|
73
|
+
if (!isestr(baseUrl)) {
|
|
74
|
+
baseUrl = BASE_URL
|
|
75
|
+
}
|
|
76
|
+
baseUrl = baseUrl.replace(/\/+$/, '')
|
|
77
|
+
|
|
78
|
+
//optFetch
|
|
79
|
+
let optFetch = getOptFetch(opt, DFLT)
|
|
80
|
+
let showLog = optFetch.showLog
|
|
81
|
+
|
|
82
|
+
//targetCodes
|
|
83
|
+
let targetCodes = isearr(stockCodes) ? stockCodes.filter(Boolean) : []
|
|
84
|
+
|
|
85
|
+
//url
|
|
86
|
+
let url = `${baseUrl}/rwd/zh/marginTrading/MI_MARGN?date=${dateStr}&selectType=ALL&response=json`
|
|
87
|
+
|
|
88
|
+
if (showLog) {
|
|
89
|
+
console.log(`Fetching TWSE margin data: ${dateStr}`)
|
|
90
|
+
console.log(`Target: ${targetCodes.length > 0 ? targetCodes.join(', ') : 'All Market'}`)
|
|
91
|
+
console.log(`URL: ${url}`)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
//data
|
|
95
|
+
let data = await fetchWithRetry(url, optFetch)
|
|
96
|
+
|
|
97
|
+
//check
|
|
98
|
+
if (get(data, 'stat') !== 'OK') {
|
|
99
|
+
throw new Error(`TWSE MI_MARGN API returned: ${get(data, 'stat')}`)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
//detailTable, 先以title精確比對, 失敗則退而以fields雙重比對
|
|
103
|
+
//fields: ["代號","名稱","買進","賣出","現金償還","前日餘額","今日餘額","次一營業日限額",
|
|
104
|
+
// "買進","賣出","現券償還","前日餘額","今日餘額","次一營業日限額","資券互抵","註記"]
|
|
105
|
+
//前8欄為融資, idx 8-13為融券, idx 14=資券互抵, idx 15=註記
|
|
106
|
+
let tables = Array.isArray(data.tables) ? data.tables : []
|
|
107
|
+
let detailTable = tables.find((t) => get(t, 'data.length', 0) > 0 && get(t, 'title', '').includes('融資融券彙總'))
|
|
108
|
+
if (!detailTable) {
|
|
109
|
+
detailTable = tables.find((t) => {
|
|
110
|
+
return get(t, 'data.length', 0) > 0 &&
|
|
111
|
+
Array.isArray(t.fields) &&
|
|
112
|
+
t.fields.some((f) => String(f).includes('融資')) &&
|
|
113
|
+
t.fields.some((f) => String(f).includes('融券'))
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
if (!detailTable || !isearr(get(detailTable, 'data'))) {
|
|
117
|
+
throw new Error('TWSE MI_MARGN: 找不到融資融券彙總資料表')
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
//shape sanity, 固定index解析讀至row[15], 驗證欄數避免fallback抓到結構不同的表後靜默解析錯欄
|
|
121
|
+
if (!Array.isArray(detailTable.fields) || detailTable.fields.length < MIN_FIELDS) {
|
|
122
|
+
throw new Error(`TWSE 融資融券資料欄數不符預期(fields ${get(detailTable, 'fields.length', 'N/A')} 欄,應 >= ${MIN_FIELDS}),可能 API 格式變更`)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
//rows
|
|
126
|
+
let rows = detailTable.data
|
|
127
|
+
if (targetCodes.length > 0) {
|
|
128
|
+
rows = rows.filter((row) => targetCodes.includes(String(row[0] || '').trim()))
|
|
129
|
+
if (rows.length === 0) {
|
|
130
|
+
throw new Error(`指定個股 ${targetCodes.join(',')} 不在上市融資融券資料中(可能為上櫃股或代碼有誤)`)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
//parsedData
|
|
135
|
+
let parsedData = rows.map((row) => {
|
|
136
|
+
|
|
137
|
+
let marginBuy = parseIntComma(row[2])
|
|
138
|
+
let marginSell = parseIntComma(row[3])
|
|
139
|
+
let marginBalance = parseIntComma(row[6])
|
|
140
|
+
let marginPrev = parseIntComma(row[5])
|
|
141
|
+
let shortBuy = parseIntComma(row[8]) //融券買進(回補)
|
|
142
|
+
let shortSell = parseIntComma(row[9]) //融券賣出(新增放空)
|
|
143
|
+
let shortBalance = parseIntComma(row[12])
|
|
144
|
+
let shortPrev = parseIntComma(row[11])
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
code: String(row[0] || '').trim(),
|
|
148
|
+
name: String(row[1] || '').trim(),
|
|
149
|
+
marginBuy,
|
|
150
|
+
marginSell,
|
|
151
|
+
marginCashRepay: parseIntComma(row[4]),
|
|
152
|
+
marginPrevBalance: marginPrev,
|
|
153
|
+
marginBalance,
|
|
154
|
+
marginChange: marginBalance - marginPrev,
|
|
155
|
+
marginLimit: parseIntComma(row[7]),
|
|
156
|
+
shortSell,
|
|
157
|
+
shortBuy,
|
|
158
|
+
shortCashRepay: parseIntComma(row[10]),
|
|
159
|
+
shortPrevBalance: shortPrev,
|
|
160
|
+
shortBalance,
|
|
161
|
+
shortChange: shortBalance - shortPrev,
|
|
162
|
+
shortLimit: parseIntComma(row[13]),
|
|
163
|
+
offset: parseIntComma(row[14]),
|
|
164
|
+
note: String(row[15] || '').trim(),
|
|
165
|
+
}
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
if (showLog) {
|
|
169
|
+
console.log(`Fetched ${parsedData.length} records.`)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
source: 'twse_margin',
|
|
174
|
+
date: dateStr,
|
|
175
|
+
count: parsedData.length,
|
|
176
|
+
data: parsedData,
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
export default fetchTwseMargin
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import get from 'lodash-es/get.js'
|
|
2
|
+
import isestr from 'wsemi/src/isestr.mjs'
|
|
3
|
+
import fetchWithRetry from './fetchWithRetry.mjs'
|
|
4
|
+
import getOptFetch from './getOptFetch.mjs'
|
|
5
|
+
import isYmd from './isYmd.mjs'
|
|
6
|
+
import toRocDate from './toRocDate.mjs'
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
//證交所網站根網址
|
|
10
|
+
let BASE_URL = 'https://www.twse.com.tw'
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
//本函數之預設抓取設定
|
|
14
|
+
let DFLT = {
|
|
15
|
+
timeout: 10000,
|
|
16
|
+
maxRetries: 10,
|
|
17
|
+
baseDelayMs: 5000,
|
|
18
|
+
maxDelayMs: 30000,
|
|
19
|
+
label: 'fetch-twse-stock',
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 抓取證交所(TWSE)上市股價
|
|
25
|
+
*
|
|
26
|
+
* 指定stockCode時走STOCK_DAY取個股日成交資訊(該API回傳整月資料, 本函數過濾為指定日單筆);
|
|
27
|
+
* 未指定或指定為'all'時走MI_INDEX取全市場收盤資料
|
|
28
|
+
*
|
|
29
|
+
* @param {String} dateStr 輸入日期YYYYMMDD字串
|
|
30
|
+
* @param {String} [stockCode] 輸入股票代號字串,例如'2330',省略或給'all'表示全市場
|
|
31
|
+
* @param {Object} [opt={}] 輸入設定物件,預設{}
|
|
32
|
+
* @param {String} [opt.baseUrl='https://www.twse.com.tw'] 輸入證交所根網址字串,供測試或改指向鏡像時覆寫
|
|
33
|
+
* @param {Integer} [opt.timeout=10000] 輸入單次請求逾時毫秒整數,預設10000
|
|
34
|
+
* @param {Integer} [opt.maxRetries=10] 輸入最大重試次數整數,含初始共執行maxRetries+1次,預設10
|
|
35
|
+
* @param {Integer} [opt.baseDelayMs=5000] 輸入重試之線性退避基礎毫秒整數,預設5000
|
|
36
|
+
* @param {Integer} [opt.maxDelayMs=30000] 輸入重試之線性退避上限毫秒整數,預設30000
|
|
37
|
+
* @param {Boolean} [opt.showLog=true] 輸入是否顯示過程訊息布林值,預設true
|
|
38
|
+
* @returns {Promise} 回傳Promise,resolve回傳證交所API原始資料物件,內含fields與data等欄位,日期無效、API回傳非OK或指定日無交易資料時reject回傳錯誤物件
|
|
39
|
+
* @example
|
|
40
|
+
*
|
|
41
|
+
* import fetchTwseStock from './src/fetchTwseStock.mjs'
|
|
42
|
+
*
|
|
43
|
+
* let test = async () => {
|
|
44
|
+
*
|
|
45
|
+
* let r = await fetchTwseStock('20260807', '2330')
|
|
46
|
+
* console.log(r.stat, r.data.length)
|
|
47
|
+
* // => 'OK' 1
|
|
48
|
+
*
|
|
49
|
+
* }
|
|
50
|
+
* await test()
|
|
51
|
+
* .catch((err) => {
|
|
52
|
+
* console.log(err)
|
|
53
|
+
* })
|
|
54
|
+
*
|
|
55
|
+
*/
|
|
56
|
+
async function fetchTwseStock(dateStr, stockCode, opt = {}) {
|
|
57
|
+
|
|
58
|
+
//check, 函數入口驗日期, 令程式化呼叫不繞過CLI之檢核
|
|
59
|
+
if (!isYmd(dateStr)) {
|
|
60
|
+
throw new Error(`dateStr 須為合法之 YYYYMMDD 字串,得到: ${dateStr}`)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
//baseUrl
|
|
64
|
+
let baseUrl = get(opt, 'baseUrl')
|
|
65
|
+
if (!isestr(baseUrl)) {
|
|
66
|
+
baseUrl = BASE_URL
|
|
67
|
+
}
|
|
68
|
+
baseUrl = baseUrl.replace(/\/+$/, '')
|
|
69
|
+
|
|
70
|
+
//optFetch
|
|
71
|
+
let optFetch = getOptFetch(opt, DFLT)
|
|
72
|
+
let showLog = optFetch.showLog
|
|
73
|
+
|
|
74
|
+
//isSingleStock
|
|
75
|
+
let isSingleStock = isestr(stockCode) && stockCode.toLowerCase() !== 'all'
|
|
76
|
+
let stockNo = isSingleStock ? stockCode : 'ALLBUT0999'
|
|
77
|
+
|
|
78
|
+
//url
|
|
79
|
+
let url = ''
|
|
80
|
+
if (isSingleStock) {
|
|
81
|
+
url = `${baseUrl}/exchangeReport/STOCK_DAY?response=json&date=${dateStr}&stockNo=${stockNo}`
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
url = `${baseUrl}/exchangeReport/MI_INDEX?response=json&date=${dateStr}&type=ALLBUT0999`
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (showLog) {
|
|
88
|
+
console.log(`Fetching TWSE data: ${dateStr}, Stock: ${stockNo}`)
|
|
89
|
+
console.log(`URL: ${url}`)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
//data
|
|
93
|
+
let data = await fetchWithRetry(url, optFetch)
|
|
94
|
+
|
|
95
|
+
//check
|
|
96
|
+
if (get(data, 'stat') !== 'OK') {
|
|
97
|
+
throw new Error(`TWSE API returned: ${get(data, 'stat')}`)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
//STOCK_DAY回傳整月資料, 呼叫者既已指定單一日期則過濾為該日單筆
|
|
101
|
+
//保留原欄位結構(fields/data/title等), 僅替換data為篩選後陣列
|
|
102
|
+
if (isSingleStock && Array.isArray(data.data)) {
|
|
103
|
+
let rocDate = toRocDate(dateStr)
|
|
104
|
+
let filtered = data.data.filter((row) => row[0] === rocDate)
|
|
105
|
+
data.data = filtered
|
|
106
|
+
if (filtered.length === 0) {
|
|
107
|
+
//整月有資料但指定日無 → 當日停盤、假日或未開市
|
|
108
|
+
throw new Error(`TWSE 個股 ${stockCode} 於 ${dateStr} 無交易資料(可能為假日或停盤)`)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return data
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
export default fetchTwseStock
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import get from 'lodash-es/get.js'
|
|
2
|
+
import isestr from 'wsemi/src/isestr.mjs'
|
|
3
|
+
import isearr from 'wsemi/src/isearr.mjs'
|
|
4
|
+
import fetchWithRetry from './fetchWithRetry.mjs'
|
|
5
|
+
import getOptFetch from './getOptFetch.mjs'
|
|
6
|
+
import isYmd from './isYmd.mjs'
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
//證交所網站根網址
|
|
10
|
+
let BASE_URL = 'https://www.twse.com.tw'
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
//本函數之預設抓取設定
|
|
14
|
+
let DFLT = {
|
|
15
|
+
timeout: 15000,
|
|
16
|
+
maxRetries: 10,
|
|
17
|
+
baseDelayMs: 5000,
|
|
18
|
+
maxDelayMs: 30000,
|
|
19
|
+
label: 'fetch-twse-t86',
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 抓取證交所(TWSE)上市三大法人買賣超
|
|
25
|
+
*
|
|
26
|
+
* 抓取指定日之上市三大法人買賣超(T86), 以API回傳之fields為key轉為物件陣列, 可指定股票代號陣列過濾
|
|
27
|
+
*
|
|
28
|
+
* @param {String} dateStr 輸入日期YYYYMMDD字串
|
|
29
|
+
* @param {Array} [stockCodes] 輸入股票代號字串陣列,省略或空陣列表示全市場
|
|
30
|
+
* @param {Object} [opt={}] 輸入設定物件,預設{}
|
|
31
|
+
* @param {String} [opt.baseUrl='https://www.twse.com.tw'] 輸入證交所根網址字串,供測試或改指向鏡像時覆寫
|
|
32
|
+
* @param {Integer} [opt.timeout=15000] 輸入單次請求逾時毫秒整數,預設15000
|
|
33
|
+
* @param {Integer} [opt.maxRetries=10] 輸入最大重試次數整數,含初始共執行maxRetries+1次,預設10
|
|
34
|
+
* @param {Integer} [opt.baseDelayMs=5000] 輸入重試之線性退避基礎毫秒整數,預設5000
|
|
35
|
+
* @param {Integer} [opt.maxDelayMs=30000] 輸入重試之線性退避上限毫秒整數,預設30000
|
|
36
|
+
* @param {Boolean} [opt.showLog=true] 輸入是否顯示過程訊息布林值,預設true
|
|
37
|
+
* @returns {Promise} 回傳Promise,resolve回傳結果物件{source,date,data},日期無效或API錯誤時reject回傳錯誤物件
|
|
38
|
+
* @example
|
|
39
|
+
*
|
|
40
|
+
* import fetchTwseT86 from './src/fetchTwseT86.mjs'
|
|
41
|
+
*
|
|
42
|
+
* let test = async () => {
|
|
43
|
+
*
|
|
44
|
+
* let r = await fetchTwseT86('20260807', ['2330'])
|
|
45
|
+
* console.log(r.source, r.data.length, r.data[0]['證券代號'])
|
|
46
|
+
* // => 'twse' 1 '2330'
|
|
47
|
+
*
|
|
48
|
+
* }
|
|
49
|
+
* await test()
|
|
50
|
+
* .catch((err) => {
|
|
51
|
+
* console.log(err)
|
|
52
|
+
* })
|
|
53
|
+
*
|
|
54
|
+
*/
|
|
55
|
+
async function fetchTwseT86(dateStr, stockCodes, opt = {}) {
|
|
56
|
+
|
|
57
|
+
//check
|
|
58
|
+
if (!isestr(dateStr) || !/^\d{8}$/.test(dateStr)) {
|
|
59
|
+
throw new Error(`dateStr must be YYYYMMDD, got: ${dateStr}`)
|
|
60
|
+
}
|
|
61
|
+
if (!isYmd(dateStr)) {
|
|
62
|
+
//合法性驗證, 例如20260230雖然符合8碼但日期不存在
|
|
63
|
+
throw new Error(`dateStr 不是合法日期: ${dateStr}`)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
//baseUrl
|
|
67
|
+
let baseUrl = get(opt, 'baseUrl')
|
|
68
|
+
if (!isestr(baseUrl)) {
|
|
69
|
+
baseUrl = BASE_URL
|
|
70
|
+
}
|
|
71
|
+
baseUrl = baseUrl.replace(/\/+$/, '')
|
|
72
|
+
|
|
73
|
+
//optFetch
|
|
74
|
+
let optFetch = getOptFetch(opt, DFLT)
|
|
75
|
+
let showLog = optFetch.showLog
|
|
76
|
+
|
|
77
|
+
//targetCodes
|
|
78
|
+
let targetCodes = isearr(stockCodes) ? stockCodes : []
|
|
79
|
+
|
|
80
|
+
//url
|
|
81
|
+
let url = `${baseUrl}/rwd/zh/fund/T86?response=json&date=${dateStr}&selectType=ALL`
|
|
82
|
+
|
|
83
|
+
if (showLog) {
|
|
84
|
+
console.log(`Fetching from: ${url}`)
|
|
85
|
+
console.log(`Target: ${targetCodes.length === 0 ? 'All Market' : targetCodes.join(', ')}`)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
//data
|
|
89
|
+
let data = await fetchWithRetry(url, optFetch)
|
|
90
|
+
|
|
91
|
+
//check
|
|
92
|
+
if (get(data, 'stat') !== 'OK') {
|
|
93
|
+
throw new Error(`TWSE T86 API returned: ${get(data, 'stat')}`)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
//fields, rawData
|
|
97
|
+
let fields = get(data, 'fields')
|
|
98
|
+
let rawData = get(data, 'data')
|
|
99
|
+
if (!Array.isArray(rawData)) {
|
|
100
|
+
throw new Error('TWSE T86: data not found in response.')
|
|
101
|
+
}
|
|
102
|
+
if (!Array.isArray(fields)) {
|
|
103
|
+
throw new Error('TWSE T86: fields not found in response.')
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
//parsedData, 以API回傳之fields為key
|
|
107
|
+
let parsedData = rawData.map((row) => {
|
|
108
|
+
let obj = {}
|
|
109
|
+
fields.forEach((field, index) => {
|
|
110
|
+
let value = row[index]
|
|
111
|
+
if (typeof value === 'string') {
|
|
112
|
+
value = value.trim()
|
|
113
|
+
}
|
|
114
|
+
obj[field] = value
|
|
115
|
+
})
|
|
116
|
+
return obj
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
//過濾指定個股
|
|
120
|
+
if (targetCodes.length > 0) {
|
|
121
|
+
let codeField = fields.find((f) => String(f).includes('證券代號'))
|
|
122
|
+
if (!codeField) {
|
|
123
|
+
throw new Error('無法篩選個股:API 回應中找不到證券代號欄位')
|
|
124
|
+
}
|
|
125
|
+
parsedData = parsedData.filter((item) => targetCodes.includes(item[codeField]))
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (showLog) {
|
|
129
|
+
console.log(`Fetched ${parsedData.length} records.`)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return { source: 'twse', date: dateStr, data: parsedData }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
export default fetchTwseT86
|