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,966 @@
1
+ import get from 'lodash-es/get.js'
2
+ import isestr from 'wsemi/src/isestr.mjs'
3
+ import ispint from 'wsemi/src/ispint.mjs'
4
+ import isp0int from 'wsemi/src/isp0int.mjs'
5
+ import cint from 'wsemi/src/cint.mjs'
6
+ import delay from 'wsemi/src/delay.mjs'
7
+ import fetchWithRetry from './fetchWithRetry.mjs'
8
+ import getOptFetch from './getOptFetch.mjs'
9
+ import decodeEntities from './decodeEntities.mjs'
10
+ import htmlToMarkdown from './htmlToMarkdown.mjs'
11
+ import extractDivContent from './extractDivContent.mjs'
12
+ import safeFilename from './safeFilename.mjs'
13
+
14
+
15
+ //愛思想網站根網址
16
+ let BASE_URL = 'https://www.aisixiang.com'
17
+
18
+
19
+ //愛思想對User-Agent較敏感, 故固定送出桌面瀏覽器標頭
20
+ let USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' +
21
+ '(KHTML, like Gecko) Chrome/120.0 Safari/537.36'
22
+
23
+
24
+ //頁間延遲毫秒, 降低被封鎖機率
25
+ let PAGE_DELAY_MS = 1000
26
+
27
+
28
+ //翻頁安全上限, 50頁×30筆=1500筆
29
+ let MAX_PAGES = 50
30
+
31
+
32
+ //本模組之預設抓取設定
33
+ let DFLT = {
34
+ responseType: 'text',
35
+ headers: { 'User-Agent': USER_AGENT, 'Accept-Language': 'zh-CN,zh;q=0.9' },
36
+ timeout: 30000,
37
+ maxRetries: 5,
38
+ baseDelayMs: 5000,
39
+ maxDelayMs: 30000,
40
+ label: 'fetch-aisixiang',
41
+ }
42
+
43
+
44
+ /**
45
+ * 正規化本模組共用設定
46
+ *
47
+ * @param {Object} [opt={}] 輸入設定物件,預設{}
48
+ * @returns {Object} 回傳已正規化之設定物件,內含baseUrl、showLog與optFetch
49
+ */
50
+ function getCfg(opt = {}) {
51
+
52
+ //baseUrl
53
+ let baseUrl = get(opt, 'baseUrl')
54
+ if (!isestr(baseUrl)) {
55
+ baseUrl = BASE_URL
56
+ }
57
+ baseUrl = baseUrl.replace(/\/+$/, '')
58
+
59
+ //optFetch
60
+ let optFetch = getOptFetch(opt, DFLT)
61
+
62
+ //pageDelayMs
63
+ let pageDelayMs = get(opt, 'pageDelayMs')
64
+ if (!isp0int(pageDelayMs)) {
65
+ pageDelayMs = PAGE_DELAY_MS
66
+ }
67
+ else {
68
+ pageDelayMs = cint(pageDelayMs)
69
+ }
70
+
71
+ //maxPages
72
+ let maxPages = get(opt, 'maxPages')
73
+ if (!ispint(maxPages)) {
74
+ maxPages = MAX_PAGES
75
+ }
76
+ else {
77
+ maxPages = Math.min(cint(maxPages), MAX_PAGES)
78
+ }
79
+
80
+ return { baseUrl, showLog: optFetch.showLog, pageDelayMs, maxPages, optFetch }
81
+ }
82
+
83
+
84
+ /**
85
+ * 由HTML抽出title標籤內容
86
+ *
87
+ * @param {String} html 輸入HTML字串
88
+ * @returns {String|null} 回傳標題字串,無title標籤時回傳null
89
+ */
90
+ function extractTitle(html) {
91
+ let m = html.match(/<title>([\s\S]*?)<\/title>/i)
92
+ return m ? decodeEntities(m[1].trim()) : null
93
+ }
94
+
95
+
96
+ /**
97
+ * 解析作者專欄頁之文章清單
98
+ *
99
+ * 專欄頁以h3標記分類(论文、时评、随笔等), 分類區塊內為/data/<aid>.html之文章連結
100
+ *
101
+ * @param {String} html 輸入作者專欄頁HTML字串
102
+ * @param {String} baseUrl 輸入網站根網址字串
103
+ * @returns {Array} 回傳文章物件陣列,各物件為{aid,url,title,category}
104
+ */
105
+ function parseAuthorColumnPage(html, baseUrl) {
106
+
107
+ let cats = ['论文', '时评', '随笔', '著作', '演讲', '读书', '访谈', '未分类']
108
+ let positions = []
109
+ for (let cat of cats) {
110
+ let re = new RegExp(`<h3>${cat}<\\/h3>`, 'g')
111
+ let m
112
+ while ((m = re.exec(html)) !== null) {
113
+ positions.push({ cat, pos: m.index })
114
+ }
115
+ }
116
+ positions.sort((a, b) => a.pos - b.pos)
117
+ positions.push({ cat: null, pos: html.length })
118
+
119
+ let items = []
120
+ for (let i = 0; i < positions.length - 1; i++) {
121
+ let { cat, pos } = positions[i]
122
+ let block = html.slice(pos, positions[i + 1].pos)
123
+ let linkRe = /<a\s+href="\/data\/(\d+)\.html"[^>]*>([\s\S]*?)<\/a>/g
124
+ let m
125
+ while ((m = linkRe.exec(block)) !== null) {
126
+ let title = m[2].replace(/<[^>]+>/g, '').trim()
127
+ if (!title) {
128
+ continue
129
+ }
130
+ items.push({
131
+ aid: m[1],
132
+ url: `${baseUrl}/data/${m[1]}.html`,
133
+ title,
134
+ category: cat,
135
+ })
136
+ }
137
+ }
138
+
139
+ return items
140
+ }
141
+
142
+
143
+ /**
144
+ * 解析搜尋結果頁之文章清單
145
+ *
146
+ * 精確切出search_list區塊再解析, 避免0結果時誤抓側邊欄連結
147
+ *
148
+ * @param {String} html 輸入搜尋結果頁HTML字串
149
+ * @param {String} baseUrl 輸入網站根網址字串
150
+ * @returns {Array} 回傳文章物件陣列,各物件為{aid,url,title,author}
151
+ */
152
+ function parseSearchResults(html, baseUrl) {
153
+
154
+ let block = extractDivContent(html, 'search_list')
155
+ if (block === null) {
156
+ return []
157
+ }
158
+
159
+ let items = []
160
+ let seen = new Set()
161
+ let re = /href="\/data\/(\d+)\.html"[^>]*title="([^"]+)"/g
162
+ let m
163
+ while ((m = re.exec(block)) !== null) {
164
+ if (seen.has(m[1])) {
165
+ continue
166
+ }
167
+ seen.add(m[1])
168
+ let raw = m[2]
169
+ let idx = raw.indexOf(':')
170
+ items.push({
171
+ aid: m[1],
172
+ url: `${baseUrl}/data/${m[1]}.html`,
173
+ title: idx > 0 ? raw.slice(idx + 1).trim() : raw.trim(),
174
+ author: idx > 0 ? raw.slice(0, idx).trim() : '',
175
+ })
176
+ }
177
+
178
+ return items
179
+ }
180
+
181
+
182
+ /**
183
+ * 解析策展主題頁之文章清單
184
+ *
185
+ * 主題頁之連結無title屬性, 標題在anchor內文, 格式為<a>作者:標題</a>
186
+ *
187
+ * @param {String} html 輸入主題頁HTML字串
188
+ * @param {String} baseUrl 輸入網站根網址字串
189
+ * @returns {Array} 回傳文章物件陣列,各物件為{aid,url,title,author}
190
+ */
191
+ function parseZhuantiArticles(html, baseUrl) {
192
+
193
+ let m = html.match(/([\s\S]+?)<div class="list_page">/)
194
+ let block = m ? m[1] : html
195
+
196
+ let items = []
197
+ let seen = new Set()
198
+ let re = /<a\s+href="\/data\/(\d+)\.html"[^>]*>([^<]+)<\/a>/g
199
+ let mm
200
+ while ((mm = re.exec(block)) !== null) {
201
+ if (seen.has(mm[1])) {
202
+ continue
203
+ }
204
+ seen.add(mm[1])
205
+ let raw = mm[2].trim()
206
+ let idx = raw.indexOf(':')
207
+ items.push({
208
+ aid: mm[1],
209
+ url: `${baseUrl}/data/${mm[1]}.html`,
210
+ title: idx > 0 ? raw.slice(idx + 1).trim() : raw,
211
+ author: idx > 0 ? raw.slice(0, idx).trim() : '',
212
+ })
213
+ }
214
+
215
+ return items
216
+ }
217
+
218
+
219
+ /**
220
+ * 由list_page區塊解析最大頁碼
221
+ *
222
+ * @param {String} html 輸入列表頁HTML字串
223
+ * @returns {Integer} 回傳總頁數整數,無分頁區塊時回傳1
224
+ */
225
+ function parseSearchTotalPages(html) {
226
+
227
+ let m = html.match(/<div class="list_page">([\s\S]+?)<\/div>/)
228
+ if (!m) {
229
+ return 1
230
+ }
231
+
232
+ let nums = [...m[1].matchAll(/page=(\d+)/g)].map((x) => Number(x[1]))
233
+
234
+ return nums.length ? Math.max(...nums) : 1
235
+ }
236
+
237
+
238
+ /**
239
+ * 自動翻頁抓取全部列表頁
240
+ *
241
+ * 由第1頁解析總頁數後逐頁抓取, 頁間延遲1秒; 該頁無新項目即停止翻頁, 並以MAX_PAGES為安全上限
242
+ *
243
+ * @param {Function} buildUrl 輸入回傳指定頁碼網址之函數
244
+ * @param {Function} parser 輸入解析單頁HTML為項目陣列之函數
245
+ * @param {Object} cfg 輸入已正規化之設定物件
246
+ * @returns {Promise} 回傳Promise,resolve回傳{items,total_pages,pages_fetched}
247
+ */
248
+ async function fetchAllSearchPages(buildUrl, parser, cfg) {
249
+
250
+ let all = []
251
+ let seen = new Set()
252
+ let totalPages = 1
253
+ let pagesFetched = 0
254
+
255
+ for (let page = 1; page <= cfg.maxPages; page++) {
256
+
257
+ let url = buildUrl(page)
258
+ if (cfg.showLog) {
259
+ process.stderr.write(`[info] fetching page ${page}${totalPages > 1 ? `/${totalPages}` : ''} ...\n`)
260
+ }
261
+ let html = await fetchWithRetry(url, cfg.optFetch)
262
+ pagesFetched++
263
+
264
+ if (page === 1) {
265
+ totalPages = parseSearchTotalPages(html)
266
+ }
267
+
268
+ let items = parser(html, cfg.baseUrl)
269
+ let fresh = items.filter((it) => !seen.has(it.aid))
270
+ if (fresh.length === 0) {
271
+ if (cfg.showLog) {
272
+ process.stderr.write(`[info] page ${page} 無新項目,停止翻頁\n`)
273
+ }
274
+ break
275
+ }
276
+
277
+ for (let it of fresh) {
278
+ seen.add(it.aid)
279
+ }
280
+ all.push(...fresh)
281
+
282
+ if (page >= totalPages) {
283
+ break
284
+ }
285
+ await delay(cfg.pageDelayMs)
286
+
287
+ }
288
+
289
+ return { items: all, total_pages: totalPages, pages_fetched: pagesFetched }
290
+ }
291
+
292
+
293
+ /**
294
+ * 抓取愛思想專欄作者清單
295
+ *
296
+ * 即時解析/thinktank/頁面, 回傳約960位專欄作者之slug與中文名
297
+ *
298
+ * @param {Object} [opt={}] 輸入設定物件,預設{}
299
+ * @param {String} [opt.baseUrl='https://www.aisixiang.com'] 輸入網站根網址字串,供測試或改指向鏡像時覆寫
300
+ * @param {Integer} [opt.timeout=30000] 輸入單次請求逾時毫秒整數,預設30000
301
+ * @param {Integer} [opt.maxRetries=5] 輸入最大重試次數整數,預設5
302
+ * @param {Integer} [opt.pageDelayMs=1000] 輸入翻頁之頁間延遲毫秒整數,預設1000
303
+ * @param {Integer} [opt.maxPages=50] 輸入翻頁上限正整數,上限為50,預設50
304
+ * @param {Boolean} [opt.showLog=true] 輸入是否顯示過程訊息布林值,預設true
305
+ * @returns {Promise} 回傳Promise,resolve回傳作者物件陣列,各物件為{slug,name}
306
+ * @example
307
+ *
308
+ * import { fetchAuthorsList } from './src/fetchAisixiang.mjs'
309
+ *
310
+ * let test = async () => {
311
+ *
312
+ * let rs = await fetchAuthorsList()
313
+ * console.log(rs.length, rs[0])
314
+ * // => 963 { slug: 'gezhaoguang', name: '葛兆光' }
315
+ *
316
+ * }
317
+ * await test()
318
+ * .catch((err) => {
319
+ * console.log(err)
320
+ * })
321
+ *
322
+ */
323
+ async function fetchAuthorsList(opt = {}) {
324
+
325
+ let cfg = getCfg(opt)
326
+
327
+ let html = await fetchWithRetry(`${cfg.baseUrl}/thinktank/`, cfg.optFetch)
328
+
329
+ let matches = [...html.matchAll(/href="\/thinktank\/([^"]+?)\.html"[^>]*>([^<]+)</g)]
330
+ let uniq = new Map()
331
+ for (let [, slug, rawName] of matches) {
332
+ let key = slug.toLowerCase()
333
+ if (uniq.has(key)) {
334
+ continue
335
+ }
336
+ let name = rawName.split(/[,、]/)[0].trim()
337
+ if (!name || name.length > 30) {
338
+ continue
339
+ }
340
+ uniq.set(key, { slug, name })
341
+ }
342
+
343
+ return [...uniq.values()]
344
+ }
345
+
346
+
347
+ /**
348
+ * 由作者清單查找指定中文名之作者
349
+ *
350
+ * 本函數不轉繁簡, 呼叫端須自行使用站方登錄字形(通常為簡體)
351
+ *
352
+ * @param {Array} authors 輸入作者物件陣列
353
+ * @param {String} name 輸入作者中文名字串
354
+ * @returns {Object|null} 回傳作者物件,查無時回傳null
355
+ * @example
356
+ *
357
+ * import { lookupAuthor } from './src/fetchAisixiang.mjs'
358
+ *
359
+ * console.log(lookupAuthor([{ slug: 'gezhaoguang', name: '葛兆光' }], '葛兆光'))
360
+ * // => { slug: 'gezhaoguang', name: '葛兆光' }
361
+ *
362
+ */
363
+ function lookupAuthor(authors, name) {
364
+
365
+ if (!Array.isArray(authors)) {
366
+ return null
367
+ }
368
+
369
+ return authors.find((a) => a.name === name) || null
370
+ }
371
+
372
+
373
+ /**
374
+ * 抓取愛思想策展主題清單
375
+ *
376
+ * 即時解析/zhuanti/頁面, 回傳約800個策展主題之id、名稱與分類(学科、事件、人物)
377
+ *
378
+ * @param {Object} [opt={}] 輸入設定物件,預設{}
379
+ * @param {String} [opt.baseUrl='https://www.aisixiang.com'] 輸入網站根網址字串,供測試或改指向鏡像時覆寫
380
+ * @param {Integer} [opt.timeout=30000] 輸入單次請求逾時毫秒整數,預設30000
381
+ * @param {Integer} [opt.maxRetries=5] 輸入最大重試次數整數,預設5
382
+ * @param {Integer} [opt.pageDelayMs=1000] 輸入翻頁之頁間延遲毫秒整數,預設1000
383
+ * @param {Integer} [opt.maxPages=50] 輸入翻頁上限正整數,上限為50,預設50
384
+ * @param {Boolean} [opt.showLog=true] 輸入是否顯示過程訊息布林值,預設true
385
+ * @returns {Promise} 回傳Promise,resolve回傳主題物件陣列,各物件為{id,name,category}
386
+ * @example
387
+ *
388
+ * import { fetchTopicsList } from './src/fetchAisixiang.mjs'
389
+ *
390
+ * let test = async () => {
391
+ *
392
+ * let rs = await fetchTopicsList()
393
+ * console.log(rs.length, rs[0])
394
+ * // => 803 { id: '301', name: '大数据', category: '学科' }
395
+ *
396
+ * }
397
+ * await test()
398
+ * .catch((err) => {
399
+ * console.log(err)
400
+ * })
401
+ *
402
+ */
403
+ async function fetchTopicsList(opt = {}) {
404
+
405
+ let cfg = getCfg(opt)
406
+
407
+ let html = await fetchWithRetry(`${cfg.baseUrl}/zhuanti/`, cfg.optFetch)
408
+
409
+ let cats = [
410
+ { name: '学科', start: html.indexOf('<h3>学科关键词</h3>') },
411
+ { name: '事件', start: html.indexOf('<h3>事件关键词</h3>') },
412
+ { name: '人物', start: html.indexOf('<h3>人物关键词</h3>') },
413
+ ].filter((c) => c.start >= 0).sort((a, b) => a.start - b.start)
414
+ cats.push({ name: null, start: html.length })
415
+
416
+ let seen = new Set()
417
+ let topics = []
418
+ for (let i = 0; i < cats.length - 1; i++) {
419
+ let block = html.slice(cats[i].start, cats[i + 1].start)
420
+ let re = /href="\/zhuanti\/(\d+)\.html"[^>]*>([^<]+)</g
421
+ let m
422
+ while ((m = re.exec(block)) !== null) {
423
+ if (seen.has(m[1])) {
424
+ continue
425
+ }
426
+ seen.add(m[1])
427
+ let name = m[2].trim().replace(/^\[|\]$/g, '').trim()
428
+ if (!name || name.length > 50) {
429
+ continue
430
+ }
431
+ topics.push({ id: m[1], name, category: cats[i].name })
432
+ }
433
+ }
434
+
435
+ return topics
436
+ }
437
+
438
+
439
+ /**
440
+ * 由主題清單查找指定名稱之主題
441
+ *
442
+ * @param {Array} topics 輸入主題物件陣列
443
+ * @param {String} name 輸入主題名稱字串
444
+ * @returns {Object|null} 回傳主題物件,查無時回傳null
445
+ * @example
446
+ *
447
+ * import { lookupTopic } from './src/fetchAisixiang.mjs'
448
+ *
449
+ * console.log(lookupTopic([{ id: '301', name: '大数据' }], '大数据'))
450
+ * // => { id: '301', name: '大数据' }
451
+ *
452
+ */
453
+ function lookupTopic(topics, name) {
454
+
455
+ if (!Array.isArray(topics)) {
456
+ return null
457
+ }
458
+
459
+ return topics.find((t) => t.name === name) || null
460
+ }
461
+
462
+
463
+ /**
464
+ * 抓取愛思想指定作者之文章清單
465
+ *
466
+ * 給name時先抓站方作者清單解析出slug再抓專欄頁(兩跳), 給slug則直接抓專欄頁;
467
+ * 查無此作者或該欄頁0篇時仍回status為'success'且count為0, 並以message說明, 以與抓取失敗區分
468
+ *
469
+ * @param {Object} [opt={}] 輸入設定物件,預設{}
470
+ * @param {String} [opt.name] 輸入作者中文名字串,與slug擇一必填
471
+ * @param {String} [opt.slug] 輸入作者slug字串,與name擇一必填
472
+ * @param {String} [opt.baseUrl='https://www.aisixiang.com'] 輸入網站根網址字串,供測試或改指向鏡像時覆寫
473
+ * @param {Integer} [opt.timeout=30000] 輸入單次請求逾時毫秒整數,預設30000
474
+ * @param {Integer} [opt.maxRetries=5] 輸入最大重試次數整數,預設5
475
+ * @param {Integer} [opt.pageDelayMs=1000] 輸入翻頁之頁間延遲毫秒整數,預設1000
476
+ * @param {Integer} [opt.maxPages=50] 輸入翻頁上限正整數,上限為50,預設50
477
+ * @param {Boolean} [opt.showLog=true] 輸入是否顯示過程訊息布林值,預設true
478
+ * @returns {Promise} 回傳Promise,resolve回傳結果物件{status,site,mode,query,resolved,fetched_at,count,items},name與slug皆未給時reject回傳錯誤物件
479
+ * @example
480
+ *
481
+ * import { fetchAuthorArticles } from './src/fetchAisixiang.mjs'
482
+ *
483
+ * let test = async () => {
484
+ *
485
+ * let r = await fetchAuthorArticles({ name: '葛兆光' })
486
+ * console.log(r.status, r.count, r.resolved.slug)
487
+ * // => 'success' 123 'gezhaoguang'
488
+ *
489
+ * }
490
+ * await test()
491
+ * .catch((err) => {
492
+ * console.log(err)
493
+ * })
494
+ *
495
+ */
496
+ async function fetchAuthorArticles(opt = {}) {
497
+
498
+ let cfg = getCfg(opt)
499
+
500
+ //「有提供才檢」, 傳了但非有效字串視為未提供, 讓下方「至少一個」檢查接手
501
+ let name = get(opt, 'name')
502
+ if (!isestr(name)) {
503
+ name = ''
504
+ }
505
+ let slug = get(opt, 'slug')
506
+ if (!isestr(slug)) {
507
+ slug = ''
508
+ }
509
+ if (!name && !slug) {
510
+ throw new Error('需要 name 或 slug')
511
+ }
512
+
513
+ let resolved = null
514
+ let chineseName = name
515
+ let usedSlug = slug
516
+
517
+ if (!usedSlug) {
518
+
519
+ if (cfg.showLog) {
520
+ process.stderr.write('[info] fetching authors list from /thinktank/ ...\n')
521
+ }
522
+ let authors = await fetchAuthorsList(opt)
523
+ let author = lookupAuthor(authors, name)
524
+ if (!author) {
525
+ //抓到真實作者清單且確定無此作者, 屬成功查詢、結果0筆(非抓取失敗)
526
+ return {
527
+ status: 'success',
528
+ site: 'aisixiang',
529
+ mode: 'author',
530
+ query: name,
531
+ fetched_at: new Date().toISOString(),
532
+ authors_count: authors.length,
533
+ count: 0,
534
+ items: [],
535
+ message:
536
+ `"${name}" 不在愛思想專欄作者清單中(共 ${authors.length} 位)。` +
537
+ `尚無此作者文章(此為查詢成功的真實結果、0 筆,非抓取失敗)。` +
538
+ `提醒:本函數不轉簡繁,呼叫端負責用站方登錄字形(通常是簡體)。`,
539
+ }
540
+ }
541
+
542
+ usedSlug = author.slug
543
+ chineseName = chineseName || author.name
544
+ resolved = { slug: usedSlug, url: `${cfg.baseUrl}/thinktank/${usedSlug}.html`, name: author.name }
545
+ }
546
+ else {
547
+ resolved = { slug: usedSlug, url: `${cfg.baseUrl}/thinktank/${usedSlug}.html` }
548
+ }
549
+
550
+ let html = await fetchWithRetry(resolved.url, cfg.optFetch)
551
+ let items = parseAuthorColumnPage(html, cfg.baseUrl)
552
+
553
+ let payload = {
554
+ status: 'success',
555
+ site: 'aisixiang',
556
+ mode: 'author',
557
+ query: chineseName || usedSlug,
558
+ resolved,
559
+ fetched_at: new Date().toISOString(),
560
+ count: items.length,
561
+ items,
562
+ }
563
+
564
+ //命中作者欄頁(含slug捷徑)但該欄頁實際0篇文章, 加message讓呼叫端能區分「真的查清楚、就是0篇」與「抓取失敗」
565
+ if (items.length === 0) {
566
+ payload.message =
567
+ `已命中愛思想作者欄頁(${resolved.url})但該欄頁目前 0 篇文章。` +
568
+ `此為查詢成功的真實結果(非抓取失敗);若 slug 為手動指定,請確認 slug 是否正確。`
569
+ }
570
+
571
+ return payload
572
+ }
573
+
574
+
575
+ /**
576
+ * 抓取愛思想指定關鍵字tag之文章清單
577
+ *
578
+ * 走站方keywords搜尋並自動翻頁全抓; 站方搜尋只認簡體, 呼叫端負責簡體化
579
+ *
580
+ * @param {String} keyword 輸入關鍵字字串
581
+ * @param {Object} [opt={}] 輸入設定物件,預設{}
582
+ * @param {String} [opt.baseUrl='https://www.aisixiang.com'] 輸入網站根網址字串,供測試或改指向鏡像時覆寫
583
+ * @param {Integer} [opt.timeout=30000] 輸入單次請求逾時毫秒整數,預設30000
584
+ * @param {Integer} [opt.maxRetries=5] 輸入最大重試次數整數,預設5
585
+ * @param {Integer} [opt.pageDelayMs=1000] 輸入翻頁之頁間延遲毫秒整數,預設1000
586
+ * @param {Integer} [opt.maxPages=50] 輸入翻頁上限正整數,上限為50,預設50
587
+ * @param {Boolean} [opt.showLog=true] 輸入是否顯示過程訊息布林值,預設true
588
+ * @returns {Promise} 回傳Promise,resolve回傳結果物件{status,site,mode,query,resolved,fetched_at,count,items},keyword非有效字串時reject回傳錯誤物件
589
+ * @example
590
+ *
591
+ * import { fetchKeywordArticles } from './src/fetchAisixiang.mjs'
592
+ *
593
+ * let test = async () => {
594
+ *
595
+ * let r = await fetchKeywordArticles('老庄')
596
+ * console.log(r.status, r.count)
597
+ * // => 'success' 42
598
+ *
599
+ * }
600
+ * await test()
601
+ * .catch((err) => {
602
+ * console.log(err)
603
+ * })
604
+ *
605
+ */
606
+ async function fetchKeywordArticles(keyword, opt = {}) {
607
+
608
+ //check
609
+ if (!isestr(keyword)) {
610
+ throw new Error('需要 keyword')
611
+ }
612
+
613
+ let cfg = getCfg(opt)
614
+
615
+ let buildUrl = (page) => `${cfg.baseUrl}/data/search?searchfield=keywords&keywords=${encodeURIComponent(keyword)}&page=${page}`
616
+ let { items, total_pages, pages_fetched } = await fetchAllSearchPages(buildUrl, parseSearchResults, cfg)
617
+
618
+ if (items.length === 0) {
619
+ return {
620
+ status: 'success',
621
+ site: 'aisixiang',
622
+ mode: 'keyword',
623
+ query: keyword,
624
+ resolved: { search_url: buildUrl(1) },
625
+ fetched_at: new Date().toISOString(),
626
+ count: 0,
627
+ items: [],
628
+ message:
629
+ `關鍵字 "${keyword}" 在愛思想無相關文章。` +
630
+ `提醒:站方搜尋只認簡體,呼叫端負責簡體化;若已是簡體仍 0 筆,該主題可能無 tag 索引。`,
631
+ }
632
+ }
633
+
634
+ return {
635
+ status: 'success',
636
+ site: 'aisixiang',
637
+ mode: 'keyword',
638
+ query: keyword,
639
+ resolved: { search_url: buildUrl(1), total_pages, pages_fetched },
640
+ fetched_at: new Date().toISOString(),
641
+ count: items.length,
642
+ items,
643
+ }
644
+ }
645
+
646
+
647
+ /**
648
+ * 抓取愛思想指定標題關鍵字之文章清單
649
+ *
650
+ * 走站方title搜尋並自動翻頁全抓; 站方搜尋只認簡體, 呼叫端負責簡體化
651
+ *
652
+ * @param {String} keyword 輸入標題關鍵字字串
653
+ * @param {Object} [opt={}] 輸入設定物件,預設{}
654
+ * @param {String} [opt.baseUrl='https://www.aisixiang.com'] 輸入網站根網址字串,供測試或改指向鏡像時覆寫
655
+ * @param {Integer} [opt.timeout=30000] 輸入單次請求逾時毫秒整數,預設30000
656
+ * @param {Integer} [opt.maxRetries=5] 輸入最大重試次數整數,預設5
657
+ * @param {Integer} [opt.pageDelayMs=1000] 輸入翻頁之頁間延遲毫秒整數,預設1000
658
+ * @param {Integer} [opt.maxPages=50] 輸入翻頁上限正整數,上限為50,預設50
659
+ * @param {Boolean} [opt.showLog=true] 輸入是否顯示過程訊息布林值,預設true
660
+ * @returns {Promise} 回傳Promise,resolve回傳結果物件{status,site,mode,query,resolved,fetched_at,count,items},keyword非有效字串時reject回傳錯誤物件
661
+ * @example
662
+ *
663
+ * import { fetchTitleArticles } from './src/fetchAisixiang.mjs'
664
+ *
665
+ * let test = async () => {
666
+ *
667
+ * let r = await fetchTitleArticles('禅宗与中国文化')
668
+ * console.log(r.status, r.count)
669
+ * // => 'success' 3
670
+ *
671
+ * }
672
+ * await test()
673
+ * .catch((err) => {
674
+ * console.log(err)
675
+ * })
676
+ *
677
+ */
678
+ async function fetchTitleArticles(keyword, opt = {}) {
679
+
680
+ //check
681
+ if (!isestr(keyword)) {
682
+ throw new Error('需要 keyword')
683
+ }
684
+
685
+ let cfg = getCfg(opt)
686
+
687
+ let buildUrl = (page) => `${cfg.baseUrl}/data/search?searchfield=title&keywords=${encodeURIComponent(keyword)}&page=${page}`
688
+ let { items, total_pages, pages_fetched } = await fetchAllSearchPages(buildUrl, parseSearchResults, cfg)
689
+
690
+ if (items.length === 0) {
691
+ return {
692
+ status: 'success',
693
+ site: 'aisixiang',
694
+ mode: 'title',
695
+ query: keyword,
696
+ resolved: { search_url: buildUrl(1) },
697
+ fetched_at: new Date().toISOString(),
698
+ count: 0,
699
+ items: [],
700
+ message:
701
+ `標題關鍵字 "${keyword}" 在愛思想無相關文章。` +
702
+ `提醒:站方搜尋只認簡體,呼叫端負責簡體化。`,
703
+ }
704
+ }
705
+
706
+ return {
707
+ status: 'success',
708
+ site: 'aisixiang',
709
+ mode: 'title',
710
+ query: keyword,
711
+ resolved: { search_url: buildUrl(1), total_pages, pages_fetched },
712
+ fetched_at: new Date().toISOString(),
713
+ count: items.length,
714
+ items,
715
+ }
716
+ }
717
+
718
+
719
+ /**
720
+ * 抓取愛思想指定策展主題之文章清單
721
+ *
722
+ * 給keyword時先抓站方主題清單解析出id再抓主題頁(兩跳), 給id則直接抓主題頁並自動翻頁全抓;
723
+ * 查無此主題時仍回status為'success'且count為0, 並以message建議改用fetchKeywordArticles(本函數不自動轉向)
724
+ *
725
+ * @param {Object} [opt={}] 輸入設定物件,預設{}
726
+ * @param {String} [opt.keyword] 輸入主題名稱字串,與id擇一必填
727
+ * @param {String} [opt.id] 輸入主題id字串,與keyword擇一必填
728
+ * @param {String} [opt.baseUrl='https://www.aisixiang.com'] 輸入網站根網址字串,供測試或改指向鏡像時覆寫
729
+ * @param {Integer} [opt.timeout=30000] 輸入單次請求逾時毫秒整數,預設30000
730
+ * @param {Integer} [opt.maxRetries=5] 輸入最大重試次數整數,預設5
731
+ * @param {Integer} [opt.pageDelayMs=1000] 輸入翻頁之頁間延遲毫秒整數,預設1000
732
+ * @param {Integer} [opt.maxPages=50] 輸入翻頁上限正整數,上限為50,預設50
733
+ * @param {Boolean} [opt.showLog=true] 輸入是否顯示過程訊息布林值,預設true
734
+ * @returns {Promise} 回傳Promise,resolve回傳結果物件{status,site,mode,query,resolved,fetched_at,count,items},keyword與id皆未給時reject回傳錯誤物件
735
+ * @example
736
+ *
737
+ * import { fetchTopicArticles } from './src/fetchAisixiang.mjs'
738
+ *
739
+ * let test = async () => {
740
+ *
741
+ * let r = await fetchTopicArticles({ keyword: '大数据' })
742
+ * console.log(r.status, r.count, r.resolved.id)
743
+ * // => 'success' 60 '301'
744
+ *
745
+ * }
746
+ * await test()
747
+ * .catch((err) => {
748
+ * console.log(err)
749
+ * })
750
+ *
751
+ */
752
+ async function fetchTopicArticles(opt = {}) {
753
+
754
+ let cfg = getCfg(opt)
755
+
756
+ //「有提供才檢」, 傳了但非有效字串視為未提供, 讓下方「至少一個」檢查接手
757
+ let keyword = get(opt, 'keyword')
758
+ if (!isestr(keyword)) {
759
+ keyword = ''
760
+ }
761
+ let id = get(opt, 'id')
762
+ if (!isestr(id)) {
763
+ id = ''
764
+ }
765
+ if (!keyword && !id) {
766
+ throw new Error('需要 keyword 或 id')
767
+ }
768
+
769
+ let topicId = id
770
+ let topicName = keyword
771
+ let resolved = null
772
+
773
+ if (!topicId) {
774
+
775
+ if (cfg.showLog) {
776
+ process.stderr.write('[info] fetching topics list from /zhuanti/ ...\n')
777
+ }
778
+ let topics = await fetchTopicsList(opt)
779
+ let hit = lookupTopic(topics, keyword)
780
+ if (!hit) {
781
+ //抓到真實主題清單且確定不是策展主題, 屬成功查詢、結果0筆(非抓取失敗)
782
+ return {
783
+ status: 'success',
784
+ site: 'aisixiang',
785
+ mode: 'topic',
786
+ query: keyword,
787
+ fetched_at: new Date().toISOString(),
788
+ topics_count: topics.length,
789
+ count: 0,
790
+ items: [],
791
+ message:
792
+ `"${keyword}" 不在愛思想策展主題清單中(共 ${topics.length} 個主題)。` +
793
+ `建議改用 fetchKeywordArticles('${keyword}') 查 keyword tag 結果。` +
794
+ `本函數不自動轉向,請呼叫端決定是否重試。`,
795
+ }
796
+ }
797
+
798
+ topicId = hit.id
799
+ topicName = hit.name
800
+ resolved = {
801
+ id: topicId,
802
+ name: hit.name,
803
+ category: hit.category,
804
+ url: `${cfg.baseUrl}/zhuanti/${topicId}.html`,
805
+ }
806
+ }
807
+ else {
808
+ resolved = { id: topicId, url: `${cfg.baseUrl}/zhuanti/${topicId}.html` }
809
+ }
810
+
811
+ let buildUrl = (page) => `${cfg.baseUrl}/zhuanti/${topicId}.html?page=${page}`
812
+ let { items, total_pages, pages_fetched } = await fetchAllSearchPages(buildUrl, parseZhuantiArticles, cfg)
813
+
814
+ let payload = {
815
+ status: 'success',
816
+ site: 'aisixiang',
817
+ mode: 'topic',
818
+ query: topicName || topicId,
819
+ resolved: { ...resolved, total_pages, pages_fetched },
820
+ fetched_at: new Date().toISOString(),
821
+ count: items.length,
822
+ items,
823
+ }
824
+
825
+ //命中主題(含id捷徑)但該主題頁實際0篇文章, 加message讓呼叫端能區分「真的查清楚、就是0篇」與「抓取失敗」
826
+ if (items.length === 0) {
827
+ payload.message =
828
+ `已命中愛思想策展主題(${resolved.url})但該主題頁目前 0 篇文章。` +
829
+ `此為查詢成功的真實結果(非抓取失敗);若需更多結果可改用 fetchKeywordArticles 查 keyword tag。`
830
+ }
831
+
832
+ return payload
833
+ }
834
+
835
+
836
+ /**
837
+ * 抓取愛思想單篇文章並轉為Markdown
838
+ *
839
+ * 由article-content區塊切出正文轉Markdown, 並產生含title、source與created之YAML frontmatter;
840
+ * 標題會去除站名後綴, 例如「葛兆光:禅宗与中国文化_爱思想」轉為「葛兆光:禅宗与中国文化」
841
+ *
842
+ * @param {Object} [opt={}] 輸入設定物件,預設{}
843
+ * @param {String} [opt.aid] 輸入文章aid字串,與url擇一必填
844
+ * @param {String} [opt.url] 輸入文章網址字串,與aid擇一必填
845
+ * @param {String} [opt.baseUrl='https://www.aisixiang.com'] 輸入網站根網址字串,供測試或改指向鏡像時覆寫
846
+ * @param {Integer} [opt.timeout=30000] 輸入單次請求逾時毫秒整數,預設30000
847
+ * @param {Integer} [opt.maxRetries=5] 輸入最大重試次數整數,預設5
848
+ * @param {Integer} [opt.pageDelayMs=1000] 輸入翻頁之頁間延遲毫秒整數,預設1000
849
+ * @param {Integer} [opt.maxPages=50] 輸入翻頁上限正整數,上限為50,預設50
850
+ * @param {Boolean} [opt.showLog=true] 輸入是否顯示過程訊息布林值,預設true
851
+ * @returns {Promise} 回傳Promise,resolve回傳結果物件{status,site,mode,url,title,chars,markdown},aid與url皆未給或解析失敗時reject回傳錯誤物件
852
+ * @example
853
+ *
854
+ * import { fetchArticle } from './src/fetchAisixiang.mjs'
855
+ *
856
+ * let test = async () => {
857
+ *
858
+ * let r = await fetchArticle({ aid: '146669' })
859
+ * console.log(r.status, r.title, r.chars)
860
+ * // => 'success' '葛兆光:禅宗与中国文化' 12345
861
+ *
862
+ * }
863
+ * await test()
864
+ * .catch((err) => {
865
+ * console.log(err)
866
+ * })
867
+ *
868
+ */
869
+ async function fetchArticle(opt = {}) {
870
+
871
+ let cfg = getCfg(opt)
872
+
873
+ //「有提供才檢」, 傳了但非有效字串視為未提供, 讓下方「至少一個」檢查接手
874
+ let aid = get(opt, 'aid')
875
+ if (!isestr(aid)) {
876
+ aid = ''
877
+ }
878
+ let url = get(opt, 'url')
879
+ if (!isestr(url)) {
880
+ url = ''
881
+ }
882
+ let targetUrl = aid ? `${cfg.baseUrl}/data/${aid}.html` : url
883
+ if (!targetUrl) {
884
+ throw new Error('需要 aid 或 url')
885
+ }
886
+
887
+ let html = await fetchWithRetry(targetUrl, cfg.optFetch)
888
+ let titleRaw = extractTitle(html)
889
+ let contentHtml = extractDivContent(html, 'article-content')
890
+ if (!titleRaw || !contentHtml) {
891
+ throw new Error(`解析失敗:title=${!!titleRaw}, content=${!!contentHtml}`)
892
+ }
893
+
894
+ let body = htmlToMarkdown(contentHtml)
895
+
896
+ //去掉站名後綴
897
+ let title = titleRaw.replace(/[_\-\s]*(?:_)?(?:爱思想|愛思想)\s*$/u, '').trim()
898
+ let today = new Date().toISOString().slice(0, 10)
899
+
900
+ let markdown =
901
+ `---
902
+ title: "${title}"
903
+ source: "${targetUrl}"
904
+ author:
905
+ published:
906
+ created: ${today}
907
+ description:
908
+ ---
909
+ ${body}
910
+ `
911
+
912
+ return {
913
+ status: 'success',
914
+ site: 'aisixiang',
915
+ mode: 'fetch',
916
+ url: targetUrl,
917
+ title,
918
+ chars: body.length,
919
+ markdown,
920
+ }
921
+ }
922
+
923
+
924
+ /**
925
+ * 抓取愛思想(aisixiang.com)
926
+ *
927
+ * @returns {Object} 回傳物件,其內可呼叫fetchAuthorsList、lookupAuthor、fetchTopicsList、lookupTopic、fetchAuthorArticles、fetchKeywordArticles、fetchTitleArticles、fetchTopicArticles、fetchArticle
928
+ * @example
929
+ *
930
+ * 詳見fetchAuthorArticles、fetchKeywordArticles、fetchTitleArticles、fetchTopicArticles、fetchArticle範例
931
+ *
932
+ */
933
+ let fetchAisixiang = {
934
+ BASE_URL,
935
+ USER_AGENT,
936
+ MAX_PAGES,
937
+ safeFilename,
938
+ fetchAuthorsList,
939
+ lookupAuthor,
940
+ fetchTopicsList,
941
+ lookupTopic,
942
+ fetchAuthorArticles,
943
+ fetchKeywordArticles,
944
+ fetchTitleArticles,
945
+ fetchTopicArticles,
946
+ fetchArticle,
947
+ }
948
+
949
+
950
+ export {
951
+ BASE_URL,
952
+ USER_AGENT,
953
+ PAGE_DELAY_MS,
954
+ MAX_PAGES,
955
+ safeFilename,
956
+ fetchAuthorsList,
957
+ lookupAuthor,
958
+ fetchTopicsList,
959
+ lookupTopic,
960
+ fetchAuthorArticles,
961
+ fetchKeywordArticles,
962
+ fetchTitleArticles,
963
+ fetchTopicArticles,
964
+ fetchArticle
965
+ }
966
+ export default fetchAisixiang