feat(route): mixi2 (#19359)

* 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 <TonyRL@users.noreply.github.com>

* Update lib/routes/mixi2/community.ts

Co-authored-by: Tony <TonyRL@users.noreply.github.com>

* Update lib/routes/mixi2/home.ts

Co-authored-by: Tony <TonyRL@users.noreply.github.com>

* Update lib/routes/mixi2/discovery.ts

Co-authored-by: Tony <TonyRL@users.noreply.github.com>

* add requireConfig

* update

* add post filter

* fix the filter
This commit is contained in:
karasu
2025-06-15 20:52:50 +08:00
committed by GitHub
co-authored by Tony
parent 499e57adc0
commit 2b16676200
9 changed files with 335 additions and 0 deletions
+8
View File
@@ -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,
},
+72
View File
@@ -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'],
};
+52
View File
@@ -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'],
};
+51
View File
@@ -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'],
};
+8
View File
@@ -0,0 +1,8 @@
import type { Namespace } from '@/types';
export const namespace: Namespace = {
name: 'mixi2',
url: 'mixi.social',
lang: 'ja',
categories: ['social-media'],
};
+77
View File
@@ -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'],
};
+56
View File
@@ -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 ? `<p>${post.text}</p>` : '';
for (const media of post.medias ?? []) {
if (media.category === Category.CATEGORY_POST_IMAGE) {
description += `<img src="${media.postImage?.largeImageUrl ?? media.postImage?.smallImageUrl}"${media.description ? `alt="${media.description}"` : ''} />`;
} else if (media.category === Category.CATEGORY_POST_VIDEO) {
description += `<img src="${media.postVideo?.previewImageUrl}" alt="${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;
}
+1
View File
@@ -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",
+10
View File
@@ -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