From 2b16676200f7245f8a31d44608e1a8ebdfdc522f Mon Sep 17 00:00:00 2001 From: karasu Date: Sun, 15 Jun 2025 20:52:50 +0800 Subject: [PATCH] feat(route): mixi2 (#19359) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(route): mixi2 * feat(route/mixi2): 添加参数描述 * feat(route/mixi2): 添加 feed 标题 * feat(route/mixi2): discovery 订阅路由添加图片 * feat(route/mixi2): 添加view属性 * fix(route/mixi2): 修复类型错误 * Update lib/routes/mixi2/utils.ts Co-authored-by: Tony * Update lib/routes/mixi2/community.ts Co-authored-by: Tony * Update lib/routes/mixi2/home.ts Co-authored-by: Tony * Update lib/routes/mixi2/discovery.ts Co-authored-by: Tony * add requireConfig * update * add post filter * fix the filter --- lib/config.ts | 8 ++++ lib/routes/mixi2/community.ts | 72 ++++++++++++++++++++++++++++++++ lib/routes/mixi2/discovery.ts | 52 +++++++++++++++++++++++ lib/routes/mixi2/home.ts | 51 +++++++++++++++++++++++ lib/routes/mixi2/namespace.ts | 8 ++++ lib/routes/mixi2/user.ts | 77 +++++++++++++++++++++++++++++++++++ lib/routes/mixi2/utils.ts | 56 +++++++++++++++++++++++++ package.json | 1 + pnpm-lock.yaml | 10 +++++ 9 files changed, 335 insertions(+) create mode 100644 lib/routes/mixi2/community.ts create mode 100644 lib/routes/mixi2/discovery.ts create mode 100644 lib/routes/mixi2/home.ts create mode 100644 lib/routes/mixi2/namespace.ts create mode 100644 lib/routes/mixi2/user.ts create mode 100644 lib/routes/mixi2/utils.ts 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 += ``; + } else if (media.category === Category.CATEGORY_POST_VIDEO) { + description += `${media.description}`; + } + } + return description; +} + +export function generatePostDataItem(post: Post, personas: Persona[]) { + const author = personas.find((persona) => persona.personaId === post.personaId); + return { + description: parsePost(post), + pubDate: parseDate(post.createdAt.seconds * 1e3), + guid: post.postId, + author: author?.name, + link: `https://mixi.social/@${author?.name}/posts/${post.postId}`, + }; +} + +export const CONFIG_OPTIONS = [ + { + name: 'MIXI2_AUTH_TOKEN', + description: 'mixi2ログイン後の情報。ブラウザのコンソールでクッキーから `auth_token` の値を取得してください', + }, + { + name: 'MIXI2_AUTH_KEY', + description: 'mixi2ログイン後の情報。ブラウザのコンソールでリクエストヘッダーから `x-auth-key` の値を取得してください', + }, +]; + +export function postFilter(post: Post) { + return !post.isDeleted && post.personaId; +} diff --git a/package.json b/package.json index 3350c1a281..c322362a22 100644 --- a/package.json +++ b/package.json @@ -101,6 +101,7 @@ "lz-string": "1.5.0", "mailparser": "3.7.3", "markdown-it": "14.1.0", + "mixi2": "0.2.2", "module-alias": "2.2.3", "narou": "1.2.0", "notion-to-md": "3.1.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2545dadaae..17086e1abc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -191,6 +191,9 @@ importers: markdown-it: specifier: 14.1.0 version: 14.1.0 + mixi2: + specifier: 0.2.2 + version: 0.2.2 module-alias: specifier: 2.2.3 version: 2.2.3 @@ -4688,6 +4691,9 @@ packages: mitt@3.0.1: resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + mixi2@0.2.2: + resolution: {integrity: sha512-8rgPNsuKIMzLctZ8oeo9idLTahvJCamljcdVth1Tx/Gqp7d3vAWzKYUsBNCr+wqD5zf/0rrYJPZCo0WZqadcbQ==} + mixin-object@2.0.1: resolution: {integrity: sha512-ALGF1Jt9ouehcaXaHhn6t1yGWRqGaHkPFndtFVHfZXOvkIZ/yoGaSi0AHVTafb3ZBGg4dr/bDwnaEKqCXzchMA==} engines: {node: '>=0.10.0'} @@ -10979,6 +10985,10 @@ snapshots: mitt@3.0.1: {} + mixi2@0.2.2: + dependencies: + protobufjs: 7.5.3 + mixin-object@2.0.1: dependencies: for-in: 0.1.8