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.
Files changed (135) hide show
  1. package/.editorconfig +9 -0
  2. package/.eslintignore +3 -0
  3. package/.eslintrc.js +55 -0
  4. package/.jsdoc +25 -0
  5. package/LICENSE +21 -0
  6. package/README.md +168 -0
  7. package/SECURITY.md +5 -0
  8. package/babel.config.js +16 -0
  9. package/dist/w-dwdata-hub.umd.js +7 -0
  10. package/dist/w-dwdata-hub.umd.js.map +1 -0
  11. package/docs/WDwdataHub.mjs.html +136 -0
  12. package/docs/decodeEntities.mjs.html +129 -0
  13. package/docs/extractDivContent.mjs.html +147 -0
  14. package/docs/fetchAiNewsAggregator.mjs.html +155 -0
  15. package/docs/fetchAisixiang.mjs.html +1038 -0
  16. package/docs/fetchCnyes.mjs.html +260 -0
  17. package/docs/fetchGuancha.mjs.html +1007 -0
  18. package/docs/fetchHackerNews.mjs.html +189 -0
  19. package/docs/fetchMoneydj.mjs.html +251 -0
  20. package/docs/fetchMops.mjs.html +385 -0
  21. package/docs/fetchRSS.mjs.html +160 -0
  22. package/docs/fetchStatementdog.mjs.html +213 -0
  23. package/docs/fetchTaifex.mjs.html +513 -0
  24. package/docs/fetchTpex3insti.mjs.html +278 -0
  25. package/docs/fetchTpexMargin.mjs.html +248 -0
  26. package/docs/fetchTpexStock.mjs.html +199 -0
  27. package/docs/fetchTwDataHoliday.mjs.html +236 -0
  28. package/docs/fetchTwseMargin.mjs.html +253 -0
  29. package/docs/fetchTwseStock.mjs.html +188 -0
  30. package/docs/fetchTwseT86.mjs.html +208 -0
  31. package/docs/fetchWithRetry.mjs.html +376 -0
  32. package/docs/fonts/Montserrat/Montserrat-Bold.eot +0 -0
  33. package/docs/fonts/Montserrat/Montserrat-Bold.ttf +0 -0
  34. package/docs/fonts/Montserrat/Montserrat-Bold.woff +0 -0
  35. package/docs/fonts/Montserrat/Montserrat-Bold.woff2 +0 -0
  36. package/docs/fonts/Montserrat/Montserrat-Regular.eot +0 -0
  37. package/docs/fonts/Montserrat/Montserrat-Regular.ttf +0 -0
  38. package/docs/fonts/Montserrat/Montserrat-Regular.woff +0 -0
  39. package/docs/fonts/Montserrat/Montserrat-Regular.woff2 +0 -0
  40. package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.eot +0 -0
  41. package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.svg +978 -0
  42. package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.ttf +0 -0
  43. package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.woff +0 -0
  44. package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.woff2 +0 -0
  45. package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.eot +0 -0
  46. package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.svg +1049 -0
  47. package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.ttf +0 -0
  48. package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.woff +0 -0
  49. package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.woff2 +0 -0
  50. package/docs/getOptFetch.mjs.html +185 -0
  51. package/docs/global.html +22649 -0
  52. package/docs/htmlToMarkdown.mjs.html +168 -0
  53. package/docs/index.html +84 -0
  54. package/docs/isYmd.mjs.html +114 -0
  55. package/docs/parseIntComma.mjs.html +100 -0
  56. package/docs/safeFilename.mjs.html +102 -0
  57. package/docs/scripts/collapse.js +39 -0
  58. package/docs/scripts/commonNav.js +28 -0
  59. package/docs/scripts/linenumber.js +25 -0
  60. package/docs/scripts/nav.js +12 -0
  61. package/docs/scripts/polyfill.js +4 -0
  62. package/docs/scripts/prettify/Apache-License-2.0.txt +202 -0
  63. package/docs/scripts/prettify/lang-css.js +2 -0
  64. package/docs/scripts/prettify/prettify.js +28 -0
  65. package/docs/scripts/search.js +99 -0
  66. package/docs/styles/jsdoc.css +776 -0
  67. package/docs/styles/prettify.css +80 -0
  68. package/docs/toDatetimeUTC8.mjs.html +143 -0
  69. package/docs/toRocDate.mjs.html +107 -0
  70. package/g.mjs +73 -0
  71. package/package.json +31 -0
  72. package/script.txt +17 -0
  73. package/src/WDwdataHub.mjs +64 -0
  74. package/src/decodeEntities.mjs +57 -0
  75. package/src/extractDivContent.mjs +75 -0
  76. package/src/fetchAiNewsAggregator.mjs +83 -0
  77. package/src/fetchAisixiang.mjs +966 -0
  78. package/src/fetchCnyes.mjs +188 -0
  79. package/src/fetchGuancha.mjs +935 -0
  80. package/src/fetchHackerNews.mjs +117 -0
  81. package/src/fetchMoneydj.mjs +179 -0
  82. package/src/fetchMops.mjs +313 -0
  83. package/src/fetchRSS.mjs +88 -0
  84. package/src/fetchStatementdog.mjs +141 -0
  85. package/src/fetchTaifex.mjs +441 -0
  86. package/src/fetchTpex3insti.mjs +206 -0
  87. package/src/fetchTpexMargin.mjs +176 -0
  88. package/src/fetchTpexStock.mjs +127 -0
  89. package/src/fetchTwDataHoliday.mjs +164 -0
  90. package/src/fetchTwseMargin.mjs +181 -0
  91. package/src/fetchTwseStock.mjs +116 -0
  92. package/src/fetchTwseT86.mjs +136 -0
  93. package/src/fetchWithRetry.mjs +304 -0
  94. package/src/getOptFetch.mjs +113 -0
  95. package/src/htmlToMarkdown.mjs +96 -0
  96. package/src/isYmd.mjs +42 -0
  97. package/src/parseIntComma.mjs +28 -0
  98. package/src/safeFilename.mjs +30 -0
  99. package/src/toDatetimeUTC8.mjs +71 -0
  100. package/src/toRocDate.mjs +35 -0
  101. package/test/WDwdataHub.test.mjs +63 -0
  102. package/test/decodeEntities.test.mjs +42 -0
  103. package/test/extractDivContent.test.mjs +48 -0
  104. package/test/fetchAiNewsAggregator.test.mjs +79 -0
  105. package/test/fetchAisixiang.test.mjs +225 -0
  106. package/test/fetchCnyes.test.mjs +61 -0
  107. package/test/fetchGuancha.test.mjs +190 -0
  108. package/test/fetchHackerNews.test.mjs +78 -0
  109. package/test/fetchMoneydj.test.mjs +82 -0
  110. package/test/fetchMops.test.mjs +106 -0
  111. package/test/fetchRSS.test.mjs +85 -0
  112. package/test/fetchStatementdog.test.mjs +69 -0
  113. package/test/fetchTaifex.test.mjs +99 -0
  114. package/test/fetchTpex3insti.test.mjs +90 -0
  115. package/test/fetchTpexMargin.test.mjs +94 -0
  116. package/test/fetchTpexStock.test.mjs +68 -0
  117. package/test/fetchTwDataHoliday.test.mjs +100 -0
  118. package/test/fetchTwseMargin.test.mjs +81 -0
  119. package/test/fetchTwseStock.test.mjs +80 -0
  120. package/test/fetchTwseT86.test.mjs +76 -0
  121. package/test/fetchWithRetry.test.mjs +174 -0
  122. package/test/htmlToMarkdown.test.mjs +71 -0
  123. package/test/isYmd.test.mjs +56 -0
  124. package/test/parseIntComma.test.mjs +38 -0
  125. package/test/safeFilename.test.mjs +35 -0
  126. package/test/toDatetimeUTC8.test.mjs +65 -0
  127. package/test/toRocDate.test.mjs +38 -0
  128. package/test/tools/encodeBig5.mjs +80 -0
  129. package/test/tools/fixtures.mjs +539 -0
  130. package/test/tools/hasChrome.mjs +39 -0
  131. package/test/tools/serverForTest.mjs +397 -0
  132. package/toolg/addVersion.mjs +4 -0
  133. package/toolg/cleanFolder.mjs +5 -0
  134. package/toolg/gDistRollup.mjs +49 -0
  135. package/toolg/modifyReadme.mjs +4 -0
@@ -0,0 +1,206 @@
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 toRocDate from './toRocDate.mjs'
8
+
9
+
10
+ //櫃買中心網站根網址
11
+ let BASE_URL = 'https://www.tpex.org.tw'
12
+
13
+
14
+ //本函數之預設抓取設定
15
+ let DFLT = {
16
+ timeout: 15000,
17
+ maxRetries: 10,
18
+ baseDelayMs: 5000,
19
+ maxDelayMs: 30000,
20
+ label: 'fetch-tpex-3insti',
21
+ }
22
+
23
+
24
+ //TPEX 3insti_hedge端點之fields為「裸欄名」——買進股數/賣出股數/買賣超股數各重複7次
25
+ //(外資不含自營 / 外資自營 / 外資合計 / 投信 / 自營自行 / 自營避險 / 自營合計), 群組標籤不在JSON內
26
+ //故唯一可靠解法是以「固定位置」對應語意欄名(不可用欄名當key, 否則同名後者覆蓋前者、7組塌成1組)
27
+ //明細欄名對齊TWSE T86(外陸資.../投信.../自營商...), 令下游TWSE與TPEX共用同一套key
28
+ //末欄保留「三大法人買賣超股數合計」(下游有寫死讀此名者, 勿改)
29
+ let TPEX_FIELD_MAP = [
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
+ * 驗證TPEX回傳fields是否仍為預期結構
59
+ *
60
+ * 結構防呆(fail-loud), 驗證仍為「24欄、買進/賣出/買賣超循環×7」;
61
+ * 不符即拋錯(屬非暫時性錯誤故不重試), 避免API改版後靜默把外資的數字貼到投信頭上
62
+ *
63
+ * @param {Array} fields 輸入API回傳之欄名字串陣列
64
+ */
65
+ function assertTpexFieldShape(fields) {
66
+
67
+ if (fields.length !== TPEX_FIELD_MAP.length) {
68
+ throw new Error(`TPEX 3insti: 欄數異常,預期 ${TPEX_FIELD_MAP.length} 得 ${fields.length}(疑似 API 改版,請重新校準 TPEX_FIELD_MAP)`)
69
+ }
70
+ if (!String(fields[0]).includes('代號') || !String(fields[1]).includes('名稱')) {
71
+ throw new Error(`TPEX 3insti: 前兩欄非「代號/名稱」(得「${fields[0]}」「${fields[1]}」,疑似 API 改版)`)
72
+ }
73
+ if (!String(fields[fields.length - 1]).includes('合計')) {
74
+ throw new Error(`TPEX 3insti: 末欄非「合計」(得「${fields[fields.length - 1]}」,疑似 API 改版)`)
75
+ }
76
+
77
+ let cycle = ['買進股數', '賣出股數', '買賣超股數']
78
+ for (let i = 2; i < fields.length - 1; i++) {
79
+ let expect = cycle[(i - 2) % 3]
80
+ if (String(fields[i]) !== expect) {
81
+ throw new Error(`TPEX 3insti: 第 ${i} 欄預期「${expect}」得「${fields[i]}」(疑似 API 改版,欄序已變)`)
82
+ }
83
+ }
84
+
85
+ }
86
+
87
+
88
+ /**
89
+ * 抓取櫃買中心(TPEX)上櫃三大法人買賣超
90
+ *
91
+ * 抓取指定日之上櫃三大法人買賣超(含自營商避險), 因API之fields為裸欄名且重複, 故以固定位置對應語意欄名;
92
+ * 套位置對應前先驗證欄位結構, 不符即拋錯以避免產生錯置之財務資料
93
+ *
94
+ * @param {String} dateStr 輸入日期YYYYMMDD字串
95
+ * @param {Array} [stockCodes] 輸入股票代號字串陣列,省略或空陣列表示全市場
96
+ * @param {Object} [opt={}] 輸入設定物件,預設{}
97
+ * @param {String} [opt.baseUrl='https://www.tpex.org.tw'] 輸入櫃買中心根網址字串,供測試或改指向鏡像時覆寫
98
+ * @param {Integer} [opt.timeout=15000] 輸入單次請求逾時毫秒整數,預設15000
99
+ * @param {Integer} [opt.maxRetries=10] 輸入最大重試次數整數,含初始共執行maxRetries+1次,預設10
100
+ * @param {Integer} [opt.baseDelayMs=5000] 輸入重試之線性退避基礎毫秒整數,預設5000
101
+ * @param {Integer} [opt.maxDelayMs=30000] 輸入重試之線性退避上限毫秒整數,預設30000
102
+ * @param {Boolean} [opt.showLog=true] 輸入是否顯示過程訊息布林值,預設true
103
+ * @returns {Promise} 回傳Promise,resolve回傳結果物件{source,date,data},日期無效、無資料或欄位結構不符時reject回傳錯誤物件
104
+ * @example
105
+ *
106
+ * import fetchTpex3insti from './src/fetchTpex3insti.mjs'
107
+ *
108
+ * let test = async () => {
109
+ *
110
+ * let r = await fetchTpex3insti('20260807', ['6488'])
111
+ * console.log(r.source, r.data.length, r.data[0]['代號'])
112
+ * // => 'tpex' 1 '6488'
113
+ *
114
+ * }
115
+ * await test()
116
+ * .catch((err) => {
117
+ * console.log(err)
118
+ * })
119
+ *
120
+ */
121
+ async function fetchTpex3insti(dateStr, stockCodes, opt = {}) {
122
+
123
+ //check
124
+ if (!isestr(dateStr) || !/^\d{8}$/.test(dateStr)) {
125
+ throw new Error(`dateStr must be YYYYMMDD, got: ${dateStr}`)
126
+ }
127
+ if (!isYmd(dateStr)) {
128
+ //合法性驗證, 例如20260230雖符合8碼但日期不存在
129
+ throw new Error(`dateStr 不是合法日期: ${dateStr}`)
130
+ }
131
+
132
+ //baseUrl
133
+ let baseUrl = get(opt, 'baseUrl')
134
+ if (!isestr(baseUrl)) {
135
+ baseUrl = BASE_URL
136
+ }
137
+ baseUrl = baseUrl.replace(/\/+$/, '')
138
+
139
+ //optFetch
140
+ let optFetch = getOptFetch(opt, DFLT)
141
+ let showLog = optFetch.showLog
142
+
143
+ //targetCodes
144
+ let targetCodes = isearr(stockCodes) ? stockCodes : []
145
+
146
+ //url
147
+ let rocDateStr = toRocDate(dateStr)
148
+ let url = `${baseUrl}/web/stock/3insti/daily_trade/3itrade_hedge_result.php?l=zh-tw&t=D&d=${rocDateStr}&o=json`
149
+
150
+ if (showLog) {
151
+ console.log(`Fetching from: ${url}`)
152
+ console.log(`Target: ${targetCodes.length === 0 ? 'All Market' : targetCodes.join(', ')}`)
153
+ }
154
+
155
+ //data
156
+ let data = await fetchWithRetry(url, optFetch)
157
+
158
+ //check
159
+ if (!isearr(get(data, 'tables'))) {
160
+ throw new Error('TPEX 3insti: tables not found in response. Possibly a holiday or no data.')
161
+ }
162
+
163
+ //table, 此端點回tables[0]=資料表、tables[1]=空{}
164
+ //取含「代號」「名稱」表頭之資料表, 退而取tables[0], 避免未來新增其他資料表時誤抓
165
+ let tables = data.tables
166
+ let table = tables.find((t) => {
167
+ return Array.isArray(t.fields) &&
168
+ t.fields.some((f) => String(f).includes('代號')) &&
169
+ t.fields.some((f) => String(f).includes('名稱'))
170
+ }) || tables[0]
171
+
172
+ //fields, rawData
173
+ let fields = get(table, 'fields')
174
+ let rawData = get(table, 'data')
175
+ if (!Array.isArray(fields) || !Array.isArray(rawData)) {
176
+ throw new Error('TPEX 3insti: data/fields not found in table.')
177
+ }
178
+
179
+ //套位置對應前先驗結構(fields為裸欄名, 必須靠固定位置才能還原7組明細)
180
+ assertTpexFieldShape(fields)
181
+
182
+ //processedData
183
+ let processedData = rawData
184
+ .map((row) => {
185
+ let obj = {}
186
+ TPEX_FIELD_MAP.forEach((key, index) => {
187
+ let value = row[index]
188
+ if (typeof value === 'string') {
189
+ value = value.trim()
190
+ }
191
+ obj[key] = value
192
+ })
193
+ return obj
194
+ })
195
+ .filter((item) => targetCodes.length === 0 || targetCodes.includes(item['代號']))
196
+
197
+ if (showLog) {
198
+ console.log(`Fetched ${processedData.length} records.`)
199
+ }
200
+
201
+ return { source: 'tpex', date: dateStr, data: processedData }
202
+ }
203
+
204
+
205
+ export { TPEX_FIELD_MAP }
206
+ export default fetchTpex3insti
@@ -0,0 +1,176 @@
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 toRocDate from './toRocDate.mjs'
8
+ import parseIntComma from './parseIntComma.mjs'
9
+
10
+
11
+ //櫃買中心網站根網址
12
+ let BASE_URL = 'https://www.tpex.org.tw'
13
+
14
+
15
+ //本函數之預設抓取設定
16
+ let DFLT = {
17
+ timeout: 15000,
18
+ maxRetries: 10,
19
+ baseDelayMs: 5000,
20
+ maxDelayMs: 30000,
21
+ label: 'fetch-tpex-margin',
22
+ }
23
+
24
+
25
+ /**
26
+ * 抓取櫃買中心(TPEX)上櫃融資融券
27
+ *
28
+ * 抓取指定日之上櫃個股融資融券餘額並轉為結構化資料, 可指定股票代號陣列過濾;
29
+ * 選表以欄位同時含「資餘額」與「券餘額」比對, 失敗則退而取第一張有資料表, 並鎖定index6與index14兩錨點欄位驗證佈局,
30
+ * 不符即fail-loud, 避免固定index解析在API改版後靜默產生錯誤資料
31
+ *
32
+ * @param {String} dateStr 輸入日期YYYYMMDD字串
33
+ * @param {Array} [stockCodes] 輸入股票代號字串陣列,省略或空陣列表示全市場
34
+ * @param {Object} [opt={}] 輸入設定物件,預設{}
35
+ * @param {String} [opt.baseUrl='https://www.tpex.org.tw'] 輸入櫃買中心根網址字串,供測試或改指向鏡像時覆寫
36
+ * @param {Integer} [opt.timeout=15000] 輸入單次請求逾時毫秒整數,預設15000
37
+ * @param {Integer} [opt.maxRetries=10] 輸入最大重試次數整數,含初始共執行maxRetries+1次,預設10
38
+ * @param {Integer} [opt.baseDelayMs=5000] 輸入重試之線性退避基礎毫秒整數,預設5000
39
+ * @param {Integer} [opt.maxDelayMs=30000] 輸入重試之線性退避上限毫秒整數,預設30000
40
+ * @param {Boolean} [opt.showLog=true] 輸入是否顯示過程訊息布林值,預設true
41
+ * @returns {Promise} 回傳Promise,resolve回傳結果物件{source,date,count,data},日期無效、無資料、欄位佈局不符或指定個股查無資料時reject回傳錯誤物件
42
+ * @example
43
+ *
44
+ * import fetchTpexMargin from './src/fetchTpexMargin.mjs'
45
+ *
46
+ * let test = async () => {
47
+ *
48
+ * let r = await fetchTpexMargin('20260807', ['6488'])
49
+ * console.log(r.source, r.count, r.data[0].code)
50
+ * // => 'tpex_margin' 1 '6488'
51
+ *
52
+ * }
53
+ * await test()
54
+ * .catch((err) => {
55
+ * console.log(err)
56
+ * })
57
+ *
58
+ */
59
+ async function fetchTpexMargin(dateStr, stockCodes, opt = {}) {
60
+
61
+ //check
62
+ if (!isestr(dateStr) || !/^\d{8}$/.test(dateStr)) {
63
+ throw new Error(`日期參數無效:格式須為 YYYYMMDD (收到 "${dateStr}")`)
64
+ }
65
+ if (!isYmd(dateStr)) {
66
+ throw new Error(`日期參數無效:不合法的日期 (${dateStr})`)
67
+ }
68
+
69
+ //baseUrl
70
+ let baseUrl = get(opt, 'baseUrl')
71
+ if (!isestr(baseUrl)) {
72
+ baseUrl = BASE_URL
73
+ }
74
+ baseUrl = baseUrl.replace(/\/+$/, '')
75
+
76
+ //optFetch
77
+ let optFetch = getOptFetch(opt, DFLT)
78
+ let showLog = optFetch.showLog
79
+
80
+ //targetCodes
81
+ let targetCodes = isearr(stockCodes) ? stockCodes.filter(Boolean) : []
82
+
83
+ //url
84
+ let rocDate = toRocDate(dateStr)
85
+ let url = `${baseUrl}/web/stock/margin_trading/margin_balance/margin_bal_result.php?l=zh-tw&d=${rocDate}&o=json`
86
+
87
+ if (showLog) {
88
+ console.log(`Fetching TPEX margin data: ${dateStr} (${rocDate})`)
89
+ console.log(`Target: ${targetCodes.length > 0 ? targetCodes.join(', ') : 'All Market'}`)
90
+ console.log(`URL: ${url}`)
91
+ }
92
+
93
+ //data
94
+ let data = await fetchWithRetry(url, optFetch)
95
+
96
+ //marginTable, TPEX格式: { stat: 'ok', tables: [{ title, fields, data }] }
97
+ //fields: ["代號","名稱","前資餘額(張)","資買","資賣","現償","資餘額","資屬證金","資使用率(%)",
98
+ // "資限額","前券餘額(張)","券賣","券買","券償","券餘額","券屬證金","券使用率(%)","券限額","資券相抵(張)","備註"]
99
+ let tables = Array.isArray(data.tables) ? data.tables : []
100
+ let marginTable = tables.find((t) => {
101
+ return get(t, 'data.length', 0) > 0 &&
102
+ Array.isArray(t.fields) &&
103
+ t.fields.some((f) => String(f).includes('資餘額')) &&
104
+ t.fields.some((f) => String(f).includes('券餘額'))
105
+ }) || tables.find((t) => get(t, 'data.length', 0) > 0)
106
+ if (!marginTable || !isearr(get(marginTable, 'data'))) {
107
+ throw new Error('TPEX margin API returned no data. Possibly a holiday or data not yet available.')
108
+ }
109
+
110
+ //shape guard, 主比對失敗時會fallback取第一張有資料表, 若該表結構不同則下方固定index(2~19)會靜默解析錯欄位
111
+ //此處鎖定解析所依賴之兩個錨點欄位(index 6=資餘額、index 14=券餘額), 不符即fail-loud
112
+ let fields = Array.isArray(marginTable.fields) ? marginTable.fields : []
113
+ if (!String(fields[6] || '').includes('資餘額') || !String(fields[14] || '').includes('券餘額')) {
114
+ throw new Error(`TPEX 融資融券資料欄位佈局與預期不符(index6 應含「資餘額」實為「${fields[6] ?? ''}」、index14 應含「券餘額」實為「${fields[14] ?? ''}」);可能 API 格式變更,停止解析以免產生錯誤資料`)
115
+ }
116
+
117
+ //rows
118
+ let rows = marginTable.data
119
+ if (targetCodes.length > 0) {
120
+ rows = rows.filter((row) => targetCodes.includes(String(row[0] || '').trim()))
121
+ if (rows.length === 0) {
122
+ throw new Error(`指定個股 ${targetCodes.join(',')} 不在上櫃融資融券資料中(可能為上市股或代碼有誤)`)
123
+ }
124
+ }
125
+
126
+ //parsedData
127
+ //idx: 0=代號, 1=名稱, 2=前資餘額, 3=資買, 4=資賣, 5=現償, 6=資餘額,
128
+ // 7=資屬證金, 8=資使用率, 9=資限額, 10=前券餘額, 11=券賣, 12=券買,
129
+ // 13=券償, 14=券餘額, 15=券屬證金, 16=券使用率, 17=券限額, 18=資券相抵, 19=備註
130
+ let parsedData = rows.map((row) => {
131
+
132
+ let marginPrev = parseIntComma(row[2])
133
+ let marginBuy = parseIntComma(row[3])
134
+ let marginSell = parseIntComma(row[4])
135
+ let marginBalance = parseIntComma(row[6])
136
+ let shortPrev = parseIntComma(row[10])
137
+ let shortSell = parseIntComma(row[11])
138
+ let shortBuy = parseIntComma(row[12])
139
+ let shortBalance = parseIntComma(row[14])
140
+
141
+ return {
142
+ code: String(row[0] || '').trim(),
143
+ name: String(row[1] || '').trim(),
144
+ marginBuy,
145
+ marginSell,
146
+ marginCashRepay: parseIntComma(row[5]),
147
+ marginPrevBalance: marginPrev,
148
+ marginBalance,
149
+ marginChange: marginBalance - marginPrev,
150
+ marginLimit: parseIntComma(row[9]),
151
+ shortSell,
152
+ shortBuy,
153
+ shortCashRepay: parseIntComma(row[13]),
154
+ shortPrevBalance: shortPrev,
155
+ shortBalance,
156
+ shortChange: shortBalance - shortPrev,
157
+ shortLimit: parseIntComma(row[17]),
158
+ offset: parseIntComma(row[18]),
159
+ note: String(row[19] || '').trim(),
160
+ }
161
+ })
162
+
163
+ if (showLog) {
164
+ console.log(`Fetched ${parsedData.length} records.`)
165
+ }
166
+
167
+ return {
168
+ source: 'tpex_margin',
169
+ date: dateStr,
170
+ count: parsedData.length,
171
+ data: parsedData,
172
+ }
173
+ }
174
+
175
+
176
+ export default fetchTpexMargin
@@ -0,0 +1,127 @@
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 toRocDate from './toRocDate.mjs'
8
+
9
+
10
+ //櫃買中心網站根網址
11
+ let BASE_URL = 'https://www.tpex.org.tw'
12
+
13
+
14
+ //本函數之預設抓取設定
15
+ let DFLT = {
16
+ timeout: 15000,
17
+ maxRetries: 10,
18
+ baseDelayMs: 5000,
19
+ maxDelayMs: 30000,
20
+ label: 'fetch-tpex-stock',
21
+ }
22
+
23
+
24
+ //下游依固定欄序讀取(index 0代號、2收盤、4-6開高低、7成交股數), 故驗證欄數下限
25
+ //門檻8遠低於實際約17欄, 不會誤擋正常資料
26
+ let MIN_COLUMNS = 8
27
+
28
+
29
+ /**
30
+ * 抓取櫃買中心(TPEX)上櫃股價
31
+ *
32
+ * 抓取指定日之上櫃股票行情, 可指定股票代號陣列過濾;
33
+ * 選表優先以標題含「行情」比對, 避免未來新增其他資料表時誤抓, 並驗證欄數以避免API改版後靜默讀錯欄
34
+ *
35
+ * @param {String} dateStr 輸入日期YYYYMMDD字串
36
+ * @param {Array} [stockCodes] 輸入股票代號字串陣列,例如['6499','6610'],省略或空陣列表示全市場
37
+ * @param {Object} [opt={}] 輸入設定物件,預設{}
38
+ * @param {String} [opt.baseUrl='https://www.tpex.org.tw'] 輸入櫃買中心根網址字串,供測試或改指向鏡像時覆寫
39
+ * @param {Integer} [opt.timeout=15000] 輸入單次請求逾時毫秒整數,預設15000
40
+ * @param {Integer} [opt.maxRetries=10] 輸入最大重試次數整數,含初始共執行maxRetries+1次,預設10
41
+ * @param {Integer} [opt.baseDelayMs=5000] 輸入重試之線性退避基礎毫秒整數,預設5000
42
+ * @param {Integer} [opt.maxDelayMs=30000] 輸入重試之線性退避上限毫秒整數,預設30000
43
+ * @param {Boolean} [opt.showLog=true] 輸入是否顯示過程訊息布林值,預設true
44
+ * @returns {Promise} 回傳Promise,resolve回傳結果物件{source,date,count,data},日期無效、無資料或指定個股查無資料時reject回傳錯誤物件
45
+ * @example
46
+ *
47
+ * import fetchTpexStock from './src/fetchTpexStock.mjs'
48
+ *
49
+ * let test = async () => {
50
+ *
51
+ * let r = await fetchTpexStock('20260807', ['6488'])
52
+ * console.log(r.source, r.count)
53
+ * // => 'tpex' 1
54
+ *
55
+ * }
56
+ * await test()
57
+ * .catch((err) => {
58
+ * console.log(err)
59
+ * })
60
+ *
61
+ */
62
+ async function fetchTpexStock(dateStr, stockCodes, opt = {}) {
63
+
64
+ //check, 函數入口驗日期, 令程式化呼叫不繞過CLI之檢核
65
+ if (!isYmd(dateStr)) {
66
+ throw new Error(`dateStr 須為合法之 YYYYMMDD 字串,得到: ${dateStr}`)
67
+ }
68
+
69
+ //baseUrl
70
+ let baseUrl = get(opt, 'baseUrl')
71
+ if (!isestr(baseUrl)) {
72
+ baseUrl = BASE_URL
73
+ }
74
+ baseUrl = baseUrl.replace(/\/+$/, '')
75
+
76
+ //optFetch
77
+ let optFetch = getOptFetch(opt, DFLT)
78
+ let showLog = optFetch.showLog
79
+
80
+ //targetCodes
81
+ let targetCodes = isearr(stockCodes) ? stockCodes : []
82
+
83
+ //url
84
+ let rocDate = toRocDate(dateStr)
85
+ let url = `${baseUrl}/web/stock/aftertrading/daily_close_quotes/stk_quote_result.php?l=zh-tw&d=${rocDate}&s=0,asc,0&o=json`
86
+
87
+ if (showLog) {
88
+ console.log(`Fetching TPEX data: ${dateStr} (${rocDate})`)
89
+ console.log(`Target: ${targetCodes.length > 0 ? targetCodes.join(', ') : 'All Market'}`)
90
+ console.log(`URL: ${url}`)
91
+ }
92
+
93
+ //data
94
+ let data = await fetchWithRetry(url, optFetch)
95
+
96
+ //TPEX API格式(2026年後): { stat, tables: [{ title: '上櫃股票行情', data: [...] }] }
97
+ //優先以title「行情」字樣比對, 避免未來新增其他資料表時誤抓
98
+ let tables = Array.isArray(data.tables) ? data.tables : []
99
+ let targetTable = tables.find((t) => get(t, 'title', '').includes('行情') && get(t, 'data.length', 0) > 0) ||
100
+ tables.find((t) => get(t, 'data.length', 0) > 0)
101
+ let rows = get(targetTable, 'data')
102
+
103
+ //check
104
+ if (!isearr(rows)) {
105
+ throw new Error('TPEX API returned no data. Possibly a holiday or data not yet available.')
106
+ }
107
+
108
+ //shape sanity, 驗證選中表欄數符合預期, 避免API改版後靜默讀錯欄
109
+ if (!Array.isArray(rows[0]) || rows[0].length < MIN_COLUMNS) {
110
+ throw new Error(`TPEX 行情資料欄數不符預期(首列 ${Array.isArray(rows[0]) ? rows[0].length : 'N/A'} 欄,應 >= ${MIN_COLUMNS}),可能 API 格式變更`)
111
+ }
112
+
113
+ //resultData
114
+ let resultData = rows
115
+ if (targetCodes.length > 0) {
116
+ resultData = resultData.filter((row) => targetCodes.includes(row[0]))
117
+ if (resultData.length === 0) {
118
+ //整體有資料但過濾後為空 → 個股不在上櫃市場, 與整體無資料之錯誤訊息明確區分
119
+ throw new Error(`指定個股 ${targetCodes.join(',')} 在 ${dateStr} 之上櫃資料中查無資料(可能為上市股、代碼有誤、或當日無交易)`)
120
+ }
121
+ }
122
+
123
+ return { source: 'tpex', date: dateStr, count: resultData.length, data: resultData }
124
+ }
125
+
126
+
127
+ export default fetchTpexStock
@@ -0,0 +1,164 @@
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
+
7
+
8
+ //證交所開放資料平台之休市日期API
9
+ let API_URL = 'https://openapi.twse.com.tw/v1/holidaySchedule/holidaySchedule'
10
+
11
+
12
+ //本函數之預設抓取設定
13
+ let DFLT = {
14
+ responseType: 'text',
15
+ timeout: 30000,
16
+ maxRetries: 10,
17
+ baseDelayMs: 5000,
18
+ maxDelayMs: 30000,
19
+ label: 'fetch-tw-data-holiday',
20
+ }
21
+
22
+
23
+ /**
24
+ * 民國日期YYYMMDD轉西元日期YYYYMMDD
25
+ *
26
+ * @param {String} rocDate 輸入民國日期字串,年份位數不限
27
+ * @returns {String} 回傳西元日期YYYYMMDD字串
28
+ */
29
+ function rocToWestern(rocDate) {
30
+
31
+ let rocYear = parseInt(rocDate.substring(0, rocDate.length - 4), 10)
32
+ let mmdd = rocDate.substring(rocDate.length - 4)
33
+
34
+ return `${rocYear + 1911}${mmdd}`
35
+ }
36
+
37
+
38
+ /**
39
+ * 判斷條目是否為非假日條目
40
+ *
41
+ * 應排除者有二類:
42
+ * 交易日標記(「國曆新年開始交易日」「農曆春節前最後交易日」「農曆春節後開始交易日」);
43
+ * 結算作業日(「市場無交易,僅辦理結算交割作業」)
44
+ *
45
+ * @param {Object} entry 輸入API回傳之單筆條目物件
46
+ * @returns {Boolean} 回傳是否為非假日條目之布林值
47
+ */
48
+ function isNonHolidayEntry(entry) {
49
+
50
+ let name = String(get(entry, 'Name', ''))
51
+ let description = String(get(entry, 'Description', ''))
52
+
53
+ return /交易日/.test(name) ||
54
+ /市場無交易/.test(name) ||
55
+ /開始交易/.test(description) ||
56
+ /最後交易/.test(description)
57
+ }
58
+
59
+
60
+ /**
61
+ * 抓取台灣證券市場休市日期
62
+ *
63
+ * 由證交所開放資料平台取得當年度休市日期清單, 排除交易日標記與結算作業日, 並依日期去重與排序;
64
+ * 給定checkDate時另回傳該日是否為休市日
65
+ *
66
+ * @param {String} [checkDate] 輸入待查詢日期YYYYMMDD字串,非合法YYYYMMDD時略過單日比對
67
+ * @param {Object} [opt={}] 輸入設定物件,預設{}
68
+ * @param {String} [opt.url='https://openapi.twse.com.tw/v1/holidaySchedule/holidaySchedule'] 輸入API網址字串,供測試或改指向鏡像時覆寫
69
+ * @param {Integer} [opt.timeout=30000] 輸入單次請求逾時毫秒整數,預設30000
70
+ * @param {Integer} [opt.maxRetries=10] 輸入最大重試次數整數,含初始共執行maxRetries+1次,預設10
71
+ * @param {Integer} [opt.baseDelayMs=5000] 輸入重試之線性退避基礎毫秒整數,預設5000
72
+ * @param {Integer} [opt.maxDelayMs=30000] 輸入重試之線性退避上限毫秒整數,預設30000
73
+ * @param {Boolean} [opt.showLog=true] 輸入是否顯示重試訊息布林值,預設true
74
+ * @returns {Promise} 回傳Promise,resolve回傳結果物件{dataYear,totalHolidays,holidays},有給checkDate時另含{checkDate,isHoliday,holidayName},API回傳非JSON或空陣列時reject回傳錯誤物件
75
+ * @example
76
+ *
77
+ * import fetchTwDataHoliday from './src/fetchTwDataHoliday.mjs'
78
+ *
79
+ * let test = async () => {
80
+ *
81
+ * let r = await fetchTwDataHoliday('20260101')
82
+ * console.log(r.dataYear, r.totalHolidays, r.isHoliday, r.holidayName)
83
+ * // => '2026' 15 true '中華民國開國紀念日'
84
+ *
85
+ * }
86
+ * await test()
87
+ * .catch((err) => {
88
+ * console.log(err)
89
+ * })
90
+ *
91
+ */
92
+ async function fetchTwDataHoliday(checkDate, opt = {}) {
93
+
94
+ //url
95
+ let url = get(opt, 'url')
96
+ if (!isestr(url)) {
97
+ url = API_URL
98
+ }
99
+
100
+ //optFetch
101
+ let optFetch = getOptFetch(opt, DFLT)
102
+
103
+ //body
104
+ let body = await fetchWithRetry(url, optFetch)
105
+
106
+ //raw, API維護時可能回HTTP 200加HTML(非JSON), 產生文件記載之友善訊息而非裸SyntaxError
107
+ let raw = null
108
+ try {
109
+ raw = JSON.parse(body)
110
+ }
111
+ catch (err) {
112
+ throw new Error('API 回傳非 JSON 格式(可能為維護頁面或網路中介),請稍後再試')
113
+ }
114
+ if (!isearr(raw)) {
115
+ throw new Error('API 回傳空陣列或格式異常')
116
+ }
117
+
118
+ //holidays, 轉為結構化假日清單(排除交易日標記與結算作業日)
119
+ let holidays = raw
120
+ .filter((entry) => !isNonHolidayEntry(entry))
121
+ .map((entry) => {
122
+ return {
123
+ date: rocToWestern(get(entry, 'Date', '')),
124
+ rocDate: get(entry, 'Date', ''),
125
+ name: get(entry, 'Name', ''),
126
+ weekday: get(entry, 'Weekday', ''),
127
+ description: String(get(entry, 'Description', '') || '').replace(/<br\s*\/?>/gi, '').trim(),
128
+ }
129
+ })
130
+
131
+ //uniqueHolidays, 去重(同一天可能有多筆同名條目)
132
+ let seen = new Set()
133
+ let uniqueHolidays = holidays.filter((h) => {
134
+ if (seen.has(h.date)) {
135
+ return false
136
+ }
137
+ seen.add(h.date)
138
+ return true
139
+ })
140
+ uniqueHolidays.sort((a, b) => a.date.localeCompare(b.date))
141
+
142
+ //dataYear
143
+ let dataYear = uniqueHolidays.length > 0 ? uniqueHolidays[0].date.substring(0, 4) : null
144
+
145
+ //result
146
+ let result = {
147
+ dataYear,
148
+ totalHolidays: uniqueHolidays.length,
149
+ holidays: uniqueHolidays,
150
+ }
151
+
152
+ //checkDate為opt, 僅當為合法YYYYMMDD字串才做單日假日比對, 無效則略過(不誤標isHoliday)
153
+ if (isestr(checkDate) && /^\d{8}$/.test(checkDate)) {
154
+ result.checkDate = checkDate
155
+ let match = uniqueHolidays.find((h) => h.date === checkDate)
156
+ result.isHoliday = !!match
157
+ result.holidayName = match ? match.name : null
158
+ }
159
+
160
+ return result
161
+ }
162
+
163
+
164
+ export default fetchTwDataHoliday