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:
Ricardo
2026-02-17 13:54:19 +01:00
parent 9a8cb669d1
commit 129dc78e09
23 changed files with 463 additions and 23 deletions

View File

@@ -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,
};

View File

@@ -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
View 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 };
}
}

View File

@@ -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);
}