mirror of
https://github.com/svemagie/indiekit-endpoint-blogroll.git
synced 2026-04-02 15:34:59 +02:00
feat: add FeedLand source type for blogroll
Adds FeedLand (feedland.com or self-hosted) as a new source type alongside OPML and Microsub. Syncs subscriptions via FeedLand's public OPML endpoint with optional category filtering and AJAX category discovery in the admin UI.
This commit is contained in:
@@ -16,6 +16,10 @@ import {
|
||||
getMicrosubChannels,
|
||||
isMicrosubAvailable,
|
||||
} from "../sync/microsub.js";
|
||||
import {
|
||||
syncFeedlandSource,
|
||||
fetchFeedlandCategories,
|
||||
} from "../sync/feedland.js";
|
||||
|
||||
/**
|
||||
* List sources
|
||||
@@ -95,6 +99,9 @@ async function create(request, response) {
|
||||
enabled,
|
||||
channelFilter,
|
||||
categoryPrefix,
|
||||
feedlandInstance,
|
||||
feedlandUsername,
|
||||
feedlandCategory,
|
||||
} = request.body;
|
||||
|
||||
try {
|
||||
@@ -120,6 +127,13 @@ async function create(request, response) {
|
||||
return response.redirect(`${request.baseUrl}/sources/new`);
|
||||
}
|
||||
|
||||
if (type === "feedland" && (!feedlandInstance || !feedlandUsername)) {
|
||||
request.session.messages = [
|
||||
{ type: "error", content: request.__("blogroll.sources.form.feedlandRequired") },
|
||||
];
|
||||
return response.redirect(`${request.baseUrl}/sources/new`);
|
||||
}
|
||||
|
||||
const sourceData = {
|
||||
name,
|
||||
type,
|
||||
@@ -135,12 +149,21 @@ async function create(request, response) {
|
||||
sourceData.categoryPrefix = categoryPrefix || "";
|
||||
}
|
||||
|
||||
// Add feedland-specific fields
|
||||
if (type === "feedland") {
|
||||
sourceData.feedlandInstance = feedlandInstance.replace(/\/+$/, "");
|
||||
sourceData.feedlandUsername = feedlandUsername;
|
||||
sourceData.feedlandCategory = feedlandCategory || null;
|
||||
}
|
||||
|
||||
const source = await createSource(application, sourceData);
|
||||
|
||||
// Trigger initial sync based on source type
|
||||
try {
|
||||
if (type === "microsub") {
|
||||
await syncMicrosubSource(application, source);
|
||||
} else if (type === "feedland") {
|
||||
await syncFeedlandSource(application, source);
|
||||
} else {
|
||||
await syncOpmlSource(application, source);
|
||||
}
|
||||
@@ -223,6 +246,9 @@ async function update(request, response) {
|
||||
enabled,
|
||||
channelFilter,
|
||||
categoryPrefix,
|
||||
feedlandInstance,
|
||||
feedlandUsername,
|
||||
feedlandCategory,
|
||||
} = request.body;
|
||||
|
||||
try {
|
||||
@@ -247,6 +273,15 @@ async function update(request, response) {
|
||||
updateData.categoryPrefix = categoryPrefix || "";
|
||||
}
|
||||
|
||||
// Add feedland-specific fields
|
||||
if (type === "feedland") {
|
||||
updateData.feedlandInstance = feedlandInstance
|
||||
? feedlandInstance.replace(/\/+$/, "")
|
||||
: null;
|
||||
updateData.feedlandUsername = feedlandUsername || null;
|
||||
updateData.feedlandCategory = feedlandCategory || null;
|
||||
}
|
||||
|
||||
await updateSource(application, id, updateData);
|
||||
|
||||
request.session.messages = [
|
||||
@@ -313,6 +348,8 @@ async function sync(request, response) {
|
||||
let result;
|
||||
if (source.type === "microsub") {
|
||||
result = await syncMicrosubSource(application, source);
|
||||
} else if (source.type === "feedland") {
|
||||
result = await syncFeedlandSource(application, source);
|
||||
} else {
|
||||
result = await syncOpmlSource(application, source);
|
||||
}
|
||||
@@ -358,6 +395,26 @@ function consumeFlashMessage(request) {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch FeedLand categories (AJAX endpoint)
|
||||
* GET /api/feedland-categories?instance=...&username=...
|
||||
*/
|
||||
async function feedlandCategories(request, response) {
|
||||
const { instance, username } = request.query;
|
||||
|
||||
if (!instance || !username) {
|
||||
return response.status(400).json({ error: "instance and username are required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await fetchFeedlandCategories(instance, username);
|
||||
response.json(data);
|
||||
} catch (error) {
|
||||
console.error("[Blogroll] FeedLand categories fetch error:", error.message);
|
||||
response.status(502).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export const sourcesController = {
|
||||
list,
|
||||
newForm,
|
||||
@@ -366,4 +423,5 @@ export const sourcesController = {
|
||||
update,
|
||||
remove,
|
||||
sync,
|
||||
feedlandCategories,
|
||||
};
|
||||
|
||||
@@ -48,13 +48,17 @@ export async function createSource(application, data) {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const source = {
|
||||
type: data.type, // "opml_url" | "opml_file" | "manual" | "json_feed" | "microsub"
|
||||
type: data.type, // "opml_url" | "opml_file" | "manual" | "json_feed" | "microsub" | "feedland"
|
||||
name: data.name,
|
||||
url: data.url || null,
|
||||
opmlContent: data.opmlContent || null,
|
||||
// Microsub-specific fields
|
||||
channelFilter: data.channelFilter || null,
|
||||
categoryPrefix: data.categoryPrefix || "",
|
||||
// FeedLand-specific fields
|
||||
feedlandInstance: data.feedlandInstance || null,
|
||||
feedlandUsername: data.feedlandUsername || null,
|
||||
feedlandCategory: data.feedlandCategory || null,
|
||||
enabled: data.enabled !== false,
|
||||
syncInterval: data.syncInterval || 60, // minutes
|
||||
lastSyncAt: null,
|
||||
@@ -160,7 +164,7 @@ export async function getSourcesDueForSync(application) {
|
||||
return collection
|
||||
.find({
|
||||
enabled: true,
|
||||
type: { $in: ["opml_url", "json_feed", "microsub"] },
|
||||
type: { $in: ["opml_url", "json_feed", "microsub", "feedland"] },
|
||||
$or: [
|
||||
{ lastSyncAt: null },
|
||||
{
|
||||
|
||||
139
lib/sync/feedland.js
Normal file
139
lib/sync/feedland.js
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* FeedLand synchronization
|
||||
* @module sync/feedland
|
||||
*/
|
||||
|
||||
import { fetchAndParseOpml } from "./opml.js";
|
||||
import { upsertBlog } from "../storage/blogs.js";
|
||||
import { updateSourceSyncStatus } from "../storage/sources.js";
|
||||
|
||||
/**
|
||||
* Fetch user categories from a FeedLand instance
|
||||
* @param {string} instanceUrl - FeedLand instance URL (e.g., https://feedland.com)
|
||||
* @param {string} username - FeedLand username
|
||||
* @param {number} timeout - Fetch timeout in ms
|
||||
* @returns {Promise<object>} Category data { categories: string[], homePageCategories: string[] }
|
||||
*/
|
||||
export async function fetchFeedlandCategories(instanceUrl, username, timeout = 10000) {
|
||||
const baseUrl = instanceUrl.replace(/\/+$/, "");
|
||||
const url = `${baseUrl}/getusercategories?screenname=${encodeURIComponent(username)}`;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
"User-Agent": "Indiekit-Blogroll/1.0",
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// FeedLand returns comma-separated strings
|
||||
const categories = data.categories
|
||||
? data.categories.split(",").map((c) => c.trim()).filter(Boolean)
|
||||
: [];
|
||||
const homePageCategories = data.homePageCategories
|
||||
? data.homePageCategories.split(",").map((c) => c.trim()).filter(Boolean)
|
||||
: [];
|
||||
|
||||
return { categories, homePageCategories, screenname: data.screenname };
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
if (error.name === "AbortError") {
|
||||
throw new Error("Request timed out");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the OPML URL for a FeedLand source
|
||||
* @param {object} source - Source document with feedland fields
|
||||
* @returns {string} OPML URL
|
||||
*/
|
||||
export function buildFeedlandOpmlUrl(source) {
|
||||
const baseUrl = source.feedlandInstance.replace(/\/+$/, "");
|
||||
let url = `${baseUrl}/opml?screenname=${encodeURIComponent(source.feedlandUsername)}`;
|
||||
|
||||
if (source.feedlandCategory) {
|
||||
url += `&catname=${encodeURIComponent(source.feedlandCategory)}`;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the FeedLand river URL for linking back
|
||||
* @param {object} source - Source document with feedland fields
|
||||
* @returns {string} River URL
|
||||
*/
|
||||
export function buildFeedlandRiverUrl(source) {
|
||||
const baseUrl = source.feedlandInstance.replace(/\/+$/, "");
|
||||
return `${baseUrl}/?river=true&screenname=${encodeURIComponent(source.feedlandUsername)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync blogs from a FeedLand source
|
||||
* @param {object} application - Application instance
|
||||
* @param {object} source - Source document
|
||||
* @returns {Promise<object>} Sync result
|
||||
*/
|
||||
export async function syncFeedlandSource(application, source) {
|
||||
try {
|
||||
const opmlUrl = buildFeedlandOpmlUrl(source);
|
||||
const blogs = await fetchAndParseOpml(opmlUrl);
|
||||
|
||||
let added = 0;
|
||||
let updated = 0;
|
||||
|
||||
for (const blog of blogs) {
|
||||
// FeedLand OPML includes a category attribute with comma-separated categories.
|
||||
// Use the first category, or fall back to the source's feedlandCategory filter,
|
||||
// or use the FeedLand username as a category grouping.
|
||||
const category = blog.category
|
||||
|| source.feedlandCategory
|
||||
|| source.feedlandUsername
|
||||
|| "";
|
||||
|
||||
const result = await upsertBlog(application, {
|
||||
...blog,
|
||||
category,
|
||||
sourceId: source._id,
|
||||
});
|
||||
|
||||
if (result.upserted) added++;
|
||||
else if (result.modified) updated++;
|
||||
}
|
||||
|
||||
// Update source sync status
|
||||
await updateSourceSyncStatus(application, source._id, { success: true });
|
||||
|
||||
console.log(
|
||||
`[Blogroll] Synced FeedLand source "${source.name}" (${source.feedlandUsername}@${source.feedlandInstance}): ${added} added, ${updated} updated, ${blogs.length} total`
|
||||
);
|
||||
|
||||
return { success: true, added, updated, total: blogs.length };
|
||||
} catch (error) {
|
||||
// Update source with error status
|
||||
await updateSourceSyncStatus(application, source._id, {
|
||||
success: false,
|
||||
error: error.message,
|
||||
});
|
||||
|
||||
console.error(
|
||||
`[Blogroll] FeedLand sync failed for "${source.name}":`,
|
||||
error.message
|
||||
);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { getBlogs, countBlogs } from "../storage/blogs.js";
|
||||
import { countItems, deleteOldItems } from "../storage/items.js";
|
||||
import { syncOpmlSource } from "./opml.js";
|
||||
import { syncMicrosubSource } from "./microsub.js";
|
||||
import { syncFeedlandSource } from "./feedland.js";
|
||||
import { syncBlogItems } from "./feed.js";
|
||||
|
||||
let syncInterval = null;
|
||||
@@ -42,7 +43,7 @@ export async function runFullSync(application, options = {}) {
|
||||
// Sync all enabled sources (OPML, JSON, Microsub)
|
||||
const sources = await getSources(application);
|
||||
const enabledSources = sources.filter(
|
||||
(s) => s.enabled && ["opml_url", "opml_file", "json_feed", "microsub"].includes(s.type)
|
||||
(s) => s.enabled && ["opml_url", "opml_file", "json_feed", "microsub", "feedland"].includes(s.type)
|
||||
);
|
||||
|
||||
let sourcesSuccess = 0;
|
||||
@@ -53,6 +54,8 @@ export async function runFullSync(application, options = {}) {
|
||||
let result;
|
||||
if (source.type === "microsub") {
|
||||
result = await syncMicrosubSource(application, source);
|
||||
} else if (source.type === "feedland") {
|
||||
result = await syncFeedlandSource(application, source);
|
||||
} else {
|
||||
result = await syncOpmlSource(application, source);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user