UNPKG

rsshub

Version:
490 lines (487 loc) • 17.7 kB
import { t as parseDate } from "./parse-date-r5WDWHno.mjs"; import { t as got_default } from "./got-CO-ndVl2.mjs"; import { Fragment, jsx, jsxs } from "hono/jsx/jsx-runtime"; import { load } from "cheerio"; import { renderToString } from "hono/jsx/dom/server"; import { raw } from "hono/html"; import CryptoJS from "crypto-js"; //#region lib/routes/huxiu/templates/description.tsx const renderDescription = ({ image, audio, video, preface, summary, description }) => renderToString(/* @__PURE__ */ jsxs(Fragment, { children: [ !video?.src && image?.src ? /* @__PURE__ */ jsx("figure", { children: /* @__PURE__ */ jsx("img", { src: image.src, width: image.width, height: image.height }) }) : null, audio?.src ? /* @__PURE__ */ jsx("audio", { controls: true, children: /* @__PURE__ */ jsx("source", { src: audio.src, type: audio.type }) }) : null, video?.src ? /* @__PURE__ */ jsxs("video", { poster: video.poster || image?.src, controls: true, children: [/* @__PURE__ */ jsx("source", { src: video.src, type: video.type }), /* @__PURE__ */ jsx("object", { data: video.src, children: /* @__PURE__ */ jsx("embed", { src: video.src }) })] }) : null, preface ? /* @__PURE__ */ jsx(Fragment, { children: raw(preface) }) : null, summary ? /* @__PURE__ */ jsx(Fragment, { children: raw(summary) }) : null, description ? /* @__PURE__ */ jsx(Fragment, { children: raw(description) }) : null ] })); //#endregion //#region lib/routes/huxiu/util.ts const domain = "huxiu.com"; const rootUrl = `https://www.${domain}`; const apiArticleRootUrl = `https://api-web-article.${domain}`; const apiClubRootUrl = `https://api-ms-web-club.${domain}`; const apiMemberRootUrl = `https://api-account.${domain}`; const apiMomentRootUrl = `https://moment-api.${domain}`; const apiSearchRootUrl = `https://search-api.${domain}`; /** * Cleans up HTML data by removing specific elements and attributes. * * @param {string} data - The HTML data to clean up. * @returns {string} - The cleaned up HTML data. */ const cleanUpHTML = (data) => { const $ = load(data); $("div.neirong-shouquan").remove(); $("em.vote__bar, div.vote__btn, div.vote__time").remove(); $("p img").each((_, e) => { e = $(e); if ((e.prop("src") ?? e.prop("_src")) !== void 0) e.parent().replaceWith(renderDescription({ image: { src: (e.prop("src") ?? e.prop("_src")).split(/\?/)[0], width: e.prop("data-w"), height: e.prop("data-h") } })); }); $("p, span").each((_, e) => { e = $(e); if (e.contents().length === 1 && /^\s*$/.test(e.text())) e.remove(); else { e.removeClass(); e.removeAttr("data-check-id label class"); } }); $(".text-big-title").each((_, e) => { e.tagName = "h3"; e = $(e); e.removeClass(); e.removeAttr("class"); }); $(".text-sm-title").each((_, e) => { e.tagName = "h4"; e = $(e); e.removeClass(); e.removeAttr("class"); }); return $.html(); }; /** * Fetches club data for the specified ID. * * @param {string} id - The ID of the club to fetch data from. * @returns {Promise<Object>} A promise that resolves to an object containing the fetched data * to be added into `ctx.state.data`. */ const fetchClubData = async (id) => { const currentUrl = new URL(`club/${id}.html`, rootUrl).href; const { data: currentResponse } = await got_default(currentUrl); const $ = load(currentResponse); const title = $("title").text(); const icon = new URL($("link[rel=\"apple-touch-icon\"]").prop("href") ?? "", rootUrl).href; const author = $("meta[name=\"author\"]").prop("content"); let clubData; const nuxtDataScript = $("#__NUXT_DATA__").text(); if (nuxtDataScript) try { const nuxtData = JSON.parse(nuxtDataScript); for (const item of nuxtData) if (typeof item === "object" && item !== null && !Array.isArray(item)) { if (Object.keys(item).includes("fetchData")) { const fetchDataIndex = item.fetchData; if (typeof fetchDataIndex === "number") { clubData = resolveNuxtData(nuxtData, fetchDataIndex); break; } } } } catch {} return { title, link: currentUrl, description: String(clubData?.format_desc ?? $("meta[name=\"description\"]").prop("content") ?? ""), language: $("html").prop("lang"), image: clubData?.icon_path ? String(clubData.icon_path).split(/\?/)[0] : void 0, icon, logo: icon, subtitle: String(clubData?.name ?? title.split(/-/)[0]), author, itunes_author: author, itunes_category: "News", allowEmpty: true }; }; /** * Fetch data from the specified URL. * * @param {string} url - The URL to fetch data from. * @returns {Promise<Object>} A promise that resolves to an object containing the fetched data * to be added into `ctx.state.data`. */ const fetchData = async (url) => { const { data: response } = await got_default(url); const $ = load(response); const icon = new URL($("link[rel=\"apple-touch-icon\"]").prop("href"), rootUrl).href; const author = $("meta[name=\"author\"]").prop("content"); return { title: $("title").text(), link: url, description: $("div.tag-content").text() || $("span.author-intro").text() || $("p.collection__intro").text() || $("meta[name=\"description\"]").prop("content"), language: $("html").prop("lang"), icon, logo: icon, subtitle: $("title").text().split(/-/)[0], author, itunes_author: author, itunes_category: "News", allowEmpty: true }; }; /** * Builds category array from article data. * * @param {Object} data - The article data. * @returns {string[]} - Array of category names. */ const buildCategories = (data) => [ data.video_article_tag, data.brief_column?.name, data.club_info?.name, ...data.tags_info?.map((c) => c.name) ?? [], ...data.relation_info?.channel?.map((c) => c.name) ?? [] ].filter(Boolean); /** * Fetches item data. * * @param {Object} item - The item to fetch data for. * @returns {Promise<Object>} The fetched item data object. */ const fetchItem = async (item) => { let detailResponse; try { detailResponse = (await got_default(item.link)).data; } catch { return item; } const data = parseInitialState(detailResponse)?.articleDetail?.articleDetail; if (!data) return item; const { processed: audio, processedItem: audioItem = {} } = processAudioInfo(data.audio_info); if (Object.keys(audioItem).length !== 0) audioItem.itunes_item_image = data.pic_path ?? data.share_info?.share_img ?? void 0; const { processed: video, processedItem: videoItem = {} } = processVideoInfo(data.video_info); const preface = data.content_preface ?? data.preface; const content = data.content; return { ...audioItem, ...videoItem, ...item, title: data.title ?? item.title, description: renderDescription({ image: { src: data.pic_path }, video, audio, preface: preface ? cleanUpHTML(preface) : void 0, summary: data.summary, description: content ? cleanUpHTML(content) : void 0 }), author: data.user_info?.username ?? item.author, category: buildCategories(data), pubDate: parseDate(data.dateline ?? data.publish_time, "X"), upvotes: Number.parseInt(data.agreenum ?? item.upvotes ?? 0, 10), comments: Number.parseInt(data.commentnum ?? data.total_comment_num ?? item.comments ?? 0, 10) }; }; /** * Generates a random nonce string. * * @returns {string} The generated nonce string. */ const generateNonce = () => { let nonce = ""; const e = "abcdefghijklmnopqrstuvwxyz1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ"; const t = 16; for (let i = 0; i < t; i++) nonce += e.charAt(Math.floor(Math.random() * 62)); return nonce; }; /** * Generates a signature object containing a nonce, timestamp, and signature value. * * @returns {string} nonce - The generated nonce. * @returns {string} timestamp - The timestamp. * @returns {string} signature - The calculated signature value. */ const generateSignature = () => { const timestamp = Math.round(Date.now() / 1e3).toString(); const appSecret = "hUzaABtNfDE-6UiyaYhfsmjW-8dnoyVc"; const nonce = generateNonce(); const r = [ appSecret, timestamp, nonce ].toSorted(); return { nonce, timestamp, signature: CryptoJS.SHA1(r[0] + r[1] + r[2]).toString() }; }; /** * Resolves Nuxt 3 data array references recursively. * Nuxt 3 uses a special array format where numbers are references to other array indices. * * @param {unknown[]} arr - The Nuxt data array. * @param {number} index - The index to resolve. * @param {Set<number>} visited - Set of visited indices to prevent infinite loops. * @returns {unknown} - The resolved value. */ const resolveNuxtData = (arr, index, visited = /* @__PURE__ */ new Set()) => { if (visited.has(index)) return arr[index]; visited.add(index); const item = arr[index]; if (Array.isArray(item) && item[0] === "ShallowReactive" && typeof item[1] === "number") return resolveNuxtData(arr, item[1], visited); if (Array.isArray(item) && item[0] === "Reactive" && typeof item[1] === "number") return resolveNuxtData(arr, item[1], visited); if (Array.isArray(item) && item[0] === "Set") return /* @__PURE__ */ new Set(); if (typeof item === "object" && item !== null && !Array.isArray(item)) { const resolved = {}; for (const [key, value] of Object.entries(item)) resolved[key] = typeof value === "number" ? resolveNuxtData(arr, value, new Set(visited)) : value; return resolved; } if (Array.isArray(item)) return item.map((value, i) => { if (typeof value === "number" && value !== i) return resolveNuxtData(arr, value, new Set(visited)); return value; }); return item; }; /** * Parses the initial state from the provided data. * Supports both Nuxt 3 (__NUXT_DATA__) format for articles and * window.__INITIAL_STATE__ format for briefs. * * @param {string} data - The data to parse the initial state from. * @returns {Object|undefined} - The parsed initial state object, or undefined if not found. */ const parseInitialState = (data) => { const nuxtDataScript = load(data)("#__NUXT_DATA__").text(); if (nuxtDataScript) try { const nuxtData = JSON.parse(nuxtDataScript); for (const item of nuxtData) if (typeof item === "object" && item !== null && !Array.isArray(item)) { const articleDetailKey = Object.keys(item).find((key) => key.startsWith("articleDetail-")); if (articleDetailKey) { const articleDetailIndex = item[articleDetailKey]; if (typeof articleDetailIndex === "number") { const articleData = resolveNuxtData(nuxtData, articleDetailIndex); if (articleData?.articleDetail) return { articleDetail: articleData }; } } } } catch {} const initialStateMatch = data.match(/window\.__INITIAL_STATE__\s*=\s*(\{[\s\S]*?\});?\s*\(function\(\)/); if (initialStateMatch?.[1]) try { const briefDetail = JSON.parse(initialStateMatch[1]).briefStoreModule?.brief_detail; const brief = briefDetail?.brief; if (brief) { const publisherList = brief.publisher_list; const briefColumn = briefDetail?.brief_column; const clubInfo = briefDetail?.club_info; return { articleDetail: { articleDetail: { title: brief.title, content: brief.content, preface: brief.preface, dateline: brief.publish_time, publish_time: brief.publish_time, audio_info: brief.audio_info, agreenum: brief.agree_num, total_comment_num: brief.total_comment_num, user_info: publisherList?.[0] ? { username: publisherList[0].username } : void 0, brief_column: briefColumn ? { name: briefColumn.name } : void 0, club_info: clubInfo ? { name: clubInfo.name } : void 0, share_info: brief.share_info } } }; } } catch {} }; const audioQualities = ["", "low"]; /** * Processes the audio information and returns the processed data. * * @param {Object} info - The audio information to process. * @returns {Object} - An object containing the processed audio data. */ const processAudioInfo = (info) => { const quality = info ? audioQualities.find((quality) => Object.hasOwn(info, `audio_${quality === "" ? "" : `${quality}_`}path`)) : void 0; if (quality === void 0) return { processed: void 0, processedItem: {} }; const linkKey = `audio_${quality}path`; const sizeKey = `audio_${quality}size`; const processed = { duration: info.format_length_new ?? info.format_length, size: Object.hasOwn(info, sizeKey) ? info[sizeKey] : void 0, src: info[linkKey], type: `audio/${info[linkKey].split(/\./).pop()}` }; return { processed, processedItem: { itunes_duration: processed.duration, enclosure_url: processed.src, enclosure_length: processed.size, enclosure_type: processed.type } }; }; /** * Resolves item identifiers (guid and link) based on item type. * * @param {Object} item - The item to resolve identifiers for. * @returns {Object|null} - Object with guid and link, or null if invalid item. */ const resolveItemIdentifiers = (item) => { if (item.object_type === 8) return { guid: `huxiu-moment-${item.object_id}`, link: item.url || new URL(`moment/${item.object_id}.html`, rootUrl).href }; if (item.brief_id || /huxiu\.com\/brief\//.test(item.url)) { const briefId = item.brief_id ?? item.aid; return { guid: `huxiu-brief-${briefId}`, link: new URL(`brief/${briefId}.html`, rootUrl).href }; } if (item.aid) return { guid: `huxiu-article-${item.aid}`, link: new URL(`article/${item.aid}.html`, rootUrl).href }; return null; }; /** * Extracts count information from item. * * @param {Object} item - The item to extract counts from. * @returns {Object} - Object with upvotes, downvotes, and comments. */ const extractCounts = (item) => ({ upvotes: Number.parseInt(item.count_info?.agree ?? item.count_info?.favtimes ?? item.agree_num ?? 0, 10), downvotes: Number.parseInt(item.count_info?.disagree ?? 0, 10), comments: Number.parseInt(item.count_info?.total_comment_num ?? item.count_info?.commentnum ?? item.total_comment_num ?? item.commentnum ?? 0, 10) }); /** * Extracts author from item using various possible fields. * * @param {Object} item - The item to extract author from. * @returns {string|undefined} - The author name or undefined. */ const extractAuthor = (item) => item.user_info?.username ?? item.brief_column?.name ?? item.author_info?.username ?? item.author; /** * Extracts image source from item. * * @param {Object} item - The item to extract image from. * @returns {string|undefined} - The image URL or undefined. */ const extractImageSrc = (item) => item.origin_pic_path ?? item.pic_path ?? item.big_pic_path?.split(/\?/)[0]; /** * Maps a single item to a processed item object. * * @param {Object} item - The raw item to process. * @returns {Object|string} - The processed item or empty string if invalid. */ const mapItem = (item) => { const identifiers = resolveItemIdentifiers(item); if (!identifiers) return ""; const { processed: audio, processedItem: audioItem = {} } = processAudioInfo(item.audio_info); if (Object.keys(audioItem).length !== 0) audioItem.itunes_item_image = item.pic_path ?? item.share_info?.share_img ?? void 0; const { processed: video, processedItem: videoItem = {} } = processVideoInfo(item.video_info); const counts = extractCounts(item); const timestamp = item.publish_time ?? item.dateline; return { ...audioItem, ...videoItem, title: (item.title ?? item.summary ?? item.content)?.replaceAll(/<\/?(?:em|br)?>/g, ""), link: identifiers.link, description: renderDescription({ image: { src: extractImageSrc(item) }, audio, video, summary: item.summary ?? item.content ?? item.preface }), author: extractAuthor(item), guid: identifiers.guid, pubDate: timestamp ? parseDate(timestamp, "X") : void 0, ...counts }; }; /** * Process the item list and return the resulting array. * * @param {Object[]} items - The items to process. * @param {number} limit - The maximum number of items to process. * @param {Function} tryGet - The tryGet function that handles the retrieval process. * @returns {Promise<Object[]>} - A promise that resolves to an array of processed items. */ const processItems = async (items, limit, tryGet) => { const processedItems = items.map((item) => mapItem(item)).filter(Boolean).slice(0, limit); return await Promise.all(processedItems.map((item) => tryGet(item.guid, async () => { const isExternalLink = !new RegExp(domain, "i").test(new URL(item.link).hostname); const isMoment = item.guid.startsWith("huxiu-moment"); if (isExternalLink || isMoment) return item; return await fetchItem(item); }))); }; const videoQualities = [ "fhd", "fhd_medium", "wifi", "fhd_low", "flow", "hd", "sd" ]; /** * Processes the video information and returns the processed data. * * @param {Object} info - The video information to process. * @returns {Object} - An object containing the processed video data. */ const processVideoInfo = (info) => { const quality = info ? videoQualities.find((quality) => Object.hasOwn(info, `${quality}_link`)) : void 0; if (quality === void 0) return { processed: void 0, processedItem: {} }; const linkKey = `${quality}_link`; const sizeKey = `origin_${quality}_size`; const processed = { duration: info.duration ?? info.origin_duration, poster: info.cover ?? info.custom_cover_path ?? info.gif_path, size: Object.hasOwn(info, sizeKey) ? info[sizeKey] : void 0, src: info[linkKey], type: `video/${info[linkKey].split(/\./).pop()}` }; return { processed, processedItem: { itunes_item_image: processed.poster, itunes_duration: processed.duration, enclosure_url: processed.src, enclosure_length: processed.size, enclosure_type: processed.type } }; }; //#endregion export { apiSearchRootUrl as a, generateSignature as c, apiMomentRootUrl as i, processItems as l, apiClubRootUrl as n, fetchClubData as o, apiMemberRootUrl as r, fetchData as s, apiArticleRootUrl as t, rootUrl as u };