finalizeResult.mjs

import isestr from 'wsemi/src/isestr.mjs'
import { fetchedAtLocal } from './fetchedAt.mjs'


/**
 * 把抓取函數之對外結果轉為內部流程用之結構
 *
 * 四個fetchWebByXxx回傳{status:'success'|'error',...},而執行計畫內部以{success:Boolean,...}判斷,
 * 由本函數轉換以隔離兩者
 *
 * @param {Object} r 輸入抓取函數之結果物件
 * @returns {Object} 回傳內部結構物件,成功時為{success:true,html,method,snapshot,contentKind},失敗時為{success:false,method,reason,message}
 * @example
 *
 * import { adapt } from './src/finalizeResult.mjs'
 *
 * console.log(adapt({ status: 'success', html: '<p>a</p>', method: 'curl' }))
 * // => { success: true, html: '<p>a</p>', method: 'curl', snapshot: undefined, contentKind: undefined }
 *
 */
function adapt(r) {
    if (r.status === 'success') {

        //contentKind與finalUrl皆須列入白名單, 否則會於此被靜默丟棄——
        //前者使runPlan無從得知該內容是否為合成物, 後者使呼叫端與解析器無從得知內容實際來自哪裡
        return { success: true, html: r.html, method: r.method, snapshot: r.snapshot, contentKind: r.contentKind, finalUrl: r.finalUrl }
    }
    return { success: false, method: r.method, reason: r.reason || 'unknown', message: r.message }
}


/**
 * 由抓取失敗之內部結構彙整為一筆attempt
 *
 * @param {Object} r 輸入內部結構之失敗結果物件
 * @returns {Object} 回傳{status:'failed',reason,message}物件
 * @example
 *
 * import { summarizeFail } from './src/finalizeResult.mjs'
 *
 * console.log(summarizeFail({ success: false, reason: 'http-error', message: 'HTTP 500' }))
 * // => { status: 'failed', reason: 'http-error', message: 'HTTP 500' }
 *
 */
function summarizeFail(r) {
    return { status: 'failed', reason: r.reason, message: r.message }
}


/**
 * 彙整fetchWeb之最終回傳結果
 *
 * 成功且已解析時輸出title與content,成功但未解析時輸出html;
 * 失敗時輸出message,並於有失敗歸因時附上reason;內容經adapter之fetch掛點取得、或於該階收攤時,另帶adapterId
 *
 * @param {String} url 輸入網址字串
 * @param {Object} result 輸入內部結構之結果物件
 * @param {Array} attempts 輸入各階嘗試紀錄陣列
 * @returns {Object} 回傳對外之結果物件
 * @example
 *
 * import { finalize } from './src/finalizeResult.mjs'
 *
 * console.log(finalize('https://a.com/', { success: false, reason: 'empty-content', message: 'too short' }, []))
 * // => { status: 'error', url: 'https://a.com/', message: 'too short', fetchedAt: '2026-09-09 16:00:00', attempts: [], reason: 'empty-content' }
 *
 */
function finalize(url, result, attempts) {

    if (result.success) {
        let out = { status: 'success', url, method: result.method, fetchedAt: fetchedAtLocal(), attempts }
        if (result.html !== undefined) {
            out.html = result.html
        }
        else {
            out.title = result.title
            out.content = result.content
            out.contentLength = result.contentLength
        }
        if (result.snapshot) {
            out.snapshot = result.snapshot
        }

        //內容由adapter之fetch掛點取得時另帶其id: method只說得出「來自某個adapter」,
        //說不出是哪一個, 而呼叫端可能同時註冊多個。未經該掛點者不輸出此欄, 維持既有形狀
        if (isestr(result.adapterId)) {
            out.adapterId = result.adapterId
        }

        //內容實際來源之網址
        //
        //`url` 是**本套件最後實際發出請求的網址**(含轉址參數提取後之目標),
        //`finalUrl` 是**這份內容實際來自哪裡**(跟隨HTTP轉址或JS轉址之後)。
        //一句話分辨: url是我要了什麼, finalUrl是我拿到了什麼。
        //
        //兩者為何必須分開: 呼叫端(尤其知識庫類)以url對回自己送出的網址、當主鍵,
        //把url改成最終值會拿走那個能力; 而不給finalUrl則使「內容其實來自別站」無從察覺——
        //實測adapter曾收到「A站的網址配B站的內容」。取不到時省略該欄而非給假值
        if (isestr(result.finalUrl) && result.finalUrl !== url) {
            out.finalUrl = result.finalUrl
        }
        return out
    }

    //reason為失敗歸因, 供呼叫端辨識「adapter程式錯誤」與「頁面真的沒內容」等不同情形,
    //以決定要重試、告警或修adapter; 無reason時不輸出該欄, 維持既有形狀
    let out = {
        status: 'error',
        url,
        message: result.message || 'all methods failed',
        fetchedAt: fetchedAtLocal(),
        attempts,
    }
    if (isestr(result.reason)) {
        out.reason = result.reason
    }

    //於adapter階收攤者另帶adapterId, 與成功結果對稱。fetchWeb之JSDoc此前即宣稱「紀錄與頂層皆另帶」,
    //但本分支未輸出(複審指出); adapter階可收攤之出口由一個增為三個後, 只讀頂層的呼叫端須知道是哪一個adapter決定的。
    //階梯耗盡而失敗者由runPlan不給此欄——最後決定者不是adapter
    if (isestr(result.adapterId)) {
        out.adapterId = result.adapterId
    }
    return out
}


export {
    adapt,
    summarizeFail,
    finalize
}