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,935 @@
1
+ import wfw from 'w-fetch-web'
2
+ import get from 'lodash-es/get.js'
3
+ import isbol from 'wsemi/src/isbol.mjs'
4
+ import isestr from 'wsemi/src/isestr.mjs'
5
+ import ispint from 'wsemi/src/ispint.mjs'
6
+ import isp0int from 'wsemi/src/isp0int.mjs'
7
+ import cint from 'wsemi/src/cint.mjs'
8
+ import delay from 'wsemi/src/delay.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
+ //w-fetch-web為UMD套件, 只能default import, named import取不到函數
16
+ let { fetchWebByCurl } = wfw
17
+
18
+
19
+ //觀察者網網站根網址
20
+ let BASE_URL = 'https://www.guancha.cn'
21
+
22
+
23
+ //觀察者網對User-Agent較敏感, 故固定送出桌面瀏覽器標頭
24
+ let USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' +
25
+ '(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
26
+
27
+
28
+ //頁間延遲毫秒
29
+ let PAGE_DELAY_MS = 1000
30
+
31
+
32
+ //翻頁安全上限, 50頁×60筆=3000筆
33
+ let MAX_PAGES = 50
34
+
35
+
36
+ //預設值
37
+ let DEFAULT_MAX_RETRIES = 5
38
+
39
+
40
+ //偵測首頁之門檻, 首頁底部A-Z作者索引實測有約22個<dt>[A-Z]</dt>, 真實list頁為0個
41
+ //取門檻5以容忍站方版型微調, 同時遠離0
42
+ let HOMEPAGE_DT_THRESHOLD = 5
43
+
44
+
45
+ /**
46
+ * 已知主題對照表
47
+ *
48
+ * 觀察者網無公開主題清單頁, 本表為手工整理(由首頁專題標籤與大頻道nav), 未涵蓋者請直接傳slug。
49
+ * 注意:同一中文名有多個slug時(如「财经」對應大頻道/economy與分頁欄目/CaiJing),
50
+ * 分頁欄目slug必須排在前面, 因lookupTopic採first-match;
51
+ * /economy等大頻道是「精選首頁」不分頁, /CaiJing等欄目才能以list_N.shtml翻頁全抓
52
+ */
53
+ let KNOWN_TOPICS = [
54
+ //中文名重複者, 分頁版slug在前, 大頻道版slug用獨立中文名標記避免覆蓋
55
+ { slug: 'CaiJing', name: '财经' }, //分頁版(首選)
56
+ { slug: 'economy', name: '财经-大頻道' }, //大頻道精選首頁, 不分頁
57
+ { slug: 'JunShi', name: '军事' }, //分頁版(首選)
58
+ { slug: 'military-affairs', name: '军事-大頻道' }, //大頻道精選首頁, 不分頁
59
+ { slug: 'ZhengZhi', name: '政治' },
60
+ { slug: 'WenHua', name: '文化' },
61
+ { slug: 'chanjing', name: '产经' },
62
+ { slug: 'qiche', name: '观出行' },
63
+ { slug: 'gongye-keji', name: '科技' },
64
+ { slug: 'ChengShi', name: '城事' },
65
+ { slug: 'GuanJinRong', name: '观金融' },
66
+ { slug: 'XinShiDai', name: '新时代' },
67
+ { slug: 'ChaoJiGongCheng', name: '超级工程' },
68
+ { slug: 'NengYuanZhanLue', name: '能源战略' },
69
+ { slug: 'RenGongZhiNeng', name: '人工智能' },
70
+ { slug: 'XinZhiGuanChaSuoNews', name: '心智观察所' },
71
+ { slug: 'YiLangJuShi', name: '伊朗局势' },
72
+ { slug: 'MeiGuoMeng', name: '美国一梦' },
73
+ { slug: 'MeiGuoJingJi', name: '美国经济' },
74
+ { slug: 'ELuoSiZhiSheng', name: '俄罗斯之声' },
75
+ { slug: 'lianganyuanzhuopai', name: '两岸圆桌派' },
76
+ { slug: 'ZheJiuShiZhongGuo', name: '这就是中国' },
77
+ { slug: 'YiZhouJunQingGuanCha', name: '一周军事观察' },
78
+ { slug: 'feizhoushangkou', name: '非洲之窗' },
79
+ { slug: 'toutiao', name: '观察者头条' },
80
+ { slug: 'gushi', name: '股市' },
81
+ { slug: 'guanwangwenyu', name: '新潮观鱼' },
82
+ { slug: 'jingtiriben', name: '冲破战后秩序 日本想干什么' },
83
+ { slug: 'DaoGuoDianAVI', name: '日本' },
84
+ ]
85
+
86
+
87
+ /**
88
+ * 正規化本模組共用設定
89
+ *
90
+ * @param {Object} [opt={}] 輸入設定物件,預設{}
91
+ * @returns {Object} 回傳已正規化之設定物件,內含baseUrl、showLog與optFetch
92
+ */
93
+ function getCfg(opt = {}) {
94
+
95
+ //baseUrl
96
+ let baseUrl = get(opt, 'baseUrl')
97
+ if (!isestr(baseUrl)) {
98
+ baseUrl = BASE_URL
99
+ }
100
+ baseUrl = baseUrl.replace(/\/+$/, '')
101
+
102
+ //maxRetries
103
+ let maxRetries = get(opt, 'maxRetries')
104
+ if (!isp0int(maxRetries)) {
105
+ maxRetries = DEFAULT_MAX_RETRIES
106
+ }
107
+ else {
108
+ maxRetries = cint(maxRetries)
109
+ }
110
+
111
+ //timeoutMs
112
+ let timeoutMs = get(opt, 'timeout')
113
+ if (!ispint(timeoutMs)) {
114
+ timeoutMs = null
115
+ }
116
+ else {
117
+ timeoutMs = cint(timeoutMs)
118
+ }
119
+
120
+ //showLog
121
+ let showLog = get(opt, 'showLog')
122
+ if (!isbol(showLog)) {
123
+ showLog = true
124
+ }
125
+
126
+ //optFetch
127
+ let optFetch = { userAgent: USER_AGENT, referer: baseUrl, maxRetries }
128
+ if (timeoutMs !== null) {
129
+ optFetch.timeoutMs = timeoutMs
130
+ }
131
+
132
+ //pageDelayMs
133
+ let pageDelayMs = get(opt, 'pageDelayMs')
134
+ if (!isp0int(pageDelayMs)) {
135
+ pageDelayMs = PAGE_DELAY_MS
136
+ }
137
+ else {
138
+ pageDelayMs = cint(pageDelayMs)
139
+ }
140
+
141
+ //maxPages
142
+ let maxPages = get(opt, 'maxPages')
143
+ if (!ispint(maxPages)) {
144
+ maxPages = MAX_PAGES
145
+ }
146
+ else {
147
+ maxPages = Math.min(cint(maxPages), MAX_PAGES)
148
+ }
149
+
150
+ return { baseUrl, showLog, pageDelayMs, maxPages, optFetch }
151
+ }
152
+
153
+
154
+ /**
155
+ * 抓取網頁原始HTML
156
+ *
157
+ * 委派w-fetch-web之fetchWebByCurl(其本身內建重試與線性退避), 失敗時拋出帶reason與httpCode之錯誤
158
+ *
159
+ * @param {String} url 輸入待抓取網址字串
160
+ * @param {Object} cfg 輸入已正規化之設定物件
161
+ * @returns {Promise} 回傳Promise,resolve回傳原始HTML字串
162
+ */
163
+ async function fetchHtml(url, cfg) {
164
+
165
+ let r = await fetchWebByCurl(url, cfg.optFetch)
166
+ if (r.status !== 'success') {
167
+ let err = new Error(r.message || 'fetch failed')
168
+ err.reason = r.reason
169
+ err.url = url
170
+ err.httpCode = r.httpCode
171
+ throw err
172
+ }
173
+
174
+ //注意:觀察者網對「不存在的slug/已下架文章」會302跳首頁(curl -L跟隨後httpCode仍為200)
175
+ //fetchWebByCurl不回傳effective URL無法靠URL比對, list頁與首頁之<title>又同為「观察者网」故title也無法區分
176
+ //改以結構特徵偵測(見isHomepageHtml), 由各caller視情境處理
177
+ return r.html
178
+ }
179
+
180
+
181
+ /**
182
+ * 偵測HTML是否為觀察者網首頁
183
+ *
184
+ * 判準為首頁底部含A-Z作者索引之<dt>[A-Z]</dt>標記(真正的作者欄頁、欄目頁與主題集頁皆無此區塊),
185
+ * 用以識別「請求被302導向首頁」之情形
186
+ *
187
+ * @param {String} html 輸入HTML字串
188
+ * @returns {Boolean} 回傳是否為首頁之布林值
189
+ */
190
+ function isHomepageHtml(html) {
191
+ let m = html.match(/<dt>[A-Z]<\/dt>/g)
192
+ return !!m && m.length >= HOMEPAGE_DT_THRESHOLD
193
+ }
194
+
195
+
196
+ /**
197
+ * 由HTML抽出title標籤內容
198
+ *
199
+ * @param {String} html 輸入HTML字串
200
+ * @returns {String|null} 回傳標題字串,無title標籤時回傳null
201
+ */
202
+ function extractHtmlTitle(html) {
203
+ let m = html.match(/<title>([\s\S]*?)<\/title>/i)
204
+ return m ? decodeEntities(m[1].trim()) : null
205
+ }
206
+
207
+
208
+ /**
209
+ * 由正文第一段抽出作者署名
210
+ *
211
+ * 觀察者網正文首段常見【文/观察者网 XXX】或【文/观察者网专栏作者 XXX,翻译/ XXX】
212
+ *
213
+ * @param {String} contentHtml 輸入正文區塊HTML字串
214
+ * @returns {String|null} 回傳作者署名字串,無署名時回傳null
215
+ */
216
+ function extractAuthorFromContent(contentHtml) {
217
+ let m = contentHtml.match(/【([^】]{4,120})】/)
218
+ if (!m) {
219
+ return null
220
+ }
221
+ return m[1].replace(/<[^>]+>/g, '').trim()
222
+ }
223
+
224
+
225
+ /**
226
+ * 由HTML抽出發布時間
227
+ *
228
+ * @param {String} html 輸入HTML字串
229
+ * @returns {String|null} 回傳發布時間字串,格式為'YYYY-MM-DD HH:mm:ss',無發布時間時回傳null
230
+ */
231
+ function extractPubTime(html) {
232
+ let m = html.match(/<span[^>]*>(20\d{2}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})<\/span>/)
233
+ return m ? m[1] : null
234
+ }
235
+
236
+
237
+ /**
238
+ * 由首頁底部A-Z索引解析作者對照
239
+ *
240
+ * @param {String} html 輸入首頁HTML字串
241
+ * @returns {Array} 回傳作者物件陣列,各物件為{slug,name,letter}
242
+ */
243
+ function parseAuthorIndex(html) {
244
+
245
+ let flat = html.replace(/\n/g, ' ')
246
+ let items = []
247
+ let seen = new Set()
248
+
249
+ let dlBlocks = [...flat.matchAll(/<dt>([A-Z])<\/dt>\s*<dd[^>]*>([\s\S]*?)<\/dd>/g)]
250
+ for (let [, letter, body] of dlBlocks) {
251
+ let linkRe = /<a[^>]+href="(?:\.\.\/|\/)?([A-Za-z][a-zA-Z0-9_-]+)\/list_1\.shtml"[^>]*>([^<]+)<\/a>/g
252
+ let m
253
+ while ((m = linkRe.exec(body)) !== null) {
254
+ let slug = m[1]
255
+ let name = decodeEntities(m[2]).trim()
256
+ if (!name || seen.has(slug)) {
257
+ continue
258
+ }
259
+ seen.add(slug)
260
+ items.push({ slug, name, letter })
261
+ }
262
+ }
263
+
264
+ return items
265
+ }
266
+
267
+
268
+ /**
269
+ * 解析列表頁之文章清單
270
+ *
271
+ * 列表頁slug與文章URL slug通常不同(作者頁同slug, 主題集頁用各自作者slug, 欄目頁用大頻道slug),
272
+ * 故本函數不限定文章URL之slug, 抽出所有符合/<slug>/<YYYY_MM_DD>_<id>.shtml模式之連結
273
+ *
274
+ * @param {String} html 輸入列表頁HTML字串
275
+ * @param {String} baseUrl 輸入網站根網址字串
276
+ * @returns {Array} 回傳文章物件陣列,各物件為{url,title,slug}
277
+ */
278
+ function parseListPage(html, baseUrl) {
279
+
280
+ let flat = html.replace(/\n/g, ' ')
281
+ let items = []
282
+ let seen = new Set()
283
+
284
+ let linkRe = /<a[^>]+href="\/([A-Za-z][a-zA-Z0-9_-]+)\/(\d{4}_\d{2}_\d{2}_\d+)\.shtml"(?:[^>]*title="([^"]+)")?[^>]*>([^<]*)<\/a>/g
285
+ let m
286
+ while ((m = linkRe.exec(flat)) !== null) {
287
+ let articleSlug = m[1]
288
+ let dateId = m[2]
289
+ let titleAttr = m[3] && decodeEntities(m[3]).trim()
290
+ let inner = m[4] && decodeEntities(m[4]).replace(/<[^>]+>/g, '').trim()
291
+ let title = titleAttr || inner || ''
292
+ let key = `${articleSlug}/${dateId}`
293
+ if (seen.has(key) || !title) {
294
+ continue
295
+ }
296
+ seen.add(key)
297
+ items.push({ url: `${baseUrl}/${articleSlug}/${dateId}.shtml`, title, slug: articleSlug })
298
+ }
299
+
300
+ return items
301
+ }
302
+
303
+
304
+ /**
305
+ * 自動翻頁抓取全部列表頁
306
+ *
307
+ * 逐頁抓/<slug>/list_N.shtml, 適用於作者頁、欄目頁與主題集頁;
308
+ * 第1頁被302導向首頁代表slug不存在故拋錯, 後續頁被導向首頁或無新項目則視為到底
309
+ *
310
+ * @param {String} slug 輸入欄目或作者之slug字串
311
+ * @param {Object} cfg 輸入已正規化之設定物件
312
+ * @returns {Promise} 回傳Promise,resolve回傳{items,pages_fetched}
313
+ */
314
+ async function fetchAllListPages(slug, cfg) {
315
+
316
+ let all = []
317
+ let seen = new Set()
318
+ let pagesFetched = 0
319
+
320
+ for (let page = 1; page <= cfg.maxPages; page++) {
321
+
322
+ let url = `${cfg.baseUrl}/${slug}/list_${page}.shtml`
323
+ if (cfg.showLog) {
324
+ process.stderr.write(`[info] fetching ${slug} list_${page} ...\n`)
325
+ }
326
+
327
+ //html, 翻頁失敗時第1頁失敗即拋, 後續頁失敗視為到底
328
+ let html = null
329
+ try {
330
+ html = await fetchHtml(url, cfg)
331
+ }
332
+ catch (err) {
333
+ if (page === 1) {
334
+ throw err
335
+ }
336
+ if (cfg.showLog) {
337
+ process.stderr.write(`[info] page ${page} fetch error (assumed end of list): ${err.message}\n`)
338
+ }
339
+ break
340
+ }
341
+
342
+ //偵測「不存在的slug被302跳首頁」
343
+ if (isHomepageHtml(html)) {
344
+ if (page === 1) {
345
+ let err = new Error(`slug "${slug}" 不存在:請求 list 頁被觀察者網 302 導向首頁。`)
346
+ err.reason = 'redirected-to-homepage'
347
+ throw err
348
+ }
349
+ if (cfg.showLog) {
350
+ process.stderr.write(`[info] page ${page} 被導向首頁,視為到底,停止翻頁\n`)
351
+ }
352
+ break
353
+ }
354
+
355
+ pagesFetched++
356
+
357
+ let items = parseListPage(html, cfg.baseUrl)
358
+ let fresh = items.filter((it) => !seen.has(it.url))
359
+ if (fresh.length === 0) {
360
+ if (cfg.showLog) {
361
+ process.stderr.write(`[info] page ${page} 無新項目,停止翻頁\n`)
362
+ }
363
+ break
364
+ }
365
+
366
+ for (let it of fresh) {
367
+ seen.add(it.url)
368
+ }
369
+ all.push(...fresh)
370
+
371
+ if (page < cfg.maxPages) {
372
+ await delay(cfg.pageDelayMs)
373
+ }
374
+
375
+ }
376
+
377
+ return { items: all, pages_fetched: pagesFetched }
378
+ }
379
+
380
+
381
+ /**
382
+ * 抓取觀察者網作者索引
383
+ *
384
+ * 由首頁底部A-Z索引解析作者中文名與slug對照, 注意首頁索引僅含部分作者
385
+ *
386
+ * @param {Object} [opt={}] 輸入設定物件,預設{}
387
+ * @param {String} [opt.baseUrl='https://www.guancha.cn'] 輸入網站根網址字串,供測試或改指向鏡像時覆寫
388
+ * @param {Integer} [opt.maxRetries=5] 輸入最大重試次數整數,預設5
389
+ * @param {Integer} [opt.pageDelayMs=1000] 輸入翻頁之頁間延遲毫秒整數,預設1000
390
+ * @param {Integer} [opt.maxPages=50] 輸入翻頁上限正整數,上限為50,預設50
391
+ * @param {Boolean} [opt.showLog=true] 輸入是否顯示過程訊息布林值,預設true
392
+ * @returns {Promise} 回傳Promise,resolve回傳作者物件陣列,各物件為{slug,name,letter}
393
+ * @example
394
+ *
395
+ * import { fetchAuthorsList } from './src/fetchGuancha.mjs'
396
+ *
397
+ * let test = async () => {
398
+ *
399
+ * let rs = await fetchAuthorsList()
400
+ * console.log(rs.length, rs[0])
401
+ * // => 636 { slug: 'AnSheng', name: '安生', letter: 'A' }
402
+ *
403
+ * }
404
+ * await test()
405
+ * .catch((err) => {
406
+ * console.log(err)
407
+ * })
408
+ *
409
+ */
410
+ async function fetchAuthorsList(opt = {}) {
411
+
412
+ let cfg = getCfg(opt)
413
+
414
+ let html = await fetchHtml(`${cfg.baseUrl}/`, cfg)
415
+
416
+ return parseAuthorIndex(html)
417
+ }
418
+
419
+
420
+ /**
421
+ * 由作者清單查找指定中文名之作者
422
+ *
423
+ * 本函數不轉繁簡, 呼叫端須自行使用站方登錄字形(簡體)
424
+ *
425
+ * @param {Array} authors 輸入作者物件陣列
426
+ * @param {String} name 輸入作者中文名字串
427
+ * @returns {Object|null} 回傳作者物件,查無時回傳null
428
+ * @example
429
+ *
430
+ * import { lookupAuthor } from './src/fetchGuancha.mjs'
431
+ *
432
+ * console.log(lookupAuthor([{ slug: 'AnSheng', name: '安生' }], '安生'))
433
+ * // => { slug: 'AnSheng', name: '安生' }
434
+ *
435
+ */
436
+ function lookupAuthor(authors, name) {
437
+
438
+ if (!Array.isArray(authors) || !name) {
439
+ return null
440
+ }
441
+
442
+ return authors.find((a) => a.name === name) || null
443
+ }
444
+
445
+
446
+ /**
447
+ * 由已知主題對照表查找指定名稱之主題
448
+ *
449
+ * 採first-match, 故同名時分頁版slug優先(見KNOWN_TOPICS說明)
450
+ *
451
+ * @param {String} name 輸入主題名稱字串
452
+ * @returns {Object|null} 回傳主題物件,查無時回傳null
453
+ * @example
454
+ *
455
+ * import { lookupTopic } from './src/fetchGuancha.mjs'
456
+ *
457
+ * console.log(lookupTopic('财经'))
458
+ * // => { slug: 'CaiJing', name: '财经' }
459
+ *
460
+ */
461
+ function lookupTopic(name) {
462
+
463
+ if (!name) {
464
+ return null
465
+ }
466
+
467
+ return KNOWN_TOPICS.find((t) => t.name === name) || null
468
+ }
469
+
470
+
471
+ /**
472
+ * 抓取觀察者網指定作者之文章清單
473
+ *
474
+ * 給name時先抓首頁A-Z索引解析出slug再翻頁全抓(兩跳), 給slug則直接翻頁全抓;
475
+ * 查無此作者時仍回status為'success'且count為0, slug不存在(被302導向首頁)時回status為'error'
476
+ *
477
+ * @param {Object} [opt={}] 輸入設定物件,預設{}
478
+ * @param {String} [opt.name] 輸入作者中文名字串,與slug擇一必填
479
+ * @param {String} [opt.slug] 輸入作者slug字串,與name擇一必填
480
+ * @param {String} [opt.baseUrl='https://www.guancha.cn'] 輸入網站根網址字串,供測試或改指向鏡像時覆寫
481
+ * @param {Integer} [opt.maxRetries=5] 輸入最大重試次數整數,預設5
482
+ * @param {Integer} [opt.pageDelayMs=1000] 輸入翻頁之頁間延遲毫秒整數,預設1000
483
+ * @param {Integer} [opt.maxPages=50] 輸入翻頁上限正整數,上限為50,預設50
484
+ * @param {Boolean} [opt.showLog=true] 輸入是否顯示過程訊息布林值,預設true
485
+ * @returns {Promise} 回傳Promise,resolve回傳結果物件{status,site,mode,query,resolved,fetched_at,count,items},name與slug皆未給時reject回傳錯誤物件
486
+ * @example
487
+ *
488
+ * import { fetchAuthorArticles } from './src/fetchGuancha.mjs'
489
+ *
490
+ * let test = async () => {
491
+ *
492
+ * let r = await fetchAuthorArticles({ name: '安生' })
493
+ * console.log(r.status, r.count, r.resolved.slug)
494
+ * // => 'success' 120 'AnSheng'
495
+ *
496
+ * }
497
+ * await test()
498
+ * .catch((err) => {
499
+ * console.log(err)
500
+ * })
501
+ *
502
+ */
503
+ async function fetchAuthorArticles(opt = {}) {
504
+
505
+ let cfg = getCfg(opt)
506
+
507
+ //「有提供才檢」, 傳了但非有效字串視為未提供, 讓下方「至少一個」檢查接手
508
+ let name = get(opt, 'name')
509
+ if (!isestr(name)) {
510
+ name = ''
511
+ }
512
+ let slug = get(opt, 'slug')
513
+ if (!isestr(slug)) {
514
+ slug = ''
515
+ }
516
+ if (!name && !slug) {
517
+ throw new Error('需要 name 或 slug')
518
+ }
519
+
520
+ let resolvedSlug = slug
521
+ let resolvedName = name
522
+
523
+ if (!resolvedSlug) {
524
+
525
+ if (cfg.showLog) {
526
+ process.stderr.write('[info] fetching authors index from / ...\n')
527
+ }
528
+ let authors = await fetchAuthorsList(opt)
529
+ let author = lookupAuthor(authors, name)
530
+ if (!author) {
531
+ //「真的沒這位作者」屬成功查詢、結果0筆
532
+ return {
533
+ status: 'success',
534
+ site: 'guancha',
535
+ mode: 'author',
536
+ query: name,
537
+ fetched_at: new Date().toISOString(),
538
+ authors_count: authors.length,
539
+ count: 0,
540
+ items: [],
541
+ message:
542
+ `"${name}" 不在觀察者網作者索引中(共 ${authors.length} 位)。尚無此作者文章。` +
543
+ `提醒:本函數不轉繁簡,呼叫端負責用站方登錄字形(簡體)。`,
544
+ }
545
+ }
546
+
547
+ resolvedSlug = author.slug
548
+ resolvedName = author.name
549
+ }
550
+
551
+ let url = `${cfg.baseUrl}/${resolvedSlug}`
552
+
553
+ let listResult = null
554
+ try {
555
+ listResult = await fetchAllListPages(resolvedSlug, cfg)
556
+ }
557
+ catch (err) {
558
+ return {
559
+ status: 'error',
560
+ site: 'guancha',
561
+ mode: 'author',
562
+ query: resolvedName || resolvedSlug,
563
+ resolved: { slug: resolvedSlug, url, name: resolvedName },
564
+ fetched_at: new Date().toISOString(),
565
+ reason: err.reason,
566
+ error: err.message,
567
+ }
568
+ }
569
+
570
+ return {
571
+ status: 'success',
572
+ site: 'guancha',
573
+ mode: 'author',
574
+ query: resolvedName || resolvedSlug,
575
+ resolved: { slug: resolvedSlug, url, name: resolvedName, pages_fetched: listResult.pages_fetched },
576
+ fetched_at: new Date().toISOString(),
577
+ count: listResult.items.length,
578
+ items: listResult.items,
579
+ }
580
+ }
581
+
582
+
583
+ /**
584
+ * 抓取觀察者網指定關鍵字之文章清單
585
+ *
586
+ * 站方搜尋API(s.guancha.cn/main/search-v2)由sojson.v4混淆並走MD5簽名, 純curl路徑無法支援,
587
+ * 故本函數一律回status為'error'且error為'unsupported-by-curl', 並於message建議改用作者或主題slug
588
+ *
589
+ * @param {String} keyword 輸入關鍵字字串
590
+ * @param {Object} [opt={}] 輸入設定物件,預設{}
591
+ * @returns {Promise} 回傳Promise,resolve回傳結果物件{status,site,mode,query,fetched_at,error,message},keyword非有效字串時reject回傳錯誤物件
592
+ * @example
593
+ *
594
+ * import { fetchKeywordArticles } from './src/fetchGuancha.mjs'
595
+ *
596
+ * let test = async () => {
597
+ *
598
+ * let r = await fetchKeywordArticles('人工智能')
599
+ * console.log(r.status, r.error)
600
+ * // => 'error' 'unsupported-by-curl'
601
+ *
602
+ * }
603
+ * await test()
604
+ * .catch((err) => {
605
+ * console.log(err)
606
+ * })
607
+ *
608
+ */
609
+ async function fetchKeywordArticles(keyword, opt = {}) {
610
+
611
+ //check
612
+ if (!isestr(keyword)) {
613
+ throw new Error('需要 keyword')
614
+ }
615
+
616
+ return {
617
+ status: 'error',
618
+ site: 'guancha',
619
+ mode: 'keyword',
620
+ query: keyword,
621
+ fetched_at: new Date().toISOString(),
622
+ error: 'unsupported-by-curl',
623
+ message:
624
+ `觀察者網搜尋 API(s.guancha.cn/main/search-v2)由 sojson.v4 混淆並走 MD5 簽名,` +
625
+ `本函數(純 curl 路徑)無法支援。` +
626
+ `如已知作者拼音 slug,請改用 fetchAuthorArticles({ slug });如已知主題 slug,請改用 fetchTopicArticles({ slug })。`,
627
+ }
628
+ }
629
+
630
+
631
+ /**
632
+ * 抓取觀察者網指定標題關鍵字之文章清單
633
+ *
634
+ * 站方無分「標題」與「全文」搜尋(兩者同源), 與fetchKeywordArticles同因簽名混淆而不可用,
635
+ * 故本函數一律回status為'error'且error為'unsupported-by-curl'
636
+ *
637
+ * @param {String} keyword 輸入標題關鍵字字串
638
+ * @param {Object} [opt={}] 輸入設定物件,預設{}
639
+ * @returns {Promise} 回傳Promise,resolve回傳結果物件{status,site,mode,query,fetched_at,error,message},keyword非有效字串時reject回傳錯誤物件
640
+ * @example
641
+ *
642
+ * import { fetchTitleArticles } from './src/fetchGuancha.mjs'
643
+ *
644
+ * let test = async () => {
645
+ *
646
+ * let r = await fetchTitleArticles('人工智能')
647
+ * console.log(r.status, r.error)
648
+ * // => 'error' 'unsupported-by-curl'
649
+ *
650
+ * }
651
+ * await test()
652
+ * .catch((err) => {
653
+ * console.log(err)
654
+ * })
655
+ *
656
+ */
657
+ async function fetchTitleArticles(keyword, opt = {}) {
658
+
659
+ //check
660
+ if (!isestr(keyword)) {
661
+ throw new Error('需要 keyword')
662
+ }
663
+
664
+ return {
665
+ status: 'error',
666
+ site: 'guancha',
667
+ mode: 'title',
668
+ query: keyword,
669
+ fetched_at: new Date().toISOString(),
670
+ error: 'unsupported-by-curl',
671
+ message:
672
+ `觀察者網無分「標題」與「全文」搜尋(兩者同源),` +
673
+ `搜尋 API 由 sojson.v4 混淆並走 MD5 簽名,本函數(純 curl 路徑)無法支援。` +
674
+ `如已知作者拼音 slug,請改用 fetchAuthorArticles({ slug })。`,
675
+ }
676
+ }
677
+
678
+
679
+ /**
680
+ * 抓取觀察者網指定主題之文章清單
681
+ *
682
+ * 給name時先查KNOWN_TOPICS對照表解析出slug再翻頁全抓, 給slug則直接翻頁全抓;
683
+ * 主題不在對照表時fail-fast回status為'error'且error為'topic-not-in-table'
684
+ *
685
+ * @param {Object} [opt={}] 輸入設定物件,預設{}
686
+ * @param {String} [opt.name] 輸入主題中文名字串,與slug擇一必填
687
+ * @param {String} [opt.slug] 輸入主題slug字串,與name擇一必填
688
+ * @param {String} [opt.baseUrl='https://www.guancha.cn'] 輸入網站根網址字串,供測試或改指向鏡像時覆寫
689
+ * @param {Integer} [opt.maxRetries=5] 輸入最大重試次數整數,預設5
690
+ * @param {Integer} [opt.pageDelayMs=1000] 輸入翻頁之頁間延遲毫秒整數,預設1000
691
+ * @param {Integer} [opt.maxPages=50] 輸入翻頁上限正整數,上限為50,預設50
692
+ * @param {Boolean} [opt.showLog=true] 輸入是否顯示過程訊息布林值,預設true
693
+ * @returns {Promise} 回傳Promise,resolve回傳結果物件{status,site,mode,query,resolved,fetched_at,count,items},name與slug皆未給時reject回傳錯誤物件
694
+ * @example
695
+ *
696
+ * import { fetchTopicArticles } from './src/fetchGuancha.mjs'
697
+ *
698
+ * let test = async () => {
699
+ *
700
+ * let r = await fetchTopicArticles({ name: '财经' })
701
+ * console.log(r.status, r.resolved.slug, r.count)
702
+ * // => 'success' 'CaiJing' 300
703
+ *
704
+ * }
705
+ * await test()
706
+ * .catch((err) => {
707
+ * console.log(err)
708
+ * })
709
+ *
710
+ */
711
+ async function fetchTopicArticles(opt = {}) {
712
+
713
+ let cfg = getCfg(opt)
714
+
715
+ //「有提供才檢」, 傳了但非有效字串視為未提供, 讓下方「至少一個」檢查接手
716
+ let name = get(opt, 'name')
717
+ if (!isestr(name)) {
718
+ name = ''
719
+ }
720
+ let slug = get(opt, 'slug')
721
+ if (!isestr(slug)) {
722
+ slug = ''
723
+ }
724
+ if (!name && !slug) {
725
+ throw new Error('需要 name 或 slug')
726
+ }
727
+
728
+ let resolvedSlug = slug
729
+ let resolvedName = name
730
+
731
+ if (!resolvedSlug) {
732
+ let topic = lookupTopic(name)
733
+ if (!topic) {
734
+ //不命中對照表則fail-fast
735
+ return {
736
+ status: 'error',
737
+ site: 'guancha',
738
+ mode: 'topic',
739
+ query: name,
740
+ fetched_at: new Date().toISOString(),
741
+ topics_count: KNOWN_TOPICS.length,
742
+ error: 'topic-not-in-table',
743
+ message:
744
+ `主題 "${name}" 不在已知主題對照表中(共 ${KNOWN_TOPICS.length} 個)。` +
745
+ `觀察者網無公開主題清單頁,本函數採手工對照。建議直接傳 slug(拼音 slug)。` +
746
+ `本函數不自動轉向,請呼叫端決定是否重試。`,
747
+ }
748
+ }
749
+ resolvedSlug = topic.slug
750
+ resolvedName = topic.name
751
+ }
752
+
753
+ let url = `${cfg.baseUrl}/${resolvedSlug}`
754
+
755
+ let listResult = null
756
+ try {
757
+ listResult = await fetchAllListPages(resolvedSlug, cfg)
758
+ }
759
+ catch (err) {
760
+ return {
761
+ status: 'error',
762
+ site: 'guancha',
763
+ mode: 'topic',
764
+ query: resolvedName || resolvedSlug,
765
+ resolved: { slug: resolvedSlug, url, name: resolvedName },
766
+ fetched_at: new Date().toISOString(),
767
+ reason: err.reason,
768
+ error: err.message,
769
+ }
770
+ }
771
+
772
+ return {
773
+ status: 'success',
774
+ site: 'guancha',
775
+ mode: 'topic',
776
+ query: resolvedName || resolvedSlug,
777
+ resolved: { slug: resolvedSlug, url, name: resolvedName, pages_fetched: listResult.pages_fetched },
778
+ fetched_at: new Date().toISOString(),
779
+ count: listResult.items.length,
780
+ items: listResult.items,
781
+ }
782
+ }
783
+
784
+
785
+ /**
786
+ * 抓取觀察者網單篇文章並轉為Markdown
787
+ *
788
+ * 由content all-txt區塊切出正文轉Markdown(保留圖片), 並產生含title、source、author、published與created之YAML frontmatter;
789
+ * 文章已下架時站方會302跳首頁, 本函數偵測此模式並回status為'error'且error為'article-not-found'
790
+ *
791
+ * @param {Object} [opt={}] 輸入設定物件,預設{}
792
+ * @param {String} opt.url 輸入文章網址字串
793
+ * @param {String} [opt.baseUrl='https://www.guancha.cn'] 輸入網站根網址字串,供測試或改指向鏡像時覆寫
794
+ * @param {Integer} [opt.maxRetries=5] 輸入最大重試次數整數,預設5
795
+ * @param {Integer} [opt.pageDelayMs=1000] 輸入翻頁之頁間延遲毫秒整數,預設1000
796
+ * @param {Integer} [opt.maxPages=50] 輸入翻頁上限正整數,上限為50,預設50
797
+ * @param {Boolean} [opt.showLog=true] 輸入是否顯示過程訊息布林值,預設true
798
+ * @returns {Promise} 回傳Promise,resolve回傳結果物件{status,site,mode,url,title,author,published,chars,markdown},url非有效字串時reject回傳錯誤物件
799
+ * @example
800
+ *
801
+ * import { fetchArticle } from './src/fetchGuancha.mjs'
802
+ *
803
+ * let test = async () => {
804
+ *
805
+ * let r = await fetchArticle({ url: 'https://www.guancha.cn/internation/2026_04_29_815417.shtml' })
806
+ * console.log(r.status, r.title, r.chars)
807
+ * // => 'success' 'OPEC+宣布增產' 2345
808
+ *
809
+ * }
810
+ * await test()
811
+ * .catch((err) => {
812
+ * console.log(err)
813
+ * })
814
+ *
815
+ */
816
+ async function fetchArticle(opt = {}) {
817
+
818
+ let cfg = getCfg(opt)
819
+
820
+ //check
821
+ let url = get(opt, 'url')
822
+ if (!isestr(url)) {
823
+ throw new Error('需要 url')
824
+ }
825
+
826
+ let html = null
827
+ try {
828
+ html = await fetchHtml(url, cfg)
829
+ }
830
+ catch (err) {
831
+ return {
832
+ status: 'error',
833
+ site: 'guancha',
834
+ mode: 'fetch',
835
+ url,
836
+ fetched_at: new Date().toISOString(),
837
+ error: err.message || 'fetch failed',
838
+ }
839
+ }
840
+
841
+ //偵測「文章下架被302跳首頁」, 即title為單純「观察者网」且找不到content all-txt區塊
842
+ let titleRaw = extractHtmlTitle(html) || ''
843
+ let contentBlock = extractDivContent(html, 'content all-txt')
844
+ if (titleRaw === '观察者网' || titleRaw === '觀察者網' || !contentBlock) {
845
+ return {
846
+ status: 'error',
847
+ site: 'guancha',
848
+ mode: 'fetch',
849
+ url,
850
+ fetched_at: new Date().toISOString(),
851
+ error: 'article-not-found',
852
+ message: `無此文章(title="${titleRaw}",可能已下架或 URL 不正確)。`,
853
+ }
854
+ }
855
+
856
+ let title = titleRaw.replace(/[_\-\s||]*(?:观察者网|觀察者網)\s*$/u, '').trim()
857
+ let author = extractAuthorFromContent(contentBlock) || ''
858
+ let pubTime = extractPubTime(html) || ''
859
+ let created = new Date().toISOString().slice(0, 10)
860
+ let body = htmlToMarkdown(contentBlock, { image: true })
861
+
862
+ //YAML frontmatter, 雙引號escape內含雙引號
863
+ let yamlEscape = (s) => String(s).replace(/"/g, '\\"')
864
+ let markdown =
865
+ `---
866
+ title: "${yamlEscape(title)}"
867
+ source: "${url}"
868
+ author: "${yamlEscape(author)}"
869
+ published: "${pubTime}"
870
+ created: ${created}
871
+ description:
872
+ ---
873
+ ${body}
874
+ `
875
+
876
+ return {
877
+ status: 'success',
878
+ site: 'guancha',
879
+ mode: 'fetch',
880
+ url,
881
+ title,
882
+ author,
883
+ published: pubTime,
884
+ chars: body.length,
885
+ markdown,
886
+ }
887
+ }
888
+
889
+
890
+ /**
891
+ * 抓取觀察者網(guancha.cn)
892
+ *
893
+ * @returns {Object} 回傳物件,其內可呼叫fetchAuthorsList、lookupAuthor、lookupTopic、fetchAuthorArticles、fetchKeywordArticles、fetchTitleArticles、fetchTopicArticles、fetchArticle
894
+ * @example
895
+ *
896
+ * 詳見fetchAuthorArticles、fetchTopicArticles、fetchArticle範例
897
+ *
898
+ */
899
+ let fetchGuancha = {
900
+ BASE_URL,
901
+ USER_AGENT,
902
+ MAX_PAGES,
903
+ KNOWN_TOPICS,
904
+ safeFilename,
905
+ fetchAuthorsList,
906
+ lookupAuthor,
907
+ lookupTopic,
908
+ fetchAuthorArticles,
909
+ fetchKeywordArticles,
910
+ fetchTitleArticles,
911
+ fetchTopicArticles,
912
+ fetchArticle,
913
+ }
914
+
915
+
916
+ export {
917
+ BASE_URL,
918
+ USER_AGENT,
919
+ PAGE_DELAY_MS,
920
+ MAX_PAGES,
921
+ KNOWN_TOPICS,
922
+ safeFilename,
923
+ isHomepageHtml,
924
+ parseAuthorIndex,
925
+ parseListPage,
926
+ fetchAuthorsList,
927
+ lookupAuthor,
928
+ lookupTopic,
929
+ fetchAuthorArticles,
930
+ fetchKeywordArticles,
931
+ fetchTitleArticles,
932
+ fetchTopicArticles,
933
+ fetchArticle
934
+ }
935
+ export default fetchGuancha