rsshub
Version:
Make RSS Great Again!
267 lines (266 loc) • 10.6 kB
JavaScript
import { t as rofetch } from "./ofetch-C3ts-Hud.mjs";
import { t as config } from "./config-CCmw1BNE.mjs";
import { t as parseDate } from "./parse-date-Cr0wQjV_.mjs";
import { t as cache_default } from "./cache-BkqOokyU.mjs";
import { t as ConfigNotFoundError } from "./config-not-found-fQ5mxvDU.mjs";
import vm from "node:vm";
//#region lib/routes/locals/feed.ts
const contentFilterMap = {
nonplus: "regular_content",
plus: "content_plus"
};
const contentTypeMap = {
article: "article",
audio: "audio",
document: "document",
live: "live_stream",
live_stream: "live_stream",
pdf: "document",
podcast: "audio",
podcasts: "audio",
video: "video"
};
const rootUrl = "https://locals.com";
const route = {
path: "/content/:community/:option1?/:option2?",
categories: ["social-media"],
example: "/locals/content/mattfradd/video",
parameters: {
community: "Community slug from `locals.com/:community/feed?mode=content`",
option1: "Filter or content type. Filters: `plus`, `nonplus`. Content types: `video`, `live`, `audio`, `podcast`, `article`, `document`, `pdf`",
option2: "Content type when `option1` is a filter"
},
description: "Fetches the Locals content library with an authenticated session cookie. By default it merges regular content and content+ posts, and it can be filtered by access tier and media type.",
features: {
requireConfig: [{
name: "LOCALS_SESSION",
description: "The value of the `locals2.v3.session` cookie after logging in to Locals"
}],
requirePuppeteer: false,
antiCrawler: true,
supportBT: false,
supportPodcast: false,
supportScihub: false
},
radar: [{
source: ["locals.com/:community/feed"],
target: "/content/:community"
}],
name: "Content Feed",
maintainers: ["luckycold"],
handler
};
function getCookieHeader(session) {
return [
`locals2.v3.session=${session}`,
"locals.preferLocals2=false",
"locals2.v3.locale.actual=en-us%2Cen",
"locals2.v3.locale.inferred=en"
].join("; ");
}
function getRequestHeaders(session) {
return { Cookie: getCookieHeader(session) };
}
function extractAssetUrls(html) {
return [...new Set(html.matchAll(/\/_build\/assets\/[^"'\s>]+\.js/g).toArray().map((match) => match[0]))];
}
function extractFeedActionId(asset) {
const symbol = asset.match(/getFeedPosts:([A-Za-z_$][\w$]*)/)?.[1];
if (!symbol) return;
const match = asset.match(new RegExp(`${symbol}=s\\(\\(\\)=>\\{\\},"([^"]+)","([^"]+)"\\)`));
if (!match) return;
return `${match[1]}#${match[2]}`;
}
function createRequestBody(value) {
let nextIndex = 0;
const encode = (input) => {
if (typeof input === "number") return {
t: 0,
s: input
};
if (typeof input === "string") return {
t: 1,
s: input
};
if (Array.isArray(input)) {
const i = nextIndex++;
return {
a: input.map((item) => encode(item)),
i,
l: input.length,
o: 0,
t: 9
};
}
if (input && typeof input === "object") {
const i = nextIndex++;
const entries = Object.entries(input).filter(([, item]) => item !== void 0);
return {
i,
o: 0,
p: {
k: entries.map(([key]) => key),
s: entries.length,
v: entries.map(([, item]) => encode(item))
},
t: 10
};
}
throw new Error("Unsupported Locals request payload.");
};
return JSON.stringify({
f: 31,
m: [],
t: encode(value)
});
}
function parseUnknownResponse(body, instanceId) {
const sandbox = {
$R: {},
self: {}
};
sandbox.self.$R = sandbox.$R;
vm.createContext(sandbox);
vm.runInContext(body, sandbox, { timeout: 5e3 });
const value = sandbox.self.$R?.[instanceId]?.[0];
if (!value) throw new Error("Unable to decode the Locals server response.");
return value;
}
function extractCommunityInfo(html, community) {
const markerIndex = html.indexOf(`hashtag:"${community}"`);
if (markerIndex === -1) throw new Error("Unable to resolve the Locals community metadata.");
const context = html.slice(Math.max(0, markerIndex - 200), markerIndex + 1200);
const idMatch = context.match(/id:(\d+)/);
const titleMatch = context.match(/title:"([^"]+)"/);
const descriptionMatch = context.match(/description:"([^"]*)"/);
const imageMatch = context.match(/image:\$R\[\d+\]=\{big:"([^"]*)",thumb:"([^"]*)"/);
if (!idMatch || !titleMatch) throw new Error("Unable to resolve the Locals community metadata.");
return {
description: descriptionMatch?.[1],
design: { image: {
big: imageMatch?.[1],
thumb: imageMatch?.[2]
} },
hashtag: community,
id: Number(idMatch[1]),
title: titleMatch[1]
};
}
function fetchContentPage(community, session) {
return cache_default.tryGet(`locals:content-page:${community}`, () => rofetch(`${rootUrl}/${community}/feed?mode=content`, { headers: getRequestHeaders(session) }));
}
function resolveActionIds(community, session) {
return cache_default.tryGet(`locals:action-ids:${community}`, async () => {
const serverApiAsset = extractAssetUrls(await fetchContentPage(community, session)).find((assetUrl) => assetUrl.includes("/serverApi-"));
if (!serverApiAsset) throw new Error("Unable to locate the current Locals serverApi asset.");
const serverId = extractFeedActionId(await rofetch(new URL(serverApiAsset, rootUrl).href, { headers: getRequestHeaders(session) }));
if (!serverId) throw new Error("Unable to discover the current Locals feed action id.");
return serverId;
});
}
function getImage(post) {
return post.photos?.[0]?.sizes?.full?.url || post.photos?.[0]?.sizes?.thumb?.url || post.previews?.[0]?.url || post.previews?.[0]?.first_frame_url || post.videos?.[0]?.preview || post.audios?.[0]?.preview;
}
function getTitle(post) {
const textFallback = post.text?.replaceAll(/<[^>]+>/g, "\n").split("\n").map((line) => line.trim()).find(Boolean);
return post.title || post.subtitle || textFallback || post.share_url || "Locals post";
}
function renderDescription(image, description) {
if (image && description) return `<p><img src="${image}"></p>${description}`;
if (image) return `<p><img src="${image}"></p>`;
return description;
}
function mapPostToItem(post) {
const image = getImage(post);
const hasBodyContent = Boolean(post.text || image || post.photos?.length || post.videos?.length || post.audios?.length);
if (!post.share_url || !post.is_content && post.content_type === "no_content" && !hasBodyContent) return null;
const contentType = post.content_type && post.content_type !== "no_content" ? [post.content_type] : [];
const accessType = post.content_plus?.enabled ? ["content_plus"] : ["content"];
return {
author: post.author_name || post.author_username,
category: [...accessType, ...contentType],
description: renderDescription(image, post.text),
image,
itunes_item_image: image,
link: post.share_url,
pubDate: post.published ? parseDate(post.published) : post.post_date ? parseDate(post.post_date) : void 0,
title: getTitle(post)
};
}
function parseOptions(option1, option2) {
const values = [option1, option2].filter(Boolean);
const hasFilter = (value) => Object.hasOwn(contentFilterMap, value);
const hasContentType = (value) => Object.hasOwn(contentTypeMap, value);
const filter = values.find((value) => hasFilter(value));
const contentType = values.find((value) => hasContentType(value));
if (values.find((value) => !hasFilter(value) && !hasContentType(value))) throw new Error("Invalid Locals content route option. Supported filters are `plus` and `nonplus`, and supported content types are `video`, `live`, `audio`, `podcast`, `article`, `document`, and `pdf`.");
return {
contentType: contentType ? contentTypeMap[contentType] : void 0,
filter
};
}
async function requestServerFunction(session, id, key, args) {
const response = await rofetch.raw(`${rootUrl}/_server`, {
body: createRequestBody(args),
headers: {
"Content-Type": "application/json",
...getRequestHeaders(session),
"X-Server-Id": id,
"X-Server-Instance": key
},
method: "POST",
responseType: "text"
});
if (!response.ok) throw new Error(`Unable to access the Locals server function (${response.status}).`);
return parseUnknownResponse(response._data, key);
}
function fetchCommunityInfo(community, session) {
return cache_default.tryGet(`locals:community:${community}`, async () => {
return extractCommunityInfo(await fetchContentPage(community, session), community);
});
}
async function fetchFeedData(communityId, community, session, serverId, filter, contentType) {
const requestFilter = filter ? contentFilterMap[filter] : void 0;
const filters = requestFilter ? [requestFilter] : Object.values(contentFilterMap);
const responses = await Promise.all(filters.map((currentFilter) => cache_default.tryGet(`locals:data:${communityId}:${community}:${currentFilter}:${contentType ?? "all"}`, () => requestServerFunction(session, serverId, `server-fn:rsshub-${community}-${currentFilter}-${contentType ?? "all"}`, [{
communityId,
contentType,
filter: currentFilter,
order: "recent",
page: 1,
pageSize: 20
}]))));
const items = /* @__PURE__ */ new Map();
for (const response of responses) for (const post of response.data) {
const item = mapPostToItem(post);
if (!item?.link) continue;
if (!items.has(item.link)) {
items.set(item.link, item);
continue;
}
const existing = items.get(item.link);
if (existing) {
existing.category = [.../* @__PURE__ */ new Set([...existing.category ?? [], ...item.category ?? []])];
existing.description ||= item.description;
existing.itunes_item_image ||= item.itunes_item_image;
}
}
return items.values().toArray();
}
async function handler(ctx) {
const { community, option1, option2 } = ctx.req.param();
if (!config.locals?.session) throw new ConfigNotFoundError("Locals RSS is disabled due to the lack of <a href=\"https://docs.rsshub.app/deploy/config#route-specific-configurations\">relevant config</a>");
const { contentType, filter } = parseOptions(option1, option2);
const serverId = await resolveActionIds(community, config.locals.session);
const communityInfo = await fetchCommunityInfo(community, config.locals.session);
const items = await fetchFeedData(communityInfo.id, community, config.locals.session, serverId, filter, contentType);
return {
description: `Locals content feed for ${communityInfo.title}${filter ? ` (${filter})` : ""}${contentType ? ` (${contentType})` : ""}`,
image: communityInfo.design?.image?.big || communityInfo.design?.image?.thumb,
item: items,
link: `https://locals.com/${community}/feed?mode=content${contentType ? `&content=${contentType}` : ""}`,
title: `Locals - ${communityInfo.title}`
};
}
//#endregion
export { route };