fix: update obsidian community routes

This commit is contained in:
DIYgod 2026-05-14 15:47:50 +08:00
parent 265921b7b8
commit 30776baef7
5 changed files with 237 additions and 35 deletions

122
lib/obsidian-routes.test.ts Normal file
View File

@ -0,0 +1,122 @@
import { http, HttpResponse } from 'msw';
import { describe, expect, it } from 'vitest';
import { route as pluginsRoute } from './routes/obsidian/plugins';
import { route as themesRoute } from './routes/obsidian/themes';
const searchConfigHtml = '<script>{"config":{"apiKey":"test-search-key","url":"https://community.obsidian.md/api/search"}}</script>';
describe('/obsidian', () => {
it('builds the plugins feed from the community search API sorted by creation time', async () => {
const { default: server } = await import('@/setup.test');
server.use(
http.get('https://community.obsidian.md/search', ({ request }) => {
const url = new URL(request.url);
expect(url.searchParams.get('type')).toBe('plugin');
expect(url.searchParams.get('sort')).toBe('created');
return HttpResponse.html(searchConfigHtml);
}),
http.get('https://community.obsidian.md/api/search/collections/entries/documents/search', ({ request }) => {
const url = new URL(request.url);
expect(request.headers.get('x-typesense-api-key')).toBe('test-search-key');
expect(url.searchParams.get('q')).toBe('*');
expect(url.searchParams.get('query_by')).toBe('name,authors,short_desc');
expect(url.searchParams.get('filter_by')).toBe('type:=plugin');
expect(url.searchParams.get('sort_by')).toBe('github_created_at:desc');
expect(url.searchParams.get('per_page')).toBe('54');
return HttpResponse.json({
hits: [
{
document: {
authors: ['slaymish'],
downloads: 2,
github_created_at: '2026-05-14T05:08:50Z',
id: '5757',
name: 'Synod',
short_desc: 'Run a council of LLM value agents over your journal.',
slug: 'synod',
tags: ['ai', 'import'],
type: 'plugin',
},
},
],
});
})
);
const feed = await pluginsRoute.handler();
expect(feed.title).toBe('Obsidian Plugins');
expect(feed.link).toBe('https://community.obsidian.md/search?type=plugin&sort=created');
expect(feed.item).toHaveLength(1);
expect(feed.item[0]).toMatchObject({
title: 'Synod',
description: 'Run a council of LLM value agents over your journal.',
link: 'https://community.obsidian.md/plugins/synod',
guid: 'plugin:5757',
author: 'slaymish',
category: ['ai', 'import'],
});
expect(feed.item[0].pubDate?.toISOString()).toBe('2026-05-14T05:08:50.000Z');
});
it('builds the themes feed from the community search API sorted by creation time', async () => {
const { default: server } = await import('@/setup.test');
server.use(
http.get('https://community.obsidian.md/search', ({ request }) => {
const url = new URL(request.url);
expect(url.searchParams.get('type')).toBe('theme');
expect(url.searchParams.get('sort')).toBe('created');
return HttpResponse.html(searchConfigHtml);
}),
http.get('https://community.obsidian.md/api/search/collections/entries/documents/search', ({ request }) => {
const url = new URL(request.url);
expect(request.headers.get('x-typesense-api-key')).toBe('test-search-key');
expect(url.searchParams.get('filter_by')).toBe('type:=theme');
expect(url.searchParams.get('sort_by')).toBe('github_created_at:desc');
return HttpResponse.json({
hits: [
{
document: {
authors: ['jshuntley'],
downloads: 0,
github_created_at: '2026-05-14T00:28:26Z',
id: '5749',
name: 'Fjord',
short_desc: 'Fjord colorscheme for Obsidian.',
slug: 'fjord',
tags: [],
type: 'theme',
},
},
],
});
})
);
const feed = await themesRoute.handler();
expect(feed.title).toBe('Obsidian Themes');
expect(feed.link).toBe('https://community.obsidian.md/search?type=theme&sort=created');
expect(feed.item).toHaveLength(1);
expect(feed.item[0]).toMatchObject({
title: 'Fjord',
description: 'Fjord colorscheme for Obsidian.',
link: 'https://community.obsidian.md/themes/fjord',
guid: 'theme:5749',
author: 'jshuntley',
category: [],
});
expect(feed.item[0].pubDate?.toISOString()).toBe('2026-05-14T00:28:26.000Z');
});
});

View File

@ -1,40 +1,16 @@
import type { Route } from '@/types';
import ofetch from '@/utils/ofetch';
import { buildCommunityFeed } from './utils';
export const route: Route = {
path: '/plugins',
name: 'Obsidian Plugins',
name: 'Plugins',
maintainers: ['DIYgod'],
categories: ['program-update'],
example: '/obsidian/plugins',
handler,
};
async function handler() {
const data = JSON.parse(await ofetch('https://raw.githubusercontent.com/obsidianmd/obsidian-releases/refs/heads/master/community-plugins.json')) as Array<{
id: string;
name: string;
author: string;
description: string;
repo: string;
}>;
const stats = JSON.parse(await ofetch('https://raw.githubusercontent.com/obsidianmd/obsidian-releases/HEAD/community-plugin-stats.json')) as {
[key: string]: {
downloads: number;
updated: number;
};
};
return {
title: 'Obsidian Plugins',
link: 'https://obsidian.md/plugins',
item: data.map((item) => ({
title: item.name,
description: `${item.description}<br><br>Downloads: ${stats[item.id].downloads}`,
link: `https://github.com/${item.repo}`,
guid: item.id,
pubDate: new Date(stats[item.id].updated),
author: item.author,
})),
};
function handler() {
return buildCommunityFeed('plugin');
}

View File

@ -0,0 +1,16 @@
import type { Route } from '@/types';
import { buildCommunityFeed } from './utils';
export const route: Route = {
path: '/themes',
name: 'Themes',
maintainers: ['DIYgod'],
categories: ['program-update'],
example: '/obsidian/themes',
handler,
};
function handler() {
return buildCommunityFeed('theme');
}

View File

@ -1,8 +1,96 @@
const regex = /([^/]+)\.md$/;
import type { Data, DataItem } from '@/types';
import ofetch from '@/utils/ofetch';
import { parseDate } from '@/utils/parse-date';
const getTitle = (path: string): string => {
const match = path.match(regex);
return match ? match[1] : '';
type CommunityEntryType = 'plugin' | 'theme';
type CommunitySearchConfig = {
apiKey: string;
url: string;
};
export { getTitle };
type CommunitySearchHit = {
document: {
authors?: string[];
github_created_at?: string;
github_updated_at?: string;
id: string;
latest_release_at?: string;
name: string;
short_desc?: string;
slug: string;
tags?: string[];
type: CommunityEntryType;
};
};
type CommunitySearchResponse = {
hits?: CommunitySearchHit[];
};
const hitsPerPage = 54;
const searchPageBaseUrl = 'https://community.obsidian.md/search';
const titleRegex = /([^/]+)\.md$/;
export async function buildCommunityFeed(type: CommunityEntryType): Promise<Data> {
const pageUrl = getSearchPageUrl(type);
const searchConfig = await getSearchConfig(pageUrl);
const data = await ofetch<CommunitySearchResponse>(`${searchConfig.url}/collections/entries/documents/search`, {
headers: {
'X-TYPESENSE-API-KEY': searchConfig.apiKey,
},
query: {
filter_by: `type:=${type}`,
per_page: hitsPerPage,
q: '*',
query_by: 'name,authors,short_desc',
sort_by: 'github_created_at:desc',
},
});
return {
title: `Obsidian ${type === 'plugin' ? 'Plugins' : 'Themes'}`,
link: pageUrl,
item: data.hits?.map(({ document }) => buildItem(document)) ?? [],
};
}
export function getTitle(path: string): string {
const match = path.match(titleRegex);
return match ? match[1] : '';
}
function getSearchPageUrl(type: CommunityEntryType) {
const url = new URL(searchPageBaseUrl);
url.searchParams.set('type', type);
url.searchParams.set('sort', 'created');
return url.toString();
}
async function getSearchConfig(pageUrl: string): Promise<CommunitySearchConfig> {
const html = await ofetch<string>(pageUrl);
const match = html.match(/apiKey\\":\\"([^"\\]+)\\"[\s\S]*?url\\":\\"([^"\\]+)\\"/) ?? html.match(/"apiKey":"([^"]+)"[\s\S]*?"url":"([^"]+)"/);
if (!match) {
throw new Error('Unable to locate Obsidian community search API config');
}
return {
apiKey: match[1],
url: match[2].replaceAll(String.raw`\/`, '/'),
};
}
function buildItem(document: CommunitySearchHit['document']): DataItem {
return {
title: document.name,
description: document.short_desc,
link: `https://community.obsidian.md/${document.type}s/${document.slug}`,
guid: `${document.type}:${document.id}`,
pubDate: document.github_created_at ? parseDate(document.github_created_at) : undefined,
updated: document.latest_release_at ? parseDate(document.latest_release_at) : document.github_updated_at ? parseDate(document.github_updated_at) : undefined,
author: document.authors?.join(', '),
category: document.tags,
};
}

View File

@ -2,7 +2,7 @@ import tsconfigPaths from 'vite-tsconfig-paths';
import { configDefaults, defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [tsconfigPaths()],
plugins: [tsconfigPaths({ root: '.' })],
test: {
watch: false,
coverage: {