fetchMsn.mjs

import isestr from 'wsemi/src/isestr.mjs'
import isobj from 'wsemi/src/isobj.mjs'
import fetchWebByCurl from './fetchWebByCurl.mjs'
import fetcherOf from './fetcherSeam.mjs'


//MSN 內建 adapter:網址比對與 fetch 掛點
//
//── 為何需要 fetch 掛點而非 parse ──
//msn.com 之內容頁為純前端渲染, 本套件四階實測全數失敗(2026-09-11, 真實文章頁):
//  curl                 42708 bytes 殼頁, title="MSN", visible=0  → empty
//  playwright-headless  57031 bytes, visible=21                    → empty
//  playwright-headed    56605 bytes, visible=21                    → empty
//  camofox              snapshot 0 字                              → camofox-empty
//整條階梯耗時 43 秒而成功率為 0。問題不在解析, 是頁面裡根本沒有正文——故只能改從內容 API 取得。
//
//── 內容 API ──
//  GET https://assets.msn.com/content/view/v2/Detail/en-us/{id}
//回 JSON, 含 title、body(HTML 字串)、type、provider、sourceHref 等欄。真實量測:
//  文章 ar-  2026-09-11 實抓 12 篇, 皆 200、type='article', body 純文字 505~963 字
//  影片 vi-  2026-09-12 實抓 6 篇, 皆 200、type='video', body 為逐字稿 31~8446 字(其中一篇僅 31 字)
//  其他      gm- 型回 410(提案方實測); 不存在之 id 回 410; 不合法之語系回 400
//
//── 為何回傳重組之 HTML 而非直接回傳文章 ──
//fetch 掛點之輸出契約與四個抓取器同形({status, html}), 取得後照常走解析——
//實測 18 篇重組後之 HTML 經 Readability 17 篇解析成功, 1 篇(逐字稿 31 字)依最低字數門檻回 empty-content,
//故本 adapter 不需要 parse 掛點, 呼叫端亦可另以自己的 parse 覆寫。
//contentKind 標為 synthesized: 這份 HTML 由 JSON 組出, 不是站方原始文件


//API 端點。host 寫死, 路徑中之 id 經下方比對規則限定字元集, 無法注入任意網址。
//
//語系固定為 en-us, 不取網址中的語系: 2026-09-11 實測同一 id 以 11 種語系路徑
//(en-us/en-xl/es-xl/zh-hk/ja-jp/en-gb/fr-fr/pt-br/ar-ae/de-de/EN-US)呼叫, 回應位元組完全相同,
//提案方另測 5 種與 15 種亦同; 而不合法之語系回 400。取網址語系只會多一個失敗點, 沒有任何收益
let API_BASE = 'https://assets.msn.com/content/view/v2/Detail/en-us/'


//內容頁網址之比對規則
//
//形如 https://www.msn.com/zh-tw/news/other/<slug>/ar-AA2bZm9d
//     https://www.msn.com/en-us/video/news/<slug>/vi-AA2bYtCB
//  kind  ar 為文章、vi 為影片(API 之 body 為逐字稿), 兩者皆已以真實網址實測(見檔頭)。
//        gm 型實測 API 回 410、ss(圖集)未取得樣本, 皆不納入——沒有量測就不加
//  id    英數字; 其後須為 /、?、# 或結尾, 使 /ar-foo-bar 這類一般路徑段不被誤判(內容 id 不含 -;
//        提案方掃其佇列 725 篇, id 段含 - 者 0 篇)
//內容段之前的各段不限形態(不要求是 xx-yy 語系, 語系已不用於請求), **但至少須有一段**:
//msn 之語系代碼本身就有以 ar-/vi- 開頭者(ar-ae、ar-sa、vi-vn), 若允許內容段直接接在主機之後,
//語系首頁與頻道頁(如 /ar-ae/news)會被當成 id 為 ae 的內容頁去打 API(實測回 410), 歸因變成「內容已下架」而非「不是內容頁」。
//本檔第一版正是如此放寬, 而 ar-ae 就在上方已測語系清單裡——複審以真實網址抓到。
//真實內容網址一律有語系與分類段(提案方掃 725 篇皆然), 不需要支援根層級 /ar-<id>。
//子網域寬度與 routeByUrl 對 msn 之寫法一致((?:www\.)?), 理由見該檔檔頭
let CONTENT_RE = /^https?:\/\/(?:www\.)?msn\.com\/[^?#]*\/(ar|vi)-([A-Za-z0-9]+)(?:[/?#]|$)/i


/**
 * 比對 msn 內容頁網址並取出內容型別與 id
 *
 * 作為內建 msn adapter 之 match 掛點。回傳之物件即為 ctx,傳入 fetch 掛點
 *
 * @param {String} url 輸入網址字串
 * @returns {Object|null} 命中時回傳{kind,id},kind為'ar'(文章)或'vi'(影片);未命中時回傳null
 * @example
 *
 * import { matchMsn } from './src/fetchMsn.mjs'
 *
 * console.log(matchMsn('https://www.msn.com/zh-tw/news/other/abc/ar-AA2bZm9d'))
 * // => { kind: 'ar', id: 'AA2bZm9d' }
 *
 * console.log(matchMsn('https://www.msn.com/en-us/video/news/abc/vi-AA2bYtCB'))
 * // => { kind: 'vi', id: 'AA2bYtCB' }
 *
 * console.log(matchMsn('https://www.msn.com/zh-tw/news'))
 * // => null
 *
 */
function matchMsn(url) {
    if (!isestr(url)) {
        return null
    }
    let m = url.match(CONTENT_RE)
    if (!m) {
        return null
    }
    return { kind: m[1].toLowerCase(), id: m[2] }
}


//HTML 轉義, 供標題放入重組之文件
function _esc(s) {
    return String(s)
        .replace(/&/g, '&amp;')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;')
        .replace(/"/g, '&quot;')
}


//內容 API 回應形狀不合預期時之失敗結果
function _miss(message) {
    return { status: 'error', reason: 'adapter-fetch-miss', message: 'msn: ' + message }
}


/**
 * 經 msn 內容 API 取得內容,重組為 HTML 文件
 *
 * 作為內建 msn adapter 之 fetch 掛點。API 之請求走本套件之 curl 抓取器,
 * 故 User-Agent、重試與 HTTP 狀態判準與其餘抓取一致;opt 原樣轉傳,並沿用 opt._fetchers.curl 測試接縫
 *
 * @param {String} url 輸入內容頁網址字串,本函數僅用於訊息
 * @param {Object} opt 輸入設定物件,轉傳給 curl 抓取器
 * @param {Object} ctx 輸入 matchMsn 之回傳物件{kind,id}
 * @returns {Promise} 回傳Promise,resolve回傳結果物件,成功時為{status:'success',html,contentKind:'synthesized'},失敗時為{status:'error',reason,message},本函數不會reject
 * @example
 *
 * import { matchMsn, fetchMsn } from './src/fetchMsn.mjs'
 *
 * let test = async () => {
 *     let url = 'https://www.msn.com/zh-tw/news/other/abc/ar-AA2bZm9d'
 *     let r = await fetchMsn(url, {}, matchMsn(url))
 *     console.log(r.status, r.contentKind)
 *     // => 'success' 'synthesized'
 * }
 * await test()
 *     .catch((err) => {
 *         console.log(err)
 *     })
 *
 */
async function fetchMsn(url, opt, ctx) {

    //ctx 由內建之 match 供給時必帶 id; 但本函數對外匯出, 可被呼叫端配上自己的 match 使用, 故仍檢核
    if (!isobj(ctx) || !isestr(ctx.id)) {
        return _miss('no content id in url')
    }

    //真實curl抓取器不會reject, 但本函數經測試接縫可接到任意函數;
    //上層runFetchSafely雖會攔下, 本函數自身之「不會reject」仍須成立, 不依賴呼叫者代為兜底
    let curl = fetcherOf(opt, 'curl', fetchWebByCurl)
    let r
    try {
        r = await curl(API_BASE + ctx.id, opt)
    }
    catch (err) {
        return { status: 'error', reason: 'fetcher-error', message: 'msn api: ' + (err?.message || String(err)) }
    }

    //抓取失敗(含 410 內容不存在、400 等)原樣帶回其歸因, 不改寫——那是抓取層已登記之值域
    if (r?.status !== 'success') {
        return { status: 'error', reason: r?.reason || 'unknown', message: 'msn api: ' + (r?.message || 'request failed') }
    }

    let data
    try {
        data = JSON.parse(r.html)
    }
    catch {
        return _miss('api response is not JSON')
    }

    if (!isobj(data)) {
        return _miss('api response is not an object')
    }

    //只要求有 body, 不檢核 type: 實測文章為 'article'、影片為 'video', 兩者皆有 body。
    //站方若再增一種帶 body 的型別, 把它交給判識與解析仍是正確的預設(套件給機制);
    //以白名單擋掉只會多一個要發版才能解的失敗。正文不足則由解析階以 empty-content 回報
    if (!isestr(data.body)) {
        return _miss('content has no body')
    }

    let title = isestr(data.title) ? data.title : ''
    let html = '<!DOCTYPE html><html><head><meta charset="utf-8"><title>' + _esc(title) + '</title></head>' +
        '<body><article><h1>' + _esc(title) + '</h1>' + data.body + '</article></body></html>'

    return { status: 'success', html, contentKind: 'synthesized' }
}


export {
    matchMsn,
    fetchMsn
}