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,141 @@
1
+ import * as cheerio from 'cheerio'
2
+ import get from 'lodash-es/get.js'
3
+ import isestr from 'wsemi/src/isestr.mjs'
4
+ import fetchWithRetry from './fetchWithRetry.mjs'
5
+ import getOptFetch from './getOptFetch.mjs'
6
+
7
+
8
+ //財報狗最新新聞頁
9
+ let PAGE_URL = 'https://statementdog.com/news/latest'
10
+
11
+
12
+ //本函數之預設抓取設定
13
+ let DFLT = {
14
+ responseType: 'text',
15
+ timeout: 30000,
16
+ maxRetries: 10,
17
+ baseDelayMs: 5000,
18
+ maxDelayMs: 30000,
19
+ retryStatus: [403],
20
+ label: 'fetch-statementdog',
21
+ }
22
+
23
+
24
+ //主要selector, 對應財報狗現行版型
25
+ let PRIMARY_ITEM = '.statementdog-news-list-item'
26
+ let PRIMARY_TITLE = '.statementdog-news-list-item-title'
27
+ let PRIMARY_LINK = '.statementdog-news-list-item-link'
28
+ let PRIMARY_DATE = '.statementdog-news-list-item-date'
29
+
30
+
31
+ //備援selector, 財報狗改版導致主要selector失效時改用之通用結構
32
+ let FALLBACK_ITEM = 'article, .news-item, [class*="news"][class*="item"]'
33
+ let FALLBACK_TITLE = 'h2, h3, [class*="title"]'
34
+ let FALLBACK_LINK = 'a[href]'
35
+ let FALLBACK_DATE = 'time, [class*="date"], [class*="time"]'
36
+
37
+
38
+ /**
39
+ * 抓取財報狗最新新聞
40
+ *
41
+ * 以cheerio解析財報狗最新新聞頁, 主要selector失效時自動改用通用結構備援selector;
42
+ * 相對路徑連結會補上來源網址之origin, 抓取到0筆時視為版型改變而拋出錯誤(不重試)
43
+ *
44
+ * @param {Object} [opt={}] 輸入設定物件,預設{}
45
+ * @param {String} [opt.url='https://statementdog.com/news/latest'] 輸入新聞頁網址字串,供測試或改指向鏡像時覆寫
46
+ * @param {Integer} [opt.timeout=30000] 輸入單次請求逾時毫秒整數,預設30000
47
+ * @param {Integer} [opt.maxRetries=10] 輸入最大重試次數整數,含初始共執行maxRetries+1次,預設10
48
+ * @param {Integer} [opt.baseDelayMs=5000] 輸入重試之線性退避基礎毫秒整數,預設5000
49
+ * @param {Integer} [opt.maxDelayMs=30000] 輸入重試之線性退避上限毫秒整數,預設30000
50
+ * @param {Boolean} [opt.showLog=true] 輸入是否顯示過程訊息布林值,預設true
51
+ * @returns {Promise} 回傳Promise,resolve回傳新聞物件陣列,各物件為{time,title,link},抓取失敗或解析到0筆時reject回傳錯誤物件
52
+ * @example
53
+ *
54
+ * import fetchStatementdog from './src/fetchStatementdog.mjs'
55
+ *
56
+ * let test = async () => {
57
+ *
58
+ * let rs = await fetchStatementdog()
59
+ * console.log(rs.length, rs[0])
60
+ * // => 30 { time: '2026-08-07', title: '...', link: 'https://statementdog.com/news/...' }
61
+ *
62
+ * }
63
+ * await test()
64
+ * .catch((err) => {
65
+ * console.log(err)
66
+ * })
67
+ *
68
+ */
69
+ async function fetchStatementdog(opt = {}) {
70
+
71
+ //url
72
+ let url = get(opt, 'url')
73
+ if (!isestr(url)) {
74
+ url = PAGE_URL
75
+ }
76
+
77
+ //optFetch
78
+ let optFetch = getOptFetch(opt, DFLT)
79
+ let showLog = optFetch.showLog
80
+
81
+ if (showLog) {
82
+ console.log(`Fetching ${url}...`)
83
+ }
84
+
85
+ //html
86
+ let html = await fetchWithRetry(url, optFetch)
87
+
88
+ //origin, 由來源網址推得, 供補齊相對路徑連結
89
+ let origin = new URL(url).origin
90
+
91
+ //$
92
+ let $ = cheerio.load(html)
93
+
94
+ //usePrimary
95
+ let usePrimary = $(PRIMARY_ITEM).length > 0
96
+ let itemSel = usePrimary ? PRIMARY_ITEM : FALLBACK_ITEM
97
+ let titleSel = usePrimary ? PRIMARY_TITLE : FALLBACK_TITLE
98
+ let linkSel = usePrimary ? PRIMARY_LINK : FALLBACK_LINK
99
+ let dateSel = usePrimary ? PRIMARY_DATE : FALLBACK_DATE
100
+ if (!usePrimary && showLog) {
101
+ console.warn('主要 CSS selector 未匹配,嘗試 fallback selector...')
102
+ }
103
+
104
+ //newsItems
105
+ let newsItems = []
106
+ $(itemSel).each((index, element) => {
107
+
108
+ let titleElement = $(element).find(titleSel)
109
+ let linkElement = usePrimary ? $(element).find(linkSel) : $(element).find(linkSel).first()
110
+ let timeElement = $(element).find(dateSel)
111
+
112
+ let title = titleElement.text().trim()
113
+ let link = linkElement.attr('href')
114
+ let time = timeElement.text().trim()
115
+
116
+ if (!title || !link) {
117
+ return
118
+ }
119
+
120
+ //補齊相對路徑連結
121
+ if (!link.startsWith('http')) {
122
+ link = `${origin}${link.startsWith('/') ? '' : '/'}${link}`
123
+ }
124
+
125
+ newsItems.push({ time, title, link })
126
+ })
127
+
128
+ if (showLog) {
129
+ console.log(`Extracted News Items: ${newsItems.length}`)
130
+ }
131
+
132
+ //check, 0筆代表版型改變或selector失效, 屬非暫時性錯誤故不重試
133
+ if (newsItems.length === 0) {
134
+ throw new Error('抓取到 0 筆新聞,可能是頁面結構改變或 selector 失效,請確認財報狗頁面是否正常。')
135
+ }
136
+
137
+ return newsItems
138
+ }
139
+
140
+
141
+ export default fetchStatementdog
@@ -0,0 +1,441 @@
1
+ import get from 'lodash-es/get.js'
2
+ import isestr from 'wsemi/src/isestr.mjs'
3
+ import isp0int from 'wsemi/src/isp0int.mjs'
4
+ import cint from 'wsemi/src/cint.mjs'
5
+ import delay from 'wsemi/src/delay.mjs'
6
+ import fetchWithRetry from './fetchWithRetry.mjs'
7
+ import getOptFetch from './getOptFetch.mjs'
8
+ import isYmd from './isYmd.mjs'
9
+
10
+
11
+ //期交所網站根網址
12
+ let BASE_URL = 'https://www.taifex.com.tw'
13
+
14
+
15
+ //本函數之預設抓取設定, 期交所CSV為MS950(Big5)編碼
16
+ let DFLT = {
17
+ responseType: 'text',
18
+ encoding: 'big5',
19
+ timeout: 15000,
20
+ maxRetries: 10,
21
+ baseDelayMs: 5000,
22
+ maxDelayMs: 30000,
23
+ label: 'fetch-taifex',
24
+ }
25
+
26
+
27
+ //對期交所同網域請求改為序列執行, 每支間隔1秒, 降低被限流風險
28
+ let DEFAULT_INTER_REQUEST_DELAY_MS = 1000
29
+
30
+
31
+ //身份別中文名對應之結果key
32
+ let IDENTITY_MAP = {
33
+ '外資及陸資': 'foreign',
34
+ '外資': 'foreign',
35
+ '投信': 'trust',
36
+ '自營商': 'dealers',
37
+ }
38
+
39
+
40
+ /**
41
+ * 解析單行CSV
42
+ *
43
+ * 支援含逗號之引號欄位與""跳脫雙引號(RFC 4180子集, 足以涵蓋期交所未來欄位變動)
44
+ *
45
+ * @param {String} line 輸入CSV單行字串
46
+ * @returns {Array} 回傳欄位字串陣列
47
+ */
48
+ function parseCSVLine(line) {
49
+
50
+ let result = []
51
+ let cur = ''
52
+ let inQuote = false
53
+
54
+ for (let i = 0; i < line.length; i++) {
55
+ let c = line[i]
56
+ if (inQuote) {
57
+ if (c === '"') {
58
+ if (line[i + 1] === '"') {
59
+ cur += '"'
60
+ i++
61
+ }
62
+ else {
63
+ inQuote = false
64
+ }
65
+ }
66
+ else {
67
+ cur += c
68
+ }
69
+ }
70
+ else if (c === ',') {
71
+ result.push(cur.trim())
72
+ cur = ''
73
+ }
74
+ else if (c === '"' && cur.length === 0) {
75
+ inQuote = true
76
+ }
77
+ else {
78
+ cur += c
79
+ }
80
+ }
81
+ result.push(cur.trim())
82
+
83
+ return result
84
+ }
85
+
86
+
87
+ /**
88
+ * 解析CSV文字為物件陣列
89
+ *
90
+ * 以首列為欄名, 支援尾端逗號、引號欄位, 並去除前後空白
91
+ *
92
+ * @param {String} csvText 輸入CSV文字字串
93
+ * @returns {Array} 回傳物件陣列,資料不足2列時回傳[]
94
+ */
95
+ function parseCSV(csvText) {
96
+
97
+ let lines = csvText.trim().split('\n').map((l) => l.trim()).filter((l) => l.length > 0)
98
+ if (lines.length < 2) {
99
+ return []
100
+ }
101
+
102
+ let headers = parseCSVLine(lines[0])
103
+ let rows = []
104
+ for (let i = 1; i < lines.length; i++) {
105
+ let values = parseCSVLine(lines[i])
106
+ let obj = {}
107
+ for (let j = 0; j < headers.length; j++) {
108
+ obj[headers[j]] = values[j] || ''
109
+ }
110
+ rows.push(obj)
111
+ }
112
+
113
+ return rows
114
+ }
115
+
116
+
117
+ /**
118
+ * 含千分位逗號之字串轉數值
119
+ *
120
+ * 與parseIntComma不同, 本函數保留小數並以null表達缺值, 供期交所價格與比率欄位使用
121
+ *
122
+ * @param {String} str 輸入數值字串
123
+ * @returns {Number|null} 回傳數值,為'-'、空字串或無法轉換時回傳null
124
+ */
125
+ function parseNum(str) {
126
+
127
+ if (!str || str === '-' || str === '') {
128
+ return null
129
+ }
130
+
131
+ let num = Number(str.replace(/,/g, ''))
132
+
133
+ return isNaN(num) ? null : num
134
+ }
135
+
136
+
137
+ /**
138
+ * 抓取台指期行情
139
+ *
140
+ * @param {String} dateSlash 輸入日期YYYY/MM/DD字串
141
+ * @param {Object} cfg 輸入已正規化之設定物件
142
+ * @returns {Promise} 回傳Promise,resolve回傳近月合約行情物件
143
+ */
144
+ async function fetchFuturesData(dateSlash, cfg) {
145
+
146
+ let url = `${cfg.baseUrl}/cht/3/futDataDown?down_type=1&queryStartDate=${dateSlash}&queryEndDate=${dateSlash}&commodity_id=TX`
147
+ if (cfg.showLog) {
148
+ console.log(`Fetching futures data: ${url}`)
149
+ }
150
+
151
+ let csvText = await fetchWithRetry(url, { ...cfg.optFetch, label: `${DFLT.label} Futures` })
152
+ let rows = parseCSV(csvText)
153
+ if (rows.length === 0) {
154
+ throw new Error('台指期行情:無資料(可能非交易日)')
155
+ }
156
+
157
+ //txRows, 排除到期月份含'/'之價差合約
158
+ let txRows = rows.filter((r) => r['契約'] === 'TX' && !r['到期月份(週別)'].includes('/'))
159
+ if (txRows.length === 0) {
160
+ throw new Error('台指期行情:找不到 TX 合約資料')
161
+ }
162
+
163
+ //依到期月份升冪排序以取近月
164
+ txRows.sort((a, b) => {
165
+ return a['到期月份(週別)'].trim().localeCompare(b['到期月份(週別)'].trim())
166
+ })
167
+
168
+ //nearMonth
169
+ let nearMonth = txRows[0]['到期月份(週別)'].trim()
170
+ let nearMonthRows = txRows.filter((r) => r['到期月份(週別)'].trim() === nearMonth)
171
+
172
+ //一般盤與盤後盤
173
+ let regularRow = nearMonthRows.find((r) => r['交易時段'] === '一般')
174
+ let afterHoursRow = nearMonthRows.find((r) => r['交易時段'] === '盤後')
175
+
176
+ let result = {
177
+ contractMonth: nearMonth,
178
+ open: null,
179
+ high: null,
180
+ low: null,
181
+ close: null,
182
+ settlement: null,
183
+ volume: null,
184
+ afterHoursClose: null,
185
+ afterHoursSettlement: null,
186
+ afterHoursVolume: null,
187
+ }
188
+
189
+ if (regularRow) {
190
+ result.open = parseNum(regularRow['開盤價'])
191
+ result.high = parseNum(regularRow['最高價'])
192
+ result.low = parseNum(regularRow['最低價'])
193
+ result.close = parseNum(regularRow['收盤價'])
194
+ result.settlement = parseNum(regularRow['結算價'])
195
+ result.volume = parseNum(regularRow['成交量'])
196
+ }
197
+
198
+ if (afterHoursRow) {
199
+ result.afterHoursClose = parseNum(afterHoursRow['收盤價'])
200
+ result.afterHoursSettlement = parseNum(afterHoursRow['結算價'])
201
+ result.afterHoursVolume = parseNum(afterHoursRow['成交量'])
202
+ }
203
+
204
+ if (cfg.showLog) {
205
+ console.log(` 台指期近月 (${nearMonth}): 開${result.open} 高${result.high} 低${result.low} 收${result.close} 結算${result.settlement} 量${result.volume}`)
206
+ if (result.afterHoursClose !== null) {
207
+ console.log(` 盤後: 收${result.afterHoursClose} 結算${result.afterHoursSettlement} 量${result.afterHoursVolume}`)
208
+ }
209
+ }
210
+
211
+ return result
212
+ }
213
+
214
+
215
+ /**
216
+ * 抓取三大法人期貨未平倉
217
+ *
218
+ * @param {String} dateSlash 輸入日期YYYY/MM/DD字串
219
+ * @param {Object} cfg 輸入已正規化之設定物件
220
+ * @returns {Promise} 回傳Promise,resolve回傳以foreign、trust、dealers為key之未平倉物件
221
+ */
222
+ async function fetchInstitutionalData(dateSlash, cfg) {
223
+
224
+ let url = `${cfg.baseUrl}/cht/3/futContractsDateDown?queryStartDate=${dateSlash}&queryEndDate=${dateSlash}&commodityId=TXF`
225
+ if (cfg.showLog) {
226
+ console.log(`Fetching institutional data: ${url}`)
227
+ }
228
+
229
+ let csvText = await fetchWithRetry(url, { ...cfg.optFetch, label: `${DFLT.label} Institutional` })
230
+ let rows = parseCSV(csvText)
231
+ if (rows.length === 0) {
232
+ throw new Error('三大法人期貨未平倉:無資料(可能非交易日)')
233
+ }
234
+
235
+ let result = {}
236
+ for (let row of rows) {
237
+
238
+ let identity = row['身份別']
239
+ let key = IDENTITY_MAP[identity]
240
+ if (!key) {
241
+ continue
242
+ }
243
+
244
+ result[key] = {
245
+ longContracts: parseNum(row['多方未平倉口數']),
246
+ longAmount: parseNum(row['多方未平倉契約金額(千元)']),
247
+ shortContracts: parseNum(row['空方未平倉口數']),
248
+ shortAmount: parseNum(row['空方未平倉契約金額(千元)']),
249
+ netContracts: parseNum(row['多空未平倉口數淨額']),
250
+ netAmount: parseNum(row['多空未平倉契約金額淨額(千元)']),
251
+ tradingLong: parseNum(row['多方交易口數']),
252
+ tradingShort: parseNum(row['空方交易口數']),
253
+ tradingNet: parseNum(row['多空交易口數淨額']),
254
+ }
255
+
256
+ if (cfg.showLog) {
257
+ console.log(` ${identity}: 未平倉淨額 ${result[key].netContracts} 口`)
258
+ }
259
+
260
+ }
261
+
262
+ //rows非空但無任一身份別命中(例如官方變更欄位用語), 視為解析失敗
263
+ //比照本檔其他解析在資料缺漏時拋錯之慣例, 避免回傳空物件被當成成功而靜默缺資料
264
+ if (Object.keys(result).length === 0) {
265
+ throw new Error('三大法人期貨未平倉:無法解析身份別(可能官方變更欄位用語)')
266
+ }
267
+
268
+ return result
269
+ }
270
+
271
+
272
+ /**
273
+ * 抓取Put/Call Ratio
274
+ *
275
+ * @param {String} dateSlash 輸入日期YYYY/MM/DD字串
276
+ * @param {Object} cfg 輸入已正規化之設定物件
277
+ * @returns {Promise} 回傳Promise,resolve回傳Put/Call Ratio物件
278
+ */
279
+ async function fetchPCRatio(dateSlash, cfg) {
280
+
281
+ let url = `${cfg.baseUrl}/cht/3/pcRatioDown?queryStartDate=${dateSlash}&queryEndDate=${dateSlash}`
282
+ if (cfg.showLog) {
283
+ console.log(`Fetching P/C ratio: ${url}`)
284
+ }
285
+
286
+ let csvText = await fetchWithRetry(url, { ...cfg.optFetch, label: `${DFLT.label} PCRatio` })
287
+ let rows = parseCSV(csvText)
288
+ if (rows.length === 0) {
289
+ throw new Error('Put/Call Ratio:無資料(可能非交易日)')
290
+ }
291
+
292
+ let row = rows[0]
293
+ let result = {
294
+ putVolume: parseNum(row['賣權成交量']),
295
+ callVolume: parseNum(row['買權成交量']),
296
+ ratio: parseNum(row['買賣權成交量比率%']),
297
+ putOpenInterest: parseNum(row['賣權未平倉量']),
298
+ callOpenInterest: parseNum(row['買權未平倉量']),
299
+ openInterestRatio: parseNum(row['買賣權未平倉量比率%']),
300
+ }
301
+
302
+ if (cfg.showLog) {
303
+ console.log(` Put ${result.putVolume} / Call ${result.callVolume} = ${result.ratio}%`)
304
+ }
305
+
306
+ return result
307
+ }
308
+
309
+
310
+ /**
311
+ * 抓取期交所(TAIFEX)台指期相關資料
312
+ *
313
+ * 序列抓取台指期行情、三大法人期貨未平倉與Put/Call Ratio共三支CSV(每支間隔1秒以降低限流風險);
314
+ * 期交所CSV為Big5(MS950)編碼, 由本函數解碼; 三支之中任一支失敗僅記錄於errors, 三支全失敗才拋錯
315
+ *
316
+ * @param {String} dateStr 輸入日期YYYYMMDD字串
317
+ * @param {Object} [opt={}] 輸入設定物件,預設{}
318
+ * @param {String} [opt.baseUrl='https://www.taifex.com.tw'] 輸入期交所根網址字串,供測試或改指向鏡像時覆寫
319
+ * @param {Integer} [opt.timeout=15000] 輸入單次請求逾時毫秒整數,預設15000
320
+ * @param {Integer} [opt.maxRetries=10] 輸入最大重試次數整數,含初始共執行maxRetries+1次,預設10
321
+ * @param {Integer} [opt.baseDelayMs=5000] 輸入重試之線性退避基礎毫秒整數,預設5000
322
+ * @param {Integer} [opt.maxDelayMs=30000] 輸入重試之線性退避上限毫秒整數,預設30000
323
+ * @param {Integer} [opt.interRequestDelayMs=1000] 輸入三支CSV之間隔毫秒整數,預設1000
324
+ * @param {Boolean} [opt.showLog=true] 輸入是否顯示過程訊息布林值,預設true
325
+ * @returns {Promise} 回傳Promise,resolve回傳結果物件{date,futures,institutional,pcRatio,errors},日期無效或三支資料全失敗時reject回傳錯誤物件
326
+ * @example
327
+ *
328
+ * import fetchTaifex from './src/fetchTaifex.mjs'
329
+ *
330
+ * let test = async () => {
331
+ *
332
+ * let r = await fetchTaifex('20260807')
333
+ * console.log(r.date, r.futures.tx.close, r.pcRatio.ratio, r.errors)
334
+ * // => '20260807' 23456 108.5 []
335
+ *
336
+ * }
337
+ * await test()
338
+ * .catch((err) => {
339
+ * console.log(err)
340
+ * })
341
+ *
342
+ */
343
+ async function fetchTaifex(dateStr, opt = {}) {
344
+
345
+ //check, 函數入口驗日期
346
+ if (!isestr(dateStr) || !/^\d{8}$/.test(dateStr)) {
347
+ throw new Error(`dateStr 須為 YYYYMMDD 字串,得到: ${dateStr}`)
348
+ }
349
+ if (!isYmd(dateStr)) {
350
+ throw new Error(`dateStr 非合法日期: ${dateStr}`)
351
+ }
352
+
353
+ //baseUrl
354
+ let baseUrl = get(opt, 'baseUrl')
355
+ if (!isestr(baseUrl)) {
356
+ baseUrl = BASE_URL
357
+ }
358
+ baseUrl = baseUrl.replace(/\/+$/, '')
359
+
360
+ //cfg
361
+ let optFetch = getOptFetch(opt, DFLT)
362
+ let showLog = optFetch.showLog
363
+ let cfg = { baseUrl, showLog, optFetch }
364
+
365
+ //interRequestDelayMs
366
+ let interRequestDelayMs = get(opt, 'interRequestDelayMs')
367
+ if (!isp0int(interRequestDelayMs)) {
368
+ interRequestDelayMs = DEFAULT_INTER_REQUEST_DELAY_MS
369
+ }
370
+ else {
371
+ interRequestDelayMs = cint(interRequestDelayMs)
372
+ }
373
+
374
+ //dateSlash
375
+ let dateSlash = `${dateStr.substring(0, 4)}/${dateStr.substring(4, 6)}/${dateStr.substring(6, 8)}`
376
+ if (showLog) {
377
+ console.log(`Fetching TAIFEX data for ${dateStr} (${dateSlash})`)
378
+ }
379
+
380
+ let errors = []
381
+ let futures = null
382
+ let institutional = null
383
+ let pcRatio = null
384
+
385
+ try {
386
+ futures = await fetchFuturesData(dateSlash, cfg)
387
+ }
388
+ catch (err) {
389
+ let msg = `台指期行情: ${get(err, 'message', err)}`
390
+ if (showLog) {
391
+ console.error(msg)
392
+ }
393
+ errors.push(msg)
394
+ }
395
+
396
+ await delay(interRequestDelayMs)
397
+
398
+ try {
399
+ institutional = await fetchInstitutionalData(dateSlash, cfg)
400
+ }
401
+ catch (err) {
402
+ let msg = `三大法人: ${get(err, 'message', err)}`
403
+ if (showLog) {
404
+ console.error(msg)
405
+ }
406
+ errors.push(msg)
407
+ }
408
+
409
+ await delay(interRequestDelayMs)
410
+
411
+ try {
412
+ pcRatio = await fetchPCRatio(dateSlash, cfg)
413
+ }
414
+ catch (err) {
415
+ let msg = `Put/Call Ratio: ${get(err, 'message', err)}`
416
+ if (showLog) {
417
+ console.error(msg)
418
+ }
419
+ errors.push(msg)
420
+ }
421
+
422
+ //三支全失敗才視為完全失敗
423
+ let hasAnyData = futures || institutional || pcRatio
424
+ if (!hasAnyData) {
425
+ let err = new Error(`所有資料抓取失敗: ${errors.join('; ')}`)
426
+ err.errors = errors
427
+ throw err
428
+ }
429
+
430
+ return {
431
+ date: dateStr,
432
+ futures: futures ? { tx: futures } : null,
433
+ institutional: institutional || null,
434
+ pcRatio: pcRatio || null,
435
+ errors,
436
+ }
437
+ }
438
+
439
+
440
+ export { parseCSV, parseCSVLine, parseNum }
441
+ export default fetchTaifex