import { Readability } from '@mozilla/readability'
import { JSDOM } from 'jsdom'
import isfun from 'wsemi/src/isfun.mjs'
import isestr from 'wsemi/src/isestr.mjs'
import { meetsMinContent, normalizeParsed } from './adapterContract.mjs'
//呼叫adapter之parse, 攔下拋錯與非法回傳, 並把成功結果投影為固定形狀
//adapter.parse來自使用端, 其拋錯不得破壞fetchWeb「不會reject」之契約;
//await可同時涵蓋同步拋錯與async reject, 避免async adapter產生unhandledRejection
async function _parseByAdapter(adapter, ctx, html, url, meta) {
let pre = 'adapter ' + adapter.id + ' '
let parsed
try {
//meta為第四個參數: JS對多餘參數天生相容, 既有adapter一行都不用改。
//它帶的是「這份內容實際來自哪裡」等資訊——adapter以**請求網址**挑選,
//而內容可能來自轉址後的別站(實測adapter曾收到A站的網址配B站的內容),
//故由套件給資訊、adapter自己判斷要不要處理, 不由套件代為猜測
parsed = await adapter.parse(html, url, ctx, meta)
}
catch (err) {
return { success: false, reason: 'parse-error', message: pre + 'parse error: ' + (err?.message || String(err)) }
}
//檢核與投影一律交由契約模組, 使adapter之輸出契約只有一處實作
return normalizeParsed(parsed, pre)
}
/**
* 依已解析之adapter命中結果解析文章,未命中者走Readability
*
* adapter之挑選(findAdapter)刻意不在本函數內:它只取決於網址,與抓回之HTML無關,
* 故由runPlan於計畫執行前解析一次後傳入,詳見runPlan之_resolveAdapter
*
* @param {String} html 輸入網頁HTML字串
* @param {String} url 輸入網址字串
* @param {Object|null} hit 輸入findAdapter之結果物件,null視為未命中
* @param {Object|null} [meta=null] 輸入本次抓取之附帶資訊物件,含finalUrl等,供adapter判斷與JSDOM之base url使用,預設null
* @returns {Promise} 回傳Promise,resolve回傳解析結果物件,本函數不會reject
*/
async function parseArticle(html, url, hit, meta = null) {
//須同時確認該adapter確實有parse掛點: 三個掛點各自獨立, 只註冊fetch而不註冊parse是合法的
//(見adapterContract之輸入契約), 此時解析仍走Readability。
//少了這個判斷, 只有fetch的adapter會在此以「adapter.parse is not a function」失敗
if (hit?.type === 'hit' && isfun(hit.adapter?.parse)) {
return _parseByAdapter(hit.adapter, hit.ctx, html, url, meta)
}
let article
try {
//base url取最終網址: Readability以它解析相對連結與判斷同源。
//此前恆用請求網址, 轉址後的頁面其文內相對連結會被解析到**轉址來源**的origin
let doc = new JSDOM(html, { url: isestr(meta?.finalUrl) ? meta.finalUrl : url })
article = new Readability(doc.window.document).parse()
}
catch (err) {
//JSDOM與Readability對畸形HTML或異常輸入可能throw, 攔下改回傳解析失敗,
//讓上層走「視為empty繼續升級, 或回error結果」, 不讓整支程式crash
return { success: false, reason: 'parse-error', message: 'parse error: ' + (err?.message || String(err)) }
}
let content = article?.textContent?.trim() || ''
let title = article?.title?.trim() || ''
if (!meetsMinContent(content)) {
return {
success: false,
reason: 'empty-content',
message: 'content too short (' + content.length + ' chars)',
}
}
return { success: true, title, content, contentLength: content.length }
}
export default parseArticle