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,190 @@
1
+ import assert from 'assert'
2
+ import fetchGuancha, {
3
+ BASE_URL,
4
+ KNOWN_TOPICS,
5
+ isHomepageHtml,
6
+ parseListPage,
7
+ fetchAuthorsList,
8
+ lookupAuthor,
9
+ lookupTopic,
10
+ fetchAuthorArticles,
11
+ fetchKeywordArticles,
12
+ fetchTitleArticles,
13
+ fetchTopicArticles,
14
+ fetchArticle
15
+ } from '../src/fetchGuancha.mjs'
16
+ import serverForTest from './tools/serverForTest.mjs'
17
+ import { guanchaHomeHtml, guanchaListPage1Html } from './tools/fixtures.mjs'
18
+
19
+
20
+ describe('fetchGuancha', function() {
21
+
22
+ let svr = null
23
+ let optFast = null
24
+
25
+ before(async function() {
26
+ svr = await serverForTest()
27
+ optFast = { baseUrl: svr.url(''), maxRetries: 0, pageDelayMs: 0, showLog: false }
28
+ })
29
+
30
+ after(async function() {
31
+ if (svr) {
32
+ await svr.close()
33
+ }
34
+ })
35
+
36
+ it('模組常數與預設匯出之函數齊備', function() {
37
+ let r = [
38
+ BASE_URL,
39
+ Array.isArray(KNOWN_TOPICS),
40
+ KNOWN_TOPICS.every((t) => typeof t.slug === 'string' && typeof t.name === 'string' && t.slug.length > 0 && t.name.length > 0),
41
+ Object.keys(fetchGuancha).sort(),
42
+ ]
43
+ let rr = [
44
+ 'https://www.guancha.cn',
45
+ true,
46
+ true,
47
+ [
48
+ 'BASE_URL', 'KNOWN_TOPICS', 'MAX_PAGES', 'USER_AGENT', 'fetchArticle', 'fetchAuthorArticles',
49
+ 'fetchAuthorsList', 'fetchKeywordArticles', 'fetchTitleArticles', 'fetchTopicArticles',
50
+ 'lookupAuthor', 'lookupTopic', 'safeFilename',
51
+ ],
52
+ ]
53
+ assert.strict.deepEqual(r, rr)
54
+ })
55
+
56
+ it('KNOWN_TOPICS之分頁版slug排在大頻道版之前, 令lookupTopic之first-match取到可翻頁者', function() {
57
+ let r = [
58
+ lookupTopic('财经').slug,
59
+ lookupTopic('军事').slug,
60
+ lookupTopic('俄罗斯之声').slug,
61
+ lookupTopic('不存在的主題XYZ'),
62
+ lookupTopic(''),
63
+ lookupTopic(null),
64
+ ]
65
+ let rr = ['CaiJing', 'JunShi', 'ELuoSiZhiSheng', null, null, null]
66
+ assert.strict.deepEqual(r, rr)
67
+ })
68
+
69
+ it('isHomepageHtml以A-Z作者索引區塊辨識首頁', function() {
70
+ let r = [
71
+ isHomepageHtml(guanchaHomeHtml),
72
+ isHomepageHtml(guanchaListPage1Html),
73
+ ]
74
+ let rr = [true, false]
75
+ assert.strict.deepEqual(r, rr)
76
+ })
77
+
78
+ it('parseListPage不限定文章URL之slug', function() {
79
+ let r = parseListPage(guanchaListPage1Html, 'https://www.guancha.cn')
80
+ let rr = [
81
+ { url: 'https://www.guancha.cn/AnSheng/2026_08_07_800001.shtml', title: '觀察者網標題一', slug: 'AnSheng' },
82
+ { url: 'https://www.guancha.cn/AnSheng/2026_08_06_800002.shtml', title: '觀察者網標題二', slug: 'AnSheng' },
83
+ ]
84
+ assert.strict.deepEqual(r, rr)
85
+ })
86
+
87
+ it('fetchAuthorsList由首頁A-Z索引解析作者對照', async function() {
88
+ let t = await fetchAuthorsList(optFast)
89
+ let r = [t.length, t[0], lookupAuthor(t, '张维为').slug, lookupAuthor(t, '不存在XYZ')]
90
+ let rr = [6, { slug: 'AnSheng', name: '安生', letter: 'A' }, 'ZhangWeiWei', null]
91
+ assert.strict.deepEqual(r, rr)
92
+ })
93
+
94
+ it('fetchAuthorArticles以name兩跳並自動翻頁至無新項目為止', async function() {
95
+ let t = await fetchAuthorArticles({ ...optFast, name: '安生' })
96
+ let r = [t.status, t.site, t.mode, t.resolved.slug, t.resolved.name, t.resolved.pages_fetched, t.count]
97
+ let rr = ['success', 'guancha', 'author', 'AnSheng', '安生', 3, 3]
98
+ assert.strict.deepEqual(r, rr)
99
+ })
100
+
101
+ it('fetchAuthorArticles以slug捷徑跳過索引查找', async function() {
102
+ let c0 = svr.getCount('/')
103
+ let t = await fetchAuthorArticles({ ...optFast, slug: 'AnSheng' })
104
+ let r = [t.status, t.count, svr.getCount('/') - c0]
105
+ let rr = ['success', 3, 0]
106
+ assert.strict.deepEqual(r, rr)
107
+ })
108
+
109
+ it('fetchAuthorArticles查無作者時回success且count為0並附說明', async function() {
110
+ let t = await fetchAuthorArticles({ ...optFast, name: '完全不存在的作者XYZ123' })
111
+ let r = [t.status, t.count, t.items, t.authors_count, t.message.includes('尚無此作者文章')]
112
+ let rr = ['success', 0, [], 6, true]
113
+ assert.strict.deepEqual(r, rr)
114
+ })
115
+
116
+ it('slug不存在時第1頁被導向首頁, 回status為error且reason為redirected-to-homepage', async function() {
117
+ let t = await fetchAuthorArticles({ ...optFast, slug: 'NotExistSlug' })
118
+ let r = [t.status, t.mode, t.reason, t.error.includes('不存在')]
119
+ let rr = ['error', 'author', 'redirected-to-homepage', true]
120
+ assert.strict.deepEqual(r, rr)
121
+ })
122
+
123
+ it('fetchKeywordArticles與fetchTitleArticles因站方簽名混淆一律回error', async function() {
124
+ let t1 = await fetchKeywordArticles('人工智能', optFast)
125
+ let t2 = await fetchTitleArticles('人工智能', optFast)
126
+ let r = [
127
+ t1.status, t1.mode, t1.error, t1.message.includes('sojson'),
128
+ t2.status, t2.mode, t2.error,
129
+ ]
130
+ let rr = ['error', 'keyword', 'unsupported-by-curl', true, 'error', 'title', 'unsupported-by-curl']
131
+ assert.strict.deepEqual(r, rr)
132
+ })
133
+
134
+ it('fetchTopicArticles以name查對照表後翻頁全抓', async function() {
135
+ let t = await fetchTopicArticles({ ...optFast, name: '财经' })
136
+ let r = [t.status, t.mode, t.resolved.slug, t.count]
137
+ let rr = ['success', 'topic', 'CaiJing', 2]
138
+ assert.strict.deepEqual(r, rr)
139
+ })
140
+
141
+ it('fetchTopicArticles不在對照表時fail-fast', async function() {
142
+ let t = await fetchTopicArticles({ ...optFast, name: '完全不存在的主題ABC123' })
143
+ let r = [t.status, t.mode, t.error, t.topics_count, t.message.includes('slug')]
144
+ let rr = ['error', 'topic', 'topic-not-in-table', KNOWN_TOPICS.length, true]
145
+ assert.strict.deepEqual(r, rr)
146
+ })
147
+
148
+ it('fetchArticle轉為含frontmatter之Markdown, 保留圖片並抽出作者與發布時間', async function() {
149
+ let t = await fetchArticle({ ...optFast, url: svr.url('/AnSheng/2026_08_07_800001.shtml') })
150
+ let r = [
151
+ t.status,
152
+ t.mode,
153
+ t.title,
154
+ t.author,
155
+ t.published,
156
+ t.markdown.startsWith('---\n'),
157
+ t.markdown.includes(`source: "${svr.url('/AnSheng/2026_08_07_800001.shtml')}"`),
158
+ t.markdown.includes('![](https://i.guancha.cn/p1.jpg)'),
159
+ t.markdown.includes('**粗體**'),
160
+ ]
161
+ let rr = ['success', 'fetch', 'OPEC+宣布增产', '文/观察者网 王五', '2026-08-07 09:02:03', true, true, true, true]
162
+ assert.strict.deepEqual(r, rr)
163
+ })
164
+
165
+ it('文章已下架被導向首頁時回status為error且error為article-not-found', async function() {
166
+ let t = await fetchArticle({ ...optFast, url: svr.url('/AnSheng/2026_01_01_000000.shtml') })
167
+ let r = [t.status, t.mode, t.error, t.message.includes('無此文章')]
168
+ let rr = ['error', 'fetch', 'article-not-found', true]
169
+ assert.strict.deepEqual(r, rr)
170
+ })
171
+
172
+ it('抓取失敗時回status為error', async function() {
173
+ let t = await fetchArticle({ ...optFast, url: svr.url('/nothing.shtml') })
174
+ let r = [t.status, t.mode, typeof t.error]
175
+ let rr = ['error', 'fetch', 'string']
176
+ assert.strict.deepEqual(r, rr)
177
+ })
178
+
179
+ it('必要參數缺失時拋錯', async function() {
180
+ let r = []
181
+ await fetchAuthorArticles(optFast).catch((err) => r.push(err.message))
182
+ await fetchKeywordArticles(undefined, optFast).catch((err) => r.push(err.message))
183
+ await fetchTitleArticles(undefined, optFast).catch((err) => r.push(err.message))
184
+ await fetchTopicArticles(optFast).catch((err) => r.push(err.message))
185
+ await fetchArticle(optFast).catch((err) => r.push(err.message))
186
+ let rr = ['需要 name 或 slug', '需要 keyword', '需要 keyword', '需要 name 或 slug', '需要 url']
187
+ assert.strict.deepEqual(r, rr)
188
+ })
189
+
190
+ })
@@ -0,0 +1,78 @@
1
+ import assert from 'assert'
2
+ import fetchHackerNews from '../src/fetchHackerNews.mjs'
3
+ import serverForTest from './tools/serverForTest.mjs'
4
+
5
+
6
+ describe('fetchHackerNews', function() {
7
+
8
+ let svr = null
9
+
10
+ before(async function() {
11
+ svr = await serverForTest()
12
+ })
13
+
14
+ after(async function() {
15
+ if (svr) {
16
+ await svr.close()
17
+ }
18
+ })
19
+
20
+ it('僅保留story, 無url者退回站內討論頁網址', async function() {
21
+ let r = await fetchHackerNews(30, { apiBase: svr.url('/hn'), showLog: false })
22
+ let rr = [
23
+ {
24
+ url: 'https://hn.example/1',
25
+ time: '2026-08-06 15:06:40',
26
+ title: 'HN標題一',
27
+ description: '',
28
+ from: 'Hacker News',
29
+ },
30
+ {
31
+ url: 'https://news.ycombinator.com/item?id=102',
32
+ time: '2026-08-06 15:07:40',
33
+ title: 'HN標題二',
34
+ description: '',
35
+ from: 'Hacker News',
36
+ },
37
+ ]
38
+ assert.strict.deepEqual(r, rr)
39
+ })
40
+
41
+ it('limit限制取回篇數', async function() {
42
+ let t = await fetchHackerNews(1, { apiBase: svr.url('/hn'), showLog: false })
43
+ let r = [t.length, t[0].title]
44
+ let rr = [1, 'HN標題一']
45
+ assert.strict.deepEqual(r, rr)
46
+ })
47
+
48
+ it('limit非正整數時改用預設值30而非回傳空陣列', async function() {
49
+ let r = []
50
+ for (let limit of [0, -1, NaN, null, undefined, 'abc']) {
51
+ let t = await fetchHackerNews(limit, { apiBase: svr.url('/hn'), showLog: false })
52
+ r.push(t.length)
53
+ }
54
+ let rr = [2, 2, 2, 2, 2, 2]
55
+ assert.strict.deepEqual(r, rr)
56
+ })
57
+
58
+ it('apiBase尾端斜線會被正規化', async function() {
59
+ let t = await fetchHackerNews(30, { apiBase: svr.url('/hn') + '/', showLog: false })
60
+ let r = t.length
61
+ let rr = 2
62
+ assert.strict.deepEqual(r, rr)
63
+ })
64
+
65
+ it('取ID清單失敗時reject', async function() {
66
+ let r = null
67
+ await fetchHackerNews(30, { apiBase: svr.url('/nothing'), maxRetries: 0, showLog: false })
68
+ .then(() => {
69
+ r = 'resolved'
70
+ })
71
+ .catch((err) => {
72
+ r = err.statusCode
73
+ })
74
+ let rr = 404
75
+ assert.strict.deepEqual(r, rr)
76
+ })
77
+
78
+ })
@@ -0,0 +1,82 @@
1
+ import assert from 'assert'
2
+ import fetchMoneydj from '../src/fetchMoneydj.mjs'
3
+ import serverForTest from './tools/serverForTest.mjs'
4
+
5
+
6
+ describe('fetchMoneydj', function() {
7
+
8
+ let svr = null
9
+
10
+ //optFast, 令重試與頁間等待縮至最短, 避免測試耗時
11
+ let optFast = { baseDelayMs: 10, maxDelayMs: 10, pageDelayMs: 0, showLog: false }
12
+
13
+ before(async function() {
14
+ svr = await serverForTest()
15
+ })
16
+
17
+ after(async function() {
18
+ if (svr) {
19
+ await svr.close()
20
+ }
21
+ })
22
+
23
+ it('逐頁抓取並合併, 相對路徑連結補上列表頁origin', async function() {
24
+ let r = await fetchMoneydj({ ...optFast, baseUrl: svr.url('/moneydj/newsreallist.aspx?a=mb06&index1='), totalPages: 2 })
25
+ let rr = [
26
+ { time: '08/07 09:02', title: 'MoneyDJ標題一', link: svr.url('/kmdj/news/newsviewer.aspx?a=m1') },
27
+ { time: '09:05', title: 'MoneyDJ標題二', link: 'https://www.moneydj.com/m2' },
28
+ { time: '昨 23:59', title: 'MoneyDJ標題三', link: svr.url('/kmdj/news/newsviewer.aspx?a=m4') },
29
+ ]
30
+ assert.strict.deepEqual(r, rr)
31
+ })
32
+
33
+ it('時間格式不符之列被略過', async function() {
34
+ let t = await fetchMoneydj({ ...optFast, baseUrl: svr.url('/moneydj/newsreallist.aspx?a=mb06&index1='), totalPages: 1 })
35
+ let r = [t.length, t.some((v) => v.title === '不應出現')]
36
+ let rr = [2, false]
37
+ assert.strict.deepEqual(r, rr)
38
+ })
39
+
40
+ it('totalPages控制抓取頁數', async function() {
41
+ let c0 = svr.getCount('/moneydj/newsreallist.aspx')
42
+ await fetchMoneydj({ ...optFast, baseUrl: svr.url('/moneydj/newsreallist.aspx?a=mb06&index1='), totalPages: 3 })
43
+ let r = svr.getCount('/moneydj/newsreallist.aspx') - c0
44
+ let rr = 3
45
+ assert.strict.deepEqual(r, rr)
46
+ })
47
+
48
+ it('onPageDone於每頁完成時被呼叫', async function() {
49
+ let rs = []
50
+ await fetchMoneydj({
51
+ ...optFast,
52
+ baseUrl: svr.url('/moneydj/newsreallist.aspx?a=mb06&index1='),
53
+ totalPages: 2,
54
+ onPageDone: (pageIndex, itemCount, totalPages) => {
55
+ rs.push([pageIndex, itemCount, totalPages])
56
+ },
57
+ })
58
+ let rr = [[1, 2, 2], [2, 1, 2]]
59
+ assert.strict.deepEqual(rs, rr)
60
+ })
61
+
62
+ it('單頁抓取失敗時跳過該頁不中斷整體抓取', async function() {
63
+ let t = await fetchMoneydj({ ...optFast, baseUrl: svr.url('/moneydj-flaky/newsreallist.aspx?index1='), totalPages: 2, maxRetries: 0 })
64
+ let r = [t.length, t[0].title]
65
+ let rr = [1, 'MoneyDJ標題三']
66
+ assert.strict.deepEqual(r, rr)
67
+ })
68
+
69
+ it('全部頁面皆0筆時reject', async function() {
70
+ let r = null
71
+ await fetchMoneydj({ ...optFast, baseUrl: svr.url('/moneydj-empty/newsreallist.aspx?index1='), totalPages: 2 })
72
+ .then(() => {
73
+ r = 'resolved'
74
+ })
75
+ .catch((err) => {
76
+ r = err.message.startsWith('抓取到 0 筆新聞')
77
+ })
78
+ let rr = true
79
+ assert.strict.deepEqual(r, rr)
80
+ })
81
+
82
+ })
@@ -0,0 +1,106 @@
1
+ import assert from 'assert'
2
+ import fetchMops, { MARKET_KINDS, getPayload } from '../src/fetchMops.mjs'
3
+ import serverForTest from './tools/serverForTest.mjs'
4
+ import hasChrome from './tools/hasChrome.mjs'
5
+
6
+
7
+ describe('fetchMops', function() {
8
+
9
+ let svr = null
10
+ let chromeOk = false
11
+
12
+ before(async function() {
13
+ this.timeout(120000)
14
+ svr = await serverForTest()
15
+ chromeOk = await hasChrome()
16
+ })
17
+
18
+ after(async function() {
19
+ if (svr) {
20
+ await svr.close()
21
+ }
22
+ })
23
+
24
+ it('目標市場別為上市、上櫃、興櫃與公開發行共4種', function() {
25
+ let r = MARKET_KINDS
26
+ let rr = [
27
+ { name: '上市', marketKind: 'sii' },
28
+ { name: '上櫃', marketKind: 'otc' },
29
+ { name: '興櫃', marketKind: 'rotc' },
30
+ { name: '公開發行', marketKind: 'pub' },
31
+ ]
32
+ assert.strict.deepEqual(r, rr)
33
+ })
34
+
35
+ it('查詢payload帶入指定市場別且其餘欄位固定', function() {
36
+ let r = getPayload('sii')
37
+ let rr = {
38
+ scopeType: '2',
39
+ companyId: '',
40
+ dateType: '2',
41
+ firstDate: '',
42
+ lastDate: '',
43
+ marketKind: 'sii',
44
+ announcementBasis: '0',
45
+ dateRangeType: '1',
46
+ announcementType: '1',
47
+ sort: '1',
48
+ encodeURIComponent: 1,
49
+ step: 1,
50
+ firstin: 1,
51
+ off: 1,
52
+ }
53
+ assert.strict.deepEqual(r, rr)
54
+ })
55
+
56
+ it('逐一查詢4種市場別並回傳結果與hasError', async function() {
57
+ if (!chromeOk) {
58
+ this.skip()
59
+ }
60
+ this.timeout(180000)
61
+ let t = await fetchMops({
62
+ pageUrl: svr.url('/mops/page'),
63
+ apiUrl: svr.url('/mops/api/t146sb10'),
64
+ maxRetries: 0,
65
+ showLog: false,
66
+ })
67
+ let r = [
68
+ t.hasError,
69
+ t.results.length,
70
+ t.results.map((v) => v.market),
71
+ t.results.map((v) => v.marketKind),
72
+ t.results[0].data.result[0].companyId,
73
+ t.results[2].data.code,
74
+ ]
75
+ let rr = [
76
+ false,
77
+ 4,
78
+ ['上市', '上櫃', '興櫃', '公開發行'],
79
+ ['sii', 'otc', 'rotc', 'pub'],
80
+ '2330',
81
+ 406,
82
+ ]
83
+ assert.strict.deepEqual(r, rr)
84
+ })
85
+
86
+ it('application-level錯誤(code非200亦非406)時該筆帶error且hasError為true', async function() {
87
+ if (!chromeOk) {
88
+ this.skip()
89
+ }
90
+ this.timeout(180000)
91
+ let t = await fetchMops({
92
+ pageUrl: svr.url('/mops/page'),
93
+ apiUrl: svr.url('/mops-error/api/t146sb10'),
94
+ maxRetries: 0,
95
+ showLog: false,
96
+ })
97
+ let r = [
98
+ t.hasError,
99
+ t.results.length,
100
+ t.results.every((v) => String(v.error).startsWith('MOPS code 500')),
101
+ ]
102
+ let rr = [true, 4, true]
103
+ assert.strict.deepEqual(r, rr)
104
+ })
105
+
106
+ })
@@ -0,0 +1,85 @@
1
+ import assert from 'assert'
2
+ import fetchRSS from '../src/fetchRSS.mjs'
3
+ import serverForTest from './tools/serverForTest.mjs'
4
+
5
+
6
+ describe('fetchRSS', function() {
7
+
8
+ let svr = null
9
+
10
+ before(async function() {
11
+ svr = await serverForTest()
12
+ })
13
+
14
+ after(async function() {
15
+ if (svr) {
16
+ await svr.close()
17
+ }
18
+ })
19
+
20
+ it('非http與https網址拋錯', async function() {
21
+ let r = []
22
+ for (let url of [null, undefined, '', 123, 'abc', 'ftp://a.com/feed.xml']) {
23
+ await fetchRSS(url)
24
+ .then(() => {
25
+ r.push('resolved')
26
+ })
27
+ .catch((err) => {
28
+ r.push(err.message)
29
+ })
30
+ }
31
+ let rr = [
32
+ 'rssUrl 須為有效的 http/https RSS 網址',
33
+ 'rssUrl 須為有效的 http/https RSS 網址',
34
+ 'rssUrl 須為有效的 http/https RSS 網址',
35
+ 'rssUrl 須為有效的 http/https RSS 網址',
36
+ 'rssUrl 須為有效的 http/https RSS 網址',
37
+ 'rssUrl 須為有效的 http/https RSS 網址',
38
+ ]
39
+ assert.strict.deepEqual(r, rr)
40
+ })
41
+
42
+ it('解析RSS並轉為統一格式, from優先取creator否則退回訂閱源標題', async function() {
43
+ let r = await fetchRSS(svr.url('/rss/feed.xml'), { showLog: false })
44
+ let rr = [
45
+ {
46
+ url: 'https://rss.example/1',
47
+ time: '2026-08-07 09:02:03',
48
+ title: 'RSS標題一',
49
+ description: '描述一',
50
+ from: '作者甲',
51
+ },
52
+ {
53
+ url: 'https://rss.example/2',
54
+ time: '2026-08-07 10:03:04',
55
+ title: 'RSS標題二',
56
+ description: '描述二',
57
+ from: '測試訂閱源',
58
+ },
59
+ ]
60
+ assert.strict.deepEqual(r, rr)
61
+ })
62
+
63
+ it('HTTP 404納入重試範圍, 含初始共執行maxRetries+1次', async function() {
64
+ let c0 = svr.getCount('/status/404')
65
+ await fetchRSS(svr.url('/status/404'), { maxRetries: 2, baseDelayMs: 10, maxDelayMs: 10, showLog: false })
66
+ .catch(() => {})
67
+ let r = svr.getCount('/status/404') - c0
68
+ let rr = 3
69
+ assert.strict.deepEqual(r, rr)
70
+ })
71
+
72
+ it('內容非XML時reject', async function() {
73
+ let r = null
74
+ await fetchRSS(svr.url('/json/ok'), { maxRetries: 0, showLog: false })
75
+ .then(() => {
76
+ r = 'resolved'
77
+ })
78
+ .catch(() => {
79
+ r = 'rejected'
80
+ })
81
+ let rr = 'rejected'
82
+ assert.strict.deepEqual(r, rr)
83
+ })
84
+
85
+ })
@@ -0,0 +1,69 @@
1
+ import assert from 'assert'
2
+ import fetchStatementdog from '../src/fetchStatementdog.mjs'
3
+ import serverForTest from './tools/serverForTest.mjs'
4
+
5
+
6
+ describe('fetchStatementdog', function() {
7
+
8
+ let svr = null
9
+
10
+ //optFast, 令重試等待縮至10毫秒, 避免測試耗時
11
+ let optFast = { baseDelayMs: 10, maxDelayMs: 10, showLog: false }
12
+
13
+ before(async function() {
14
+ svr = await serverForTest()
15
+ })
16
+
17
+ after(async function() {
18
+ if (svr) {
19
+ await svr.close()
20
+ }
21
+ })
22
+
23
+ it('以主要selector解析, 相對路徑連結補上來源網址origin', async function() {
24
+ let r = await fetchStatementdog({ ...optFast, url: svr.url('/statementdog/news/latest') })
25
+ let rr = [
26
+ { time: '2026-08-07', title: '財報狗標題一', link: svr.url('/news/n1') },
27
+ { time: '2026-08-06', title: '財報狗標題二', link: 'https://other.example/n2' },
28
+ ]
29
+ assert.strict.deepEqual(r, rr)
30
+ })
31
+
32
+ it('無標題之項目被略過', async function() {
33
+ let t = await fetchStatementdog({ ...optFast, url: svr.url('/statementdog/news/latest') })
34
+ let r = t.length
35
+ let rr = 2
36
+ assert.strict.deepEqual(r, rr)
37
+ })
38
+
39
+ it('主要selector未匹配時改用備援selector', async function() {
40
+ let t = await fetchStatementdog({ ...optFast, url: svr.url('/statementdog-fallback/news/latest') })
41
+ let r = [t.length, t[0].title, t[0].link]
42
+ let rr = [2, '備援標題一', svr.url('/news/f1')]
43
+ assert.strict.deepEqual(r, rr)
44
+ })
45
+
46
+ it('解析到0筆時reject且不重試', async function() {
47
+ let c0 = svr.getCount('/statementdog-empty/news/latest')
48
+ let r = null
49
+ await fetchStatementdog({ ...optFast, url: svr.url('/statementdog-empty/news/latest'), maxRetries: 3 })
50
+ .then(() => {
51
+ r = 'resolved'
52
+ })
53
+ .catch((err) => {
54
+ r = [err.message.startsWith('抓取到 0 筆新聞'), svr.getCount('/statementdog-empty/news/latest') - c0]
55
+ })
56
+ let rr = [true, 1]
57
+ assert.strict.deepEqual(r, rr)
58
+ })
59
+
60
+ it('HTTP 403納入重試範圍, 含初始共執行maxRetries+1次', async function() {
61
+ let c0 = svr.getCount('/status/403')
62
+ await fetchStatementdog({ ...optFast, url: svr.url('/status/403'), maxRetries: 2 })
63
+ .catch(() => {})
64
+ let r = svr.getCount('/status/403') - c0
65
+ let rr = 3
66
+ assert.strict.deepEqual(r, rr)
67
+ })
68
+
69
+ })