w-dwdata-hub 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.editorconfig +9 -0
- package/.eslintignore +3 -0
- package/.eslintrc.js +55 -0
- package/.jsdoc +25 -0
- package/LICENSE +21 -0
- package/README.md +168 -0
- package/SECURITY.md +5 -0
- package/babel.config.js +16 -0
- package/dist/w-dwdata-hub.umd.js +7 -0
- package/dist/w-dwdata-hub.umd.js.map +1 -0
- package/docs/WDwdataHub.mjs.html +136 -0
- package/docs/decodeEntities.mjs.html +129 -0
- package/docs/extractDivContent.mjs.html +147 -0
- package/docs/fetchAiNewsAggregator.mjs.html +155 -0
- package/docs/fetchAisixiang.mjs.html +1038 -0
- package/docs/fetchCnyes.mjs.html +260 -0
- package/docs/fetchGuancha.mjs.html +1007 -0
- package/docs/fetchHackerNews.mjs.html +189 -0
- package/docs/fetchMoneydj.mjs.html +251 -0
- package/docs/fetchMops.mjs.html +385 -0
- package/docs/fetchRSS.mjs.html +160 -0
- package/docs/fetchStatementdog.mjs.html +213 -0
- package/docs/fetchTaifex.mjs.html +513 -0
- package/docs/fetchTpex3insti.mjs.html +278 -0
- package/docs/fetchTpexMargin.mjs.html +248 -0
- package/docs/fetchTpexStock.mjs.html +199 -0
- package/docs/fetchTwDataHoliday.mjs.html +236 -0
- package/docs/fetchTwseMargin.mjs.html +253 -0
- package/docs/fetchTwseStock.mjs.html +188 -0
- package/docs/fetchTwseT86.mjs.html +208 -0
- package/docs/fetchWithRetry.mjs.html +376 -0
- package/docs/fonts/Montserrat/Montserrat-Bold.eot +0 -0
- package/docs/fonts/Montserrat/Montserrat-Bold.ttf +0 -0
- package/docs/fonts/Montserrat/Montserrat-Bold.woff +0 -0
- package/docs/fonts/Montserrat/Montserrat-Bold.woff2 +0 -0
- package/docs/fonts/Montserrat/Montserrat-Regular.eot +0 -0
- package/docs/fonts/Montserrat/Montserrat-Regular.ttf +0 -0
- package/docs/fonts/Montserrat/Montserrat-Regular.woff +0 -0
- package/docs/fonts/Montserrat/Montserrat-Regular.woff2 +0 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.eot +0 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.svg +978 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.ttf +0 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.woff +0 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.woff2 +0 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.eot +0 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.svg +1049 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.ttf +0 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.woff +0 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.woff2 +0 -0
- package/docs/getOptFetch.mjs.html +185 -0
- package/docs/global.html +22649 -0
- package/docs/htmlToMarkdown.mjs.html +168 -0
- package/docs/index.html +84 -0
- package/docs/isYmd.mjs.html +114 -0
- package/docs/parseIntComma.mjs.html +100 -0
- package/docs/safeFilename.mjs.html +102 -0
- package/docs/scripts/collapse.js +39 -0
- package/docs/scripts/commonNav.js +28 -0
- package/docs/scripts/linenumber.js +25 -0
- package/docs/scripts/nav.js +12 -0
- package/docs/scripts/polyfill.js +4 -0
- package/docs/scripts/prettify/Apache-License-2.0.txt +202 -0
- package/docs/scripts/prettify/lang-css.js +2 -0
- package/docs/scripts/prettify/prettify.js +28 -0
- package/docs/scripts/search.js +99 -0
- package/docs/styles/jsdoc.css +776 -0
- package/docs/styles/prettify.css +80 -0
- package/docs/toDatetimeUTC8.mjs.html +143 -0
- package/docs/toRocDate.mjs.html +107 -0
- package/g.mjs +73 -0
- package/package.json +31 -0
- package/script.txt +17 -0
- package/src/WDwdataHub.mjs +64 -0
- package/src/decodeEntities.mjs +57 -0
- package/src/extractDivContent.mjs +75 -0
- package/src/fetchAiNewsAggregator.mjs +83 -0
- package/src/fetchAisixiang.mjs +966 -0
- package/src/fetchCnyes.mjs +188 -0
- package/src/fetchGuancha.mjs +935 -0
- package/src/fetchHackerNews.mjs +117 -0
- package/src/fetchMoneydj.mjs +179 -0
- package/src/fetchMops.mjs +313 -0
- package/src/fetchRSS.mjs +88 -0
- package/src/fetchStatementdog.mjs +141 -0
- package/src/fetchTaifex.mjs +441 -0
- package/src/fetchTpex3insti.mjs +206 -0
- package/src/fetchTpexMargin.mjs +176 -0
- package/src/fetchTpexStock.mjs +127 -0
- package/src/fetchTwDataHoliday.mjs +164 -0
- package/src/fetchTwseMargin.mjs +181 -0
- package/src/fetchTwseStock.mjs +116 -0
- package/src/fetchTwseT86.mjs +136 -0
- package/src/fetchWithRetry.mjs +304 -0
- package/src/getOptFetch.mjs +113 -0
- package/src/htmlToMarkdown.mjs +96 -0
- package/src/isYmd.mjs +42 -0
- package/src/parseIntComma.mjs +28 -0
- package/src/safeFilename.mjs +30 -0
- package/src/toDatetimeUTC8.mjs +71 -0
- package/src/toRocDate.mjs +35 -0
- package/test/WDwdataHub.test.mjs +63 -0
- package/test/decodeEntities.test.mjs +42 -0
- package/test/extractDivContent.test.mjs +48 -0
- package/test/fetchAiNewsAggregator.test.mjs +79 -0
- package/test/fetchAisixiang.test.mjs +225 -0
- package/test/fetchCnyes.test.mjs +61 -0
- package/test/fetchGuancha.test.mjs +190 -0
- package/test/fetchHackerNews.test.mjs +78 -0
- package/test/fetchMoneydj.test.mjs +82 -0
- package/test/fetchMops.test.mjs +106 -0
- package/test/fetchRSS.test.mjs +85 -0
- package/test/fetchStatementdog.test.mjs +69 -0
- package/test/fetchTaifex.test.mjs +99 -0
- package/test/fetchTpex3insti.test.mjs +90 -0
- package/test/fetchTpexMargin.test.mjs +94 -0
- package/test/fetchTpexStock.test.mjs +68 -0
- package/test/fetchTwDataHoliday.test.mjs +100 -0
- package/test/fetchTwseMargin.test.mjs +81 -0
- package/test/fetchTwseStock.test.mjs +80 -0
- package/test/fetchTwseT86.test.mjs +76 -0
- package/test/fetchWithRetry.test.mjs +174 -0
- package/test/htmlToMarkdown.test.mjs +71 -0
- package/test/isYmd.test.mjs +56 -0
- package/test/parseIntComma.test.mjs +38 -0
- package/test/safeFilename.test.mjs +35 -0
- package/test/toDatetimeUTC8.test.mjs +65 -0
- package/test/toRocDate.test.mjs +38 -0
- package/test/tools/encodeBig5.mjs +80 -0
- package/test/tools/fixtures.mjs +539 -0
- package/test/tools/hasChrome.mjs +39 -0
- package/test/tools/serverForTest.mjs +397 -0
- package/toolg/addVersion.mjs +4 -0
- package/toolg/cleanFolder.mjs +5 -0
- package/toolg/gDistRollup.mjs +49 -0
- package/toolg/modifyReadme.mjs +4 -0
|
@@ -0,0 +1,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
|