diff --git a/lib/config.ts b/lib/config.ts index 235207e9e3..2b2796921c 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -255,6 +255,10 @@ export type Config = { misskey: { accessToken?: string; }; + mixi2: { + authToken?: string; + authKey?: string; + }; mox: { cookie: string; }; @@ -706,6 +710,10 @@ const calculateValue = () => { misskey: { accessToken: envs.MISSKEY_ACCESS_TOKEN, }, + mixi2: { + authToken: envs.MIXI2_AUTH_TOKEN, + authKey: envs.MIXI2_AUTH_KEY, + }, mox: { cookie: envs.MOX_COOKIE, }, diff --git a/lib/routes/mixi2/community.ts b/lib/routes/mixi2/community.ts new file mode 100644 index 0000000000..cd053668cd --- /dev/null +++ b/lib/routes/mixi2/community.ts @@ -0,0 +1,72 @@ +import { type Data, type Route, ViewType } from '@/types'; +import type { Context } from 'hono'; +import { CONFIG_OPTIONS, generatePostDataItem, getClient, postFilter } from './utils'; + +const handler = async (ctx: Context) => { + const limit = Number.parseInt(ctx.req.query('limit') ?? '20', 10); + const communityId = ctx.req.param('id'); + const mediaOnly = ctx.req.param('media') === 'media'; + + const client = getClient(); + + const [communityInfo, postsData] = await Promise.all([ + client.getCommunity({ + communityId, + }), + client.getCommunityTimeline({ + communityId, + limit, + mediaOnly, + }), + ]); + + const personasData = await client.getPersonas({ + personaIds: postsData?.posts.map((post) => post.personaId), + }); + + return { + title: `${communityInfo.community.name} - ${mediaOnly ? 'メディア' : 'ポスト'}`, + description: communityInfo.community.purpose.replaceAll('\n', ' '), + link: `https://mixi.social/communities/${communityId}/about`, + image: communityInfo.community.coverImage.postImage?.largeImageUrl, + item: + postsData?.posts?.filter(postFilter).map((post) => ({ + title: communityInfo.community.name, + ...generatePostDataItem(post, personasData.personas), + })) ?? [], + } as Data; +}; + +export const route: Route = { + path: '/community/:id/:media?', + name: 'コミュニティ', + categories: ['social-media'], + example: '/mixi2/community/62e7e813-d242-4c54-a0ee-0aab5b2bbad2', + parameters: { + id: { + description: 'コミュニティID', + }, + media: { + description: '`media`を入力するとメディアを含むポストのみを取得、デフォルトは空で全てのポストを取得', + }, + }, + features: { + supportRadar: true, + requireConfig: CONFIG_OPTIONS, + }, + radar: [ + { + source: ['mixi.social/communities/:id', 'mixi.social/communities/:id/about'], + target: '/community/:id', + title: 'コミュニティ - ポスト', + }, + { + source: ['mixi.social/communities/:id', 'mixi.social/communities/:id/about'], + target: '/community/:id/media', + title: 'コミュニティ - メディア', + }, + ], + view: ViewType.SocialMedia, + handler, + maintainers: ['KarasuShin'], +}; diff --git a/lib/routes/mixi2/discovery.ts b/lib/routes/mixi2/discovery.ts new file mode 100644 index 0000000000..8c2e9ede27 --- /dev/null +++ b/lib/routes/mixi2/discovery.ts @@ -0,0 +1,52 @@ +import { type Data, type Route, ViewType } from '@/types'; +import type { Context } from 'hono'; +import { CONFIG_OPTIONS, generatePostDataItem, getClient, postFilter } from './utils'; + +const handler = async (ctx: Context) => { + const limit = Number.parseInt(ctx.req.query('limit') ?? '20', 10); + + const client = getClient(); + + const data = await client.getRecommendedTimeline({ + limit, + }); + + const personasData = await client.getPersonas({ + personaIds: data?.posts?.map((post) => post.personaId) ?? [], + }); + + return { + title: '発見', + link: 'https://mixi.social/home/discovery', + image: 'https://mixi.social/_next/static/media/image_logo.8bb36f11.svg', + item: + data?.posts?.filter(postFilter).map((post) => { + const author = personasData.personas.find((persona) => persona.personaId === post.personaId); + return { + title: `@${author?.name}`, + ...generatePostDataItem(post, personasData.personas), + }; + }) ?? [], + } as Data; +}; + +export const route: Route = { + path: '/discovery', + name: '発見', + categories: ['social-media'], + example: '/mixi2/discovery', + features: { + supportRadar: true, + requireConfig: CONFIG_OPTIONS, + }, + radar: [ + { + source: ['mixi.social/home/discovery'], + target: '/discovery', + title: '発見', + }, + ], + view: ViewType.SocialMedia, + handler, + maintainers: ['KarasuShin'], +}; diff --git a/lib/routes/mixi2/home.ts b/lib/routes/mixi2/home.ts new file mode 100644 index 0000000000..ec2d31f04d --- /dev/null +++ b/lib/routes/mixi2/home.ts @@ -0,0 +1,51 @@ +import { type Data, type Route, ViewType } from '@/types'; +import type { Context } from 'hono'; +import { CONFIG_OPTIONS, generatePostDataItem, getClient, postFilter } from './utils'; + +const handler = async (ctx: Context) => { + const limit = Number.parseInt(ctx.req.query('limit') ?? '20', 10); + + const client = getClient(); + + const data = await client.getSubscribingFeeds({ + limit, + }); + + const personasData = await client.getPersonas({ + personaIds: data?.feeds?.map((feed) => feed.post.personaId) ?? [], + }); + + return { + title: 'フォロー中', + link: 'https://mixi.social/home', + image: 'https://mixi.social/_next/static/media/image_logo.8bb36f11.svg', + item: + data?.feeds + ?.filter((feed) => postFilter(feed.post)) + .map((feed) => ({ + title: `@${personasData.personas.find((persona) => persona.personaId === feed.post.personaId)?.name}`, + ...generatePostDataItem(feed.post, personasData.personas), + })) ?? [], + } as Data; +}; + +export const route: Route = { + path: '/home', + name: 'フォロー中', + categories: ['social-media'], + example: '/mixi2/home', + features: { + requireConfig: CONFIG_OPTIONS, + supportRadar: true, + }, + radar: [ + { + source: ['mixi.social/home'], + target: '/home', + title: 'フォロー中', + }, + ], + view: ViewType.SocialMedia, + handler, + maintainers: ['KarasuShin'], +}; diff --git a/lib/routes/mixi2/namespace.ts b/lib/routes/mixi2/namespace.ts new file mode 100644 index 0000000000..616aa8949c --- /dev/null +++ b/lib/routes/mixi2/namespace.ts @@ -0,0 +1,8 @@ +import type { Namespace } from '@/types'; + +export const namespace: Namespace = { + name: 'mixi2', + url: 'mixi.social', + lang: 'ja', + categories: ['social-media'], +}; diff --git a/lib/routes/mixi2/user.ts b/lib/routes/mixi2/user.ts new file mode 100644 index 0000000000..1594beb62e --- /dev/null +++ b/lib/routes/mixi2/user.ts @@ -0,0 +1,77 @@ +import { type Data, type Route, ViewType } from '@/types'; +import type { Context } from 'hono'; +import { CONFIG_OPTIONS, getClient, parsePost, postFilter } from './utils'; +import { parseDate } from '@/utils/parse-date'; +import InvalidParameterError from '@/errors/types/invalid-parameter'; + +const handler = async (ctx: Context) => { + const limit = Number.parseInt(ctx.req.query('limit') ?? '20', 10); + const name = ctx.req.param('name'); + const mediaOnly = ctx.req.param('media') === 'media'; + + if (!name.startsWith('@')) { + throw new InvalidParameterError('ユーザー名は@で始まる必要があります'); + } + + const client = getClient(); + + const userInfo = await client.getPersonaByName({ + name: name.slice(1), + }); + + const persona = userInfo.persona; + + const data = await client.getPersonalTimeline({ + personaId: persona?.personaId, + limit, + mediaOnly, + }); + + return { + title: `${persona?.name} - ${mediaOnly ? 'メディア' : 'ポスト'}`, + image: persona?.avatarUrl, + item: + data?.posts?.filter(postFilter).map((post) => ({ + title: `@${persona?.name}`, + description: parsePost(post), + pubDate: parseDate(post.createdAt.seconds * 1e3), + guid: post.postId, + author: persona?.name, + link: `https://mixi.social/@${persona?.name}/posts/${post.postId}`, + })) ?? [], + } as Data; +}; + +export const route: Route = { + path: '/user/:name/:media?', + name: 'ユーザー', + categories: ['social-media'], + example: '/mixi2/user/@deyo', + parameters: { + name: { + description: '@で始まるユーザー名', + }, + media: { + description: '`media`を入力するとメディアを含むポストのみを取得、デフォルトは空で全てのポストを取得', + }, + }, + features: { + requireConfig: CONFIG_OPTIONS, + supportRadar: true, + }, + radar: [ + { + source: ['mixi.social/:id'], + target: '/user/:id', + title: 'ユーザー - ポスト', + }, + { + source: ['mixi.social/:id'], + target: '/user/:id/media', + title: 'ユーザー - メディア', + }, + ], + view: ViewType.SocialMedia, + handler, + maintainers: ['KarasuShin'], +}; diff --git a/lib/routes/mixi2/utils.ts b/lib/routes/mixi2/utils.ts new file mode 100644 index 0000000000..fa3a01fae2 --- /dev/null +++ b/lib/routes/mixi2/utils.ts @@ -0,0 +1,56 @@ +import { Category, MixiClient, Persona, type Post } from 'mixi2'; +import { config } from '@/config'; +import ConfigNotFoundError from '@/errors/types/config-not-found'; +import ofetch from '@/utils/ofetch'; +import { parseDate } from '@/utils/parse-date'; + +export function getClient() { + const { authToken, authKey } = config.mixi2; + + if (!authToken || !authKey) { + throw new ConfigNotFoundError('MIXI2_AUTH_TOKEN and MIXI2_AUTH_KEY are required'); + } + + return new MixiClient(`auth_token=${authToken}`, authKey, { + httpAdapter: ofetch, + }); +} + +export function parsePost(post: Post) { + let description = post.text ? `
${post.text}
` : ''; + + for (const media of post.medias ?? []) { + if (media.category === Category.CATEGORY_POST_IMAGE) { + description += `