From e20f27e4917a124bf751362423048c785db2d8e4 Mon Sep 17 00:00:00 2001 From: karasu Date: Tue, 9 Sep 2025 06:40:09 +0800 Subject: [PATCH] feat(route): add sdo/ff14risingstones (#19914) * feat(route): sdo/ff14risingstones * fix: add a check for `from_info` * feat: add category * fix: raw output * feat: add author * fix: remove the duplicate `[RP]` * docs: add information for the `ua` configuration * feat: add fc create_time * feat: add fc create_time * fix: add fc detail_mask * fix error message * Update utils.ts * Update lib/routes/sdo/ff14risingstones/strats.ts Co-authored-by: Tony * Update lib/routes/sdo/ff14risingstones/posts.ts --- lib/config.ts | 8 + lib/routes/sdo/ff14risingstones/api.ts | 78 ++++ lib/routes/sdo/ff14risingstones/constant.ts | 338 ++++++++++++++++++ lib/routes/sdo/ff14risingstones/posts.ts | 80 +++++ lib/routes/sdo/ff14risingstones/strats.ts | 75 ++++ .../templates/duties-party.art | 41 +++ .../ff14risingstones/templates/fc-party.art | 26 ++ .../templates/novice-network-party.art | 9 + .../ff14risingstones/templates/rp-party.art | 15 + lib/routes/sdo/ff14risingstones/timeline.ts | 31 ++ .../sdo/ff14risingstones/types/dynamic.ts | 50 +++ .../sdo/ff14risingstones/types/index.ts | 3 + .../sdo/ff14risingstones/types/other.ts | 57 +++ .../sdo/ff14risingstones/types/party.ts | 111 ++++++ .../sdo/ff14risingstones/user-dynamics.ts | 32 ++ lib/routes/sdo/ff14risingstones/user-posts.ts | 32 ++ .../sdo/ff14risingstones/user-resently.ts | 38 ++ .../sdo/ff14risingstones/user-strats.ts | 32 ++ lib/routes/sdo/ff14risingstones/utils.ts | 215 +++++++++++ lib/routes/sdo/namespace.ts | 7 + 20 files changed, 1278 insertions(+) create mode 100644 lib/routes/sdo/ff14risingstones/api.ts create mode 100644 lib/routes/sdo/ff14risingstones/constant.ts create mode 100644 lib/routes/sdo/ff14risingstones/posts.ts create mode 100644 lib/routes/sdo/ff14risingstones/strats.ts create mode 100644 lib/routes/sdo/ff14risingstones/templates/duties-party.art create mode 100644 lib/routes/sdo/ff14risingstones/templates/fc-party.art create mode 100644 lib/routes/sdo/ff14risingstones/templates/novice-network-party.art create mode 100644 lib/routes/sdo/ff14risingstones/templates/rp-party.art create mode 100644 lib/routes/sdo/ff14risingstones/timeline.ts create mode 100644 lib/routes/sdo/ff14risingstones/types/dynamic.ts create mode 100644 lib/routes/sdo/ff14risingstones/types/index.ts create mode 100644 lib/routes/sdo/ff14risingstones/types/other.ts create mode 100644 lib/routes/sdo/ff14risingstones/types/party.ts create mode 100644 lib/routes/sdo/ff14risingstones/user-dynamics.ts create mode 100644 lib/routes/sdo/ff14risingstones/user-posts.ts create mode 100644 lib/routes/sdo/ff14risingstones/user-resently.ts create mode 100644 lib/routes/sdo/ff14risingstones/user-strats.ts create mode 100644 lib/routes/sdo/ff14risingstones/utils.ts create mode 100644 lib/routes/sdo/namespace.ts diff --git a/lib/config.ts b/lib/config.ts index f9f2501fb..cac318f6c 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -325,6 +325,10 @@ export type Config = { scihub: { host?: string; }; + sdo: { + ff14risingstones?: string; + ua?: string; + }; sis001: { baseUrl?: string; }; @@ -797,6 +801,10 @@ const calculateValue = () => { scihub: { host: envs.SCIHUB_HOST || 'https://sci-hub.se/', }, + sdo: { + ff14risingstones: envs.SDO_FF14RISINGSTONES, + ua: envs.SDO_UA, + }, sis001: { baseUrl: envs.SIS001_BASE_URL || 'https://sis001.com', }, diff --git a/lib/routes/sdo/ff14risingstones/api.ts b/lib/routes/sdo/ff14risingstones/api.ts new file mode 100644 index 000000000..e91e4bad0 --- /dev/null +++ b/lib/routes/sdo/ff14risingstones/api.ts @@ -0,0 +1,78 @@ +import cache from '@/utils/cache'; +import { API_URL } from './constant'; +import type { DutiesPartyDetail, FreeCompanyPartyDetail, NoviceNetworkParty, PostDetail, Resently, UserDynamic, UserInfo, UserPost } from './types'; +import { requestAPI } from './utils'; + +// 获取用户游戏近况 +export const getResently = async (uid: string) => await requestAPI(`${API_URL}/home/userInfo/getResently?uuid=${uid}`); + +// 获取用户信息 +export const getUserInfo = async (uid: string) => await requestAPI(`${API_URL}/home/userInfo/getUserInfo?uuid=${uid}`); + +// 获取用户帖子 +export const getUserPosts = async (uid: string, type: 1 | 2) => + await requestAPI<{ + rows: UserPost[]; + }>(`${API_URL}/home/userInfo/getUserPosts?uuid=${uid}&type=${type}`).then((res) => res.rows); + +// 获取用户动态 +export const getUserDynamic = async (uid: string) => + await requestAPI<{ + rows: UserDynamic[]; + }>(`${API_URL}/home/userInfo/getUserDynamic?uuid=${uid}`).then((res) => res.rows); + +// 获取帖子详情 +export async function getPostsDetail(postID: string) { + try { + return await cache.tryGet(`sdo/ff14risingstones/post-detail:${postID}`, async () => await requestAPI(`${API_URL}/home/posts/postsDetail?id=${postID}`)); + } catch { + return null; + } +} + +// 获取导芽招募详情 +export async function getNoviceNetworkRecruitDetail(recruitID: number) { + try { + return await cache.tryGet(`sdo/ff14risingstones/novice-network-recruit-detail:${recruitID}`, async () => await requestAPI(`${API_URL}/home/recruit/getNeDetail?id=${recruitID}`)); + } catch { + return null; + } +} + +// 获取副本招募详情 +export async function getDutiesRecruitDetail(recruitID: number) { + try { + return await cache.tryGet(`sdo/ff14risingstones/duties-recruit-detail:${recruitID}`, async () => await requestAPI(`${API_URL}/home/recruit/getRecruitFbDetail?id=${recruitID}`)); + } catch { + return null; + } +} + +// 获取部队招募详情 +export async function getFreeCompanyRecruitDetail(recruitID: number) { + try { + return await cache.tryGet(`sdo/ff14risingstones/free-company-recruit-detail:${recruitID}`, async () => await requestAPI(`${API_URL}/home/recruit/getRecruitGuildDetail?id=${recruitID}`)); + } catch { + return null; + } +} + +// 获取帖文列表 +export async function getPosts(params: { type: 1 | 2; is_top?: 0 | 1; is_refine?: 0 | 1; limit?: number | string; order?: string; hotType?: string; part_id?: string }) { + const searchParams = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value !== undefined) { + searchParams.set(key, value.toString()); + } + } + + return await requestAPI<{ + rows: UserPost[]; + }>(`${API_URL}/home/posts/postsList?${searchParams.toString()}`).then((res) => res.rows); +} + +// 获取用户关注动态 +export const getFollowDynamicList = async (limit: number | string) => + await requestAPI<{ + rows: UserDynamic[]; + }>(`${API_URL}/home/dynamic/getFollowDynamicList?limit=${limit}`).then((res) => res.rows); diff --git a/lib/routes/sdo/ff14risingstones/constant.ts b/lib/routes/sdo/ff14risingstones/constant.ts new file mode 100644 index 000000000..ccbb4f422 --- /dev/null +++ b/lib/routes/sdo/ff14risingstones/constant.ts @@ -0,0 +1,338 @@ +import type { Route } from '@/types'; + +export const REQUIRE_CONFIG: NonNullable['requireConfig'] = [ + { + name: 'SDO_FF14RISINGSTONES', + description: '值为 Cookie 头中 ff14risingstones 值', + }, + { + // https://github.com/StarHeartHunt/ff14risingstone_sign_task/issues/17 + name: 'SDO_UA', + description: '值为与在网页端获取 Cookie 时相匹配的 User-Agent 值', + }, +]; + +export const API_URL = 'https://apiff14risingstones.web.sdo.com/api'; + +export const ORIGIN = 'https://ff14risingstones.web.sdo.com'; +export const INDEX_URL = `${ORIGIN}/pc/index.html`; + +export const LOGO_URL = `${ORIGIN}/pc/favicon.ico`; + +// source: /api/home/recruit/styleConfigList +export const PLAY_STYLE = { + '2': '采矿/园艺', + '3': '成就收集', + '4': '大型任务', + '5': '钓鱼', + '6': '多玛方城战', + '7': '房屋', + '8': '怪物狩猎', + '9': '集体动作', + '10': '角色扮演', + '11': '金碟游乐场', + '18': '练级', + '19': '休闲玩家', + '20': '硬核玩家', + '21': '主线', + '22': '赚钱', + '23': '武具投影', + '26': '乐器演奏', + '28': '聊天', + '29': '玩家自办活动', + '30': '迷宫挑战', + '31': '讨伐歼灭战', + '32': '玩家对战', + '33': '随机任务', + '34': '寻宝', + '35': '特殊迷宫探索', + '36': '特殊地图探索', + '37': '青魔法师', + '38': '开拓无人岛', + '39': '制作', + '41': '绝境战', + '42': '深层迷宫挑战', +}; + +// source: /api/home/recruit/getJobConfigList +export const JOB = { + '1': '防护职业', + '3': '进攻职业', + '5': '治疗职业', + '6': '骑士', + '7': '战士', + '8': '暗黑骑士', + '9': '绝枪战士', + '10': '白魔法师', + '11': '占星术士', + '12': '学者', + '13': '贤者', + '14': '武僧', + '15': '龙骑士', + '16': '忍者', + '17': '武士', + '18': '钐镰客', + '19': '吟游诗人', + '20': '机工士', + '21': '舞者', + '22': '黑魔法师', + '23': '召唤师', + '24': '赤魔法师', + '25': '青魔法师', + '28': '近战职业', + '29': '远程物理', + '30': '远程魔法', + '32': '任意职业', + '33': '蝰蛇剑士', + '34': '绘灵法师', +}; + +// source: /api/home/posts/partList type = 1 +export const POST_PART = [ + { + value: '34', + label: '冒险者行会', + }, + { + value: '52', + label: '生活杂谈', + }, + { + value: '38', + label: '同人创作', + }, + { + value: '36', + label: '剧情讨论', + }, + { + value: '51', + label: '建议和BUG反馈', + }, + { + value: '37', + label: '游戏记录', + }, + { + value: '35', + label: '举手提问', + }, + { + value: '74', + label: '版务专区', + }, + { + value: '75', + label: '官方讯息', + }, +]; + +// source: /api/home/posts/partList type = 2 +export const STRAT_PART = [ + { + label: '新手指引', + value: '1', + }, + { + label: '副本攻略', + value: '2', + }, + { + label: '战斗职业', + value: '3', + }, + { + label: 'PVP', + value: '4', + }, + { + label: '生产采集', + value: '5', + }, + { + label: '投影外观', + value: '6', + }, + { + label: '房屋装修', + value: '7', + }, + { + label: '骑士', + value: '8', + }, + { + label: '武僧', + value: '9', + }, + { + label: '战士', + value: '10', + }, + { + label: '龙骑士', + value: '11', + }, + { + label: '吟游诗人', + value: '12', + }, + { + label: '白魔法师', + value: '13', + }, + { + label: '黑魔法师', + value: '14', + }, + { + label: '召唤师', + value: '15', + }, + { + label: '学者', + value: '16', + }, + { + label: '忍者', + value: '17', + }, + { + label: '机工士', + value: '18', + }, + { + label: '暗黑骑士', + value: '19', + }, + { + label: '占星术士', + value: '20', + }, + { + label: '武士', + value: '21', + }, + { + label: '赤魔法师', + value: '22', + }, + { + label: '青魔法师', + value: '23', + }, + { + label: '绝枪战士', + value: '24', + }, + { + label: '舞者', + value: '25', + }, + { + label: '钐镰客', + value: '26', + }, + { + label: '贤者', + value: '27', + }, + { + label: '猫魅族', + value: '28', + }, + { + label: '拉拉菲尔族', + value: '29', + }, + { + label: '人族', + value: '30', + }, + { + label: '精灵族', + value: '31', + }, + { + label: '维埃拉族', + value: '32', + }, + { + label: '敖龙族', + value: '59', + }, + { + label: '硌狮族', + value: '60', + }, + { + label: '鲁加族', + value: '61', + }, + { + label: '无人岛', + value: '62', + }, + { + label: '特殊场景探索', + value: '63', + }, + { + label: '游戏资讯', + value: '64', + }, + { + label: '内容考据', + value: '65', + }, + { + label: '摄影截图', + value: '66', + }, + { + label: '金碟游乐场', + value: '67', + }, + { + label: '综合', + value: '68', + }, + { + label: '其他', + value: '69', + }, + { + label: '国际服资讯翻译', + value: '70', + }, + { + label: '游戏资讯整理', + value: '71', + }, + { + label: '其他', + value: '72', + }, +]; + +export const POST_TYPE = { + top: '置顶', + refine: '精华', + hot: '周热门', +}; + +export enum DynamicSource { + General = 1, + Post = 2, + Strat = 3, + NoviceNetwork = 5, + Duty = 6, + FreeCompany = 7, + RolePlay = 8, + Other = 9, +} + +export enum NoviceNetworkIdentity { + Mentor = 1, + Sprout = 2, +} diff --git a/lib/routes/sdo/ff14risingstones/posts.ts b/lib/routes/sdo/ff14risingstones/posts.ts new file mode 100644 index 000000000..dd142d535 --- /dev/null +++ b/lib/routes/sdo/ff14risingstones/posts.ts @@ -0,0 +1,80 @@ +import type { Route } from '@/types'; +import { INDEX_URL, LOGO_URL, POST_PART, POST_TYPE, REQUIRE_CONFIG } from './constant'; +import type { Context } from 'hono'; +import { checkConfig, generatePostFeeds } from './utils'; +import { getPosts } from './api'; + +export const route: Route = { + path: '/ff14risingstones/posts/:pid?/:type?', + example: '/sdo/ff14risingstones/posts/all/hot', + name: '帖子', + categories: ['bbs'], + maintainers: ['KarasuShin'], + features: { + requireConfig: REQUIRE_CONFIG, + }, + parameters: { + pid: { + description: '分区id,默认显示所有分区,可通过 `,` 拼接多个分区 id 进行筛选', + default: 'all', + options: [ + { + label: '全部', + value: 'all', + }, + ...POST_PART, + ], + }, + type: { + description: '帖文类型,默认不做筛选', + options: [ + { + label: '置顶', + value: 'top', + }, + { + label: '精华', + value: 'refine', + }, + { + label: '周热门', + value: 'hot', + }, + ], + }, + }, + handler, +}; + +async function handler(ctx: Context) { + checkConfig(); + + const limit = ctx.req.query('limit') || 20; + + const pid = + ctx.req + .param('pid') + ?.split(',') + .filter((i) => POST_PART.some((part) => part.value === i)) ?? []; + + const type = ctx.req.param('type'); + + const postPart = pid.length ? pid.map((i) => POST_PART.find((p) => p.value === i)?.label).join(',') : ''; + + const posts = await getPosts({ + type: 1, + limit, + order: 'latest', + hotType: type === 'hot' ? 'postsHotWeek' : 'postsHotNow', + is_refine: type === 'refine' ? 1 : 0, + is_top: type === 'top' ? 1 : 0, + part_id: pid.length ? pid.join(',') : undefined, + }); + + return { + title: `石之家 - ${POST_TYPE[type] ?? ''}帖文${postPart ? ` - ${postPart}` : ''}`, + link: `${INDEX_URL}#/post`, + image: LOGO_URL, + item: await generatePostFeeds(posts), + }; +} diff --git a/lib/routes/sdo/ff14risingstones/strats.ts b/lib/routes/sdo/ff14risingstones/strats.ts new file mode 100644 index 000000000..6b75187fd --- /dev/null +++ b/lib/routes/sdo/ff14risingstones/strats.ts @@ -0,0 +1,75 @@ +import type { Route } from '@/types'; +import { INDEX_URL, LOGO_URL, POST_TYPE, REQUIRE_CONFIG, STRAT_PART } from './constant'; +import type { Context } from 'hono'; +import { checkConfig, generatePostFeeds } from './utils'; +import { getPosts } from './api'; + +export const route: Route = { + path: '/ff14risingstones/strats/:pid?/:type?', + example: '/sdo/ff14risingstones/strats/1,2/refine', + name: '攻略', + categories: ['bbs'], + maintainers: ['KarasuShin'], + features: { + requireConfig: REQUIRE_CONFIG, + }, + parameters: { + pid: { + description: '分区id,默认显示所有分区,可通过 `,` 拼接多个分区 id 进行筛选', + default: 'all', + options: [ + { + label: '全部', + value: 'all', + }, + ...STRAT_PART, + ], + }, + type: { + description: '攻略类型,默认不做筛选', + options: [ + { + label: '置顶', + value: 'top', + }, + { + label: '精华', + value: 'refine', + }, + ], + }, + }, + handler, +}; + +async function handler(ctx: Context) { + checkConfig(); + + const limit = ctx.req.query('limit') || 20; + + const pid = + ctx.req + .param('pid') + ?.split(',') + .filter((i) => STRAT_PART.some((part) => part.value === i)) ?? []; + + const type = ctx.req.param('type'); + + const stratPart = pid.length ? pid.map((i) => STRAT_PART.find((p) => p.value === i)?.label).join(',') : ''; + + const posts = await getPosts({ + type: 2, + limit, + order: 'latest', + is_refine: type === 'refine' ? 1 : 0, + is_top: type === 'top' ? 1 : 0, + part_id: pid.length ? pid.join(',') : undefined, + }); + + return { + title: `石之家 - ${POST_TYPE[type] ?? ''}攻略${stratPart ? ` - ${stratPart}` : ''}`, + link: `${INDEX_URL}#/strat`, + image: LOGO_URL, + item: await generatePostFeeds(posts), + }; +} diff --git a/lib/routes/sdo/ff14risingstones/templates/duties-party.art b/lib/routes/sdo/ff14risingstones/templates/duties-party.art new file mode 100644 index 000000000..ccee1b353 --- /dev/null +++ b/lib/routes/sdo/ff14risingstones/templates/duties-party.art @@ -0,0 +1,41 @@ +
+

进度:{{ progress }}

+

攻略:{{ strategy }}

+

时间:{{ fb_time }}

+

标签:{{ labelInfo.map(i => i.name).join(',') }}

+ {{ if team_composition === '团队' }} +

队伍构成

+

A队:{{ team_position.A }}

+

B队:{{ team_position.B }}

+

C队:{{ team_position.C }}

+ {{ else if team_composition === '满编小队' }} +

队伍构成

+

MT:{{ MT }}

+

ST:{{ ST }}

+

H1:{{ H1 }}

+

H2:{{ H2 }}

+

D1:{{ D1 }}

+

D2:{{ D2 }}

+

D3:{{ D3 }}

+

D4:{{ D4 }}

+ {{ else if team_composition === '轻锐小队' }} +

队伍构成

+

T:{{ T }}

+

H:{{ H }}

+

D1:{{ D1 }}

+

D2:{{ D2 }}

+ {{ /if }} + + {{ if need_job }} +

需求职业:{{ need_job }}

+ {{ /if }} + {{ if team_detail_mask }} +

队伍详情:{{ team_detail_mask }}

+ {{ /if }} + {{ if recruit_require_mask }} +

招募要求:{{ recruit_require_mask }}

+ {{ /if }} + {{ if strategy_desc_mask }} +

攻略说明:{{ strategy_desc_mask }}

+ {{ /if }} +
diff --git a/lib/routes/sdo/ff14risingstones/templates/fc-party.art b/lib/routes/sdo/ff14risingstones/templates/fc-party.art new file mode 100644 index 000000000..812675629 --- /dev/null +++ b/lib/routes/sdo/ff14risingstones/templates/fc-party.art @@ -0,0 +1,26 @@ +{{ if cover_pic }} + +{{ /if }} + +

部队名称:{{ guild_name }} <{{ guild_tag }}>

+

区服:{{ area_name }} {{ group_name }}

+

活跃成员:{{ active_member_num }}

+

招募人数:{{ target_recruit_num }}

+

活跃时间:工作日 {{ weekday_time }}    休息日 {{ weekend_time }}

+{{ if guild_address }} +

部队地址:{{ guild_address }}

+{{ /if }} +{{ if create_time }} +

成立时间:{{ create_time }}

+{{ /if }} +

部队标签:{{ labelInfo.map(i => i.name).join(',') }}

+ +
+{{@ detail_mask }} +
+ +{{ if foot_pic }} +{{ each foot_pic.split(',') url }} + +{{ /each }} +{{ /if }} diff --git a/lib/routes/sdo/ff14risingstones/templates/novice-network-party.art b/lib/routes/sdo/ff14risingstones/templates/novice-network-party.art new file mode 100644 index 000000000..b0287600c --- /dev/null +++ b/lib/routes/sdo/ff14risingstones/templates/novice-network-party.art @@ -0,0 +1,9 @@ +{{@ detail_mask }} + +
+ {{ if weekday_time && weekend_time }} +

活跃时间:工作日 {{ weekday_time }}    休息日 {{ weekend_time }}

+ {{ /if }} +

游戏风格:{{ styles }}

+

招募区服:{{ target }}

+
diff --git a/lib/routes/sdo/ff14risingstones/templates/rp-party.art b/lib/routes/sdo/ff14risingstones/templates/rp-party.art new file mode 100644 index 000000000..2a450a941 --- /dev/null +++ b/lib/routes/sdo/ff14risingstones/templates/rp-party.art @@ -0,0 +1,15 @@ +{{ if cover_pic }} + +{{ /if }} + +

开放时间:{{ open_time }}

+

RP 类型:{{ rp_type }}

+

创立时间:{{ create_time }}

+

区服:{{ area }}

+

地址:{{ address }}

+

标签:{{ custom_label }}

+

简介:{{ profile }}

+ +
+{{@ detail_mask }} +
diff --git a/lib/routes/sdo/ff14risingstones/timeline.ts b/lib/routes/sdo/ff14risingstones/timeline.ts new file mode 100644 index 000000000..f8bfa1e9d --- /dev/null +++ b/lib/routes/sdo/ff14risingstones/timeline.ts @@ -0,0 +1,31 @@ +import type { Data, Route } from '@/types'; +import { INDEX_URL, LOGO_URL, REQUIRE_CONFIG } from './constant'; +import type { Context } from 'hono'; +import { checkConfig, generateDynamicFeeds } from './utils'; +import { getFollowDynamicList } from './api'; + +export const route: Route = { + path: '/ff14risingstones/timeline', + example: '/sdo/ff14risingstones/timeline', + name: '时间线', + categories: ['bbs'], + maintainers: ['KarasuShin'], + features: { + requireConfig: REQUIRE_CONFIG, + }, + handler, +}; + +async function handler(ctx: Context) { + checkConfig(); + + const limit = ctx.req.query('limit') || 20; + const dynamics = await getFollowDynamicList(limit); + + return { + title: '石之家 - 时间线', + link: `${INDEX_URL}#/dynamic`, + image: LOGO_URL, + item: await generateDynamicFeeds(dynamics), + } as Data; +} diff --git a/lib/routes/sdo/ff14risingstones/types/dynamic.ts b/lib/routes/sdo/ff14risingstones/types/dynamic.ts new file mode 100644 index 000000000..6d6532d2d --- /dev/null +++ b/lib/routes/sdo/ff14risingstones/types/dynamic.ts @@ -0,0 +1,50 @@ +import type { DynamicSource } from '../constant'; +import type { DateTimeFormat, UserPost } from './other'; +import type { DutiesParty, NoviceNetworkParty, FreeCompanyParty, OtherParty, RolePlayParty } from './party'; + +interface BaseUserDynamic { + character_name: string; + area_name: string; + group_name: string; + created_at: DateTimeFormat; + from: DynamicSource; + from_id: string; + id: number; + mask_content: string; +} + +export interface GeneralDynamic extends BaseUserDynamic { + from: DynamicSource.General; +} + +export interface PostDynamic extends BaseUserDynamic { + from: DynamicSource.Post | DynamicSource.Strat; + from_info?: UserPost; +} + +export interface NoviceNetworkRecruitDynamic extends BaseUserDynamic { + from: DynamicSource.NoviceNetwork; + from_info?: Omit; +} + +export interface DutiesRecruitDynamic extends BaseUserDynamic { + from: DynamicSource.Duty; + from_info?: DutiesParty; +} + +export interface FreeCompanyRecruitDynamic extends BaseUserDynamic { + from: DynamicSource.FreeCompany; + from_info?: FreeCompanyParty; +} + +export interface RolePlayRecruitDynamic extends BaseUserDynamic { + from: DynamicSource.RolePlay; + from_info?: RolePlayParty; +} + +export interface OtherRecruitDynamic extends BaseUserDynamic { + from: DynamicSource.Other; + from_info?: OtherParty; +} + +export type UserDynamic = GeneralDynamic | PostDynamic | NoviceNetworkRecruitDynamic | FreeCompanyRecruitDynamic | RolePlayRecruitDynamic | DutiesRecruitDynamic | OtherRecruitDynamic; diff --git a/lib/routes/sdo/ff14risingstones/types/index.ts b/lib/routes/sdo/ff14risingstones/types/index.ts new file mode 100644 index 000000000..632bae83d --- /dev/null +++ b/lib/routes/sdo/ff14risingstones/types/index.ts @@ -0,0 +1,3 @@ +export type * from './dynamic'; +export type * from './other'; +export type * from './party'; diff --git a/lib/routes/sdo/ff14risingstones/types/other.ts b/lib/routes/sdo/ff14risingstones/types/other.ts new file mode 100644 index 000000000..48a2b12af --- /dev/null +++ b/lib/routes/sdo/ff14risingstones/types/other.ts @@ -0,0 +1,57 @@ +export type TimeStamp = `${number}`; + +export type DateFormat = `${number}-${number}-${number}`; + +export type DateTimeFormat = `${number}-${number}-${number} ${number}:${number}:${number}`; + +export type BaseResponse = { + code: number; + data: T; + msg: string; +}; + +export interface Resently { + detail: string; + event_type: string; + event_type_id: string; + log_time: DateTimeFormat; + part_date: DateFormat; +} + +export interface UserInfo { + avatar: string; + character_name: string; + group_name: string; +} + +export interface UserPost { + created_at: DateTimeFormat; + posts_id: string; + part_name: string; + title: string; + character_name: string; + group_name: string; + area_name: string; +} + +export interface PostDetail { + contentInfo: { + content: string; + created_at: DateTimeFormat; + id: string; + posts_id: string; + }; + created_at: DateTimeFormat; + updated_at: DateTimeFormat; +} + +export interface TeamPosition { + D1: string; + D2: string; + D3: string; + D4: string; + H1: string; + H2: string; + MT: string; + ST: string; +} diff --git a/lib/routes/sdo/ff14risingstones/types/party.ts b/lib/routes/sdo/ff14risingstones/types/party.ts new file mode 100644 index 000000000..68332e458 --- /dev/null +++ b/lib/routes/sdo/ff14risingstones/types/party.ts @@ -0,0 +1,111 @@ +import type { NoviceNetworkIdentity } from '../constant'; +import type { DateFormat, DateTimeFormat, TeamPosition, TimeStamp } from './other'; + +export interface BaseParty { + area_name: string; + begin_time: TimeStamp; + character_name: string; + end_time: TimeStamp; + group_name: string; + id: number; + status: number; // 1: 招募中, !1: 已结束 + target_area_name: string; + target_group_name: string | null; +} + +// 副本招募 +export interface DutiesParty extends BaseParty { + D1: number; + D2: number; + D3: number; + D4: number; + H: number; + H1: number; + H2: number; + MT: number; + ST: number; + T: number; + fb_name: string; + fb_time: string; + fb_type: string; + labelInfo: { + name: string; + }[]; + progress: string; + strategy: string; + team_composition: string; + team_position: { + A: TeamPosition; + B: TeamPosition; + C: TeamPosition; + } | null; +} + +export interface DutiesPartyDetail extends DutiesParty { + team_detail_mask: string | null; + recruit_require_mask: string | null; + strategy_desc_mask: string | null; + updated_at: DateTimeFormat | null; + need_job: string[]; +} + +// 导芽招募 +export interface NoviceNetworkParty extends BaseParty { + identity: NoviceNetworkIdentity; // 1: 导师, 2: 豆芽 + title: string; + weekday_time: string; + weekend_time: string; + style: string[]; + styleInfo: { style: string; pic_url: string }[]; + detail_mask: string; +} + +// 角色扮演招募 +export interface RolePlayParty extends BaseParty { + address: string; + custom_label: string; + detail_mask: string; + open_time: string; + profile: string; + rp_area_name: string; + rp_group_name: string; + rp_name: string; + rp_type: ('0' | '1' | '2' | '3')[]; // 0: 无 RP 元素, 1: 轻 RP 元素, 2: 中 RP 元素, 3: 重 RP 元素 + create_time: DateFormat; + cover_pic: string; +} + +export interface RolePlayPartyDetail extends RolePlayParty { + updated_at: DateTimeFormat | null; +} + +// 部队招募 +export interface FreeCompanyParty extends BaseParty { + created_at: TimeStamp; + guild_id: string; + guild_name: string; + guild_tag: string; + labelInfo: { + name: string; + }[]; + detail_mask: string; + active_member_num: number; + target_recruit_num: number; + cover_pic: string; + weekday_time: string; + weekend_time: string; +} + +export interface FreeCompanyPartyDetail extends FreeCompanyParty { + updated_at: DateTimeFormat | null; + create_time: DateTimeFormat | null; + guild_address: string | null; + foot_pic: string | null; +} + +// 其它招募 +export interface OtherParty extends BaseParty { + category_name: string; + title: string; + detail_mask: string; +} diff --git a/lib/routes/sdo/ff14risingstones/user-dynamics.ts b/lib/routes/sdo/ff14risingstones/user-dynamics.ts new file mode 100644 index 000000000..200170aa7 --- /dev/null +++ b/lib/routes/sdo/ff14risingstones/user-dynamics.ts @@ -0,0 +1,32 @@ +import type { Data, Route } from '@/types'; +import { INDEX_URL, REQUIRE_CONFIG } from './constant'; +import type { Context } from 'hono'; +import { checkConfig, generateDynamicFeeds } from './utils'; +import { getUserDynamic, getUserInfo } from './api'; + +export const route: Route = { + path: '/ff14risingstones/user-dynamics/:uid', + example: '/sdo/ff14risingstones/user-dynamics/10001226', + name: '用户动态', + categories: ['bbs'], + maintainers: ['KarasuShin'], + features: { + requireConfig: REQUIRE_CONFIG, + }, + handler, +}; + +async function handler(ctx: Context) { + checkConfig(); + + const uid = ctx.req.param('uid'); + + const [dynamics, userInfo] = await Promise.all([getUserDynamic(uid), getUserInfo(uid)]); + + return { + title: `石之家 - ${userInfo.character_name}@${userInfo.group_name} 的动态`, + link: `${INDEX_URL}#/me/dynamics?uuid=${uid}`, + image: userInfo.avatar, + item: await generateDynamicFeeds(dynamics), + } as Data; +} diff --git a/lib/routes/sdo/ff14risingstones/user-posts.ts b/lib/routes/sdo/ff14risingstones/user-posts.ts new file mode 100644 index 000000000..d06e2cff5 --- /dev/null +++ b/lib/routes/sdo/ff14risingstones/user-posts.ts @@ -0,0 +1,32 @@ +import type { Data, Route } from '@/types'; +import { INDEX_URL, REQUIRE_CONFIG } from './constant'; +import type { Context } from 'hono'; +import { checkConfig, generatePostFeeds } from './utils'; +import { getUserInfo, getUserPosts } from './api'; + +export const route: Route = { + path: '/ff14risingstones/user-posts/:uid', + example: '/sdo/ff14risingstones/user-posts/10001226', + name: '用户发帖', + categories: ['bbs'], + maintainers: ['KarasuShin'], + features: { + requireConfig: REQUIRE_CONFIG, + }, + handler, +}; + +async function handler(ctx: Context) { + checkConfig(); + + const uid = ctx.req.param('uid'); + + const [posts, userInfo] = await Promise.all([getUserPosts(uid, 1), getUserInfo(uid)]); + + return { + title: `石之家 - ${userInfo.character_name}@${userInfo.group_name} 发布的帖子`, + link: `${INDEX_URL}#/me/posts?uuid=${uid}`, + image: userInfo.avatar, + item: await generatePostFeeds(posts), + } as Data; +} diff --git a/lib/routes/sdo/ff14risingstones/user-resently.ts b/lib/routes/sdo/ff14risingstones/user-resently.ts new file mode 100644 index 000000000..6040e6093 --- /dev/null +++ b/lib/routes/sdo/ff14risingstones/user-resently.ts @@ -0,0 +1,38 @@ +import type { Data, Route } from '@/types'; +import { INDEX_URL, REQUIRE_CONFIG } from './constant'; +import type { Context } from 'hono'; +import { checkConfig } from './utils'; +import { getResently, getUserInfo } from './api'; +import { parseDate } from '@/utils/parse-date'; +import timezone from '@/utils/timezone'; + +export const route: Route = { + path: '/ff14risingstones/user-resently/:uid', + categories: ['bbs'], + example: '/sdo/ff14risingstones/user-resently/10008214', + name: '游戏近况', + maintainers: ['KarasuShin'], + features: { + requireConfig: REQUIRE_CONFIG, + }, + handler, +}; + +async function handler(ctx: Context) { + checkConfig(); + + const uid = ctx.req.param('uid'); + + const [resently, userInfo] = await Promise.all([getResently(uid), getUserInfo(uid)]); + + return { + title: `石之家 - ${userInfo.character_name}@${userInfo.group_name} 的游戏近况`, + link: `${INDEX_URL}#/me/info?uuid=${uid}`, + image: userInfo.avatar, + item: resently.map((i) => ({ + title: `${i.event_type} - ${i.detail}`, + pubDate: timezone(parseDate(i.log_time), +8), + guid: `sdo/ff14risingstones/resently:${uid}-${i.detail}`, + })), + } as Data; +} diff --git a/lib/routes/sdo/ff14risingstones/user-strats.ts b/lib/routes/sdo/ff14risingstones/user-strats.ts new file mode 100644 index 000000000..480c31965 --- /dev/null +++ b/lib/routes/sdo/ff14risingstones/user-strats.ts @@ -0,0 +1,32 @@ +import type { Data, Route } from '@/types'; +import { INDEX_URL, REQUIRE_CONFIG } from './constant'; +import type { Context } from 'hono'; +import { checkConfig, generatePostFeeds } from './utils'; +import { getUserInfo, getUserPosts } from './api'; + +export const route: Route = { + path: '/ff14risingstones/user-strats/:uid', + example: '/sdo/ff14risingstones/user-strats/10001226', + name: '用户攻略', + categories: ['bbs'], + maintainers: ['KarasuShin'], + features: { + requireConfig: REQUIRE_CONFIG, + }, + handler, +}; + +async function handler(ctx: Context) { + checkConfig(); + + const uid = ctx.req.param('uid'); + + const [posts, userInfo] = await Promise.all([getUserPosts(uid, 2), getUserInfo(uid)]); + + return { + title: `石之家 - ${userInfo.character_name}@${userInfo.group_name} 发布的攻略`, + link: `${INDEX_URL}#/me/posts?uuid=${uid}`, + image: userInfo.avatar, + item: await generatePostFeeds(posts), + } as Data; +} diff --git a/lib/routes/sdo/ff14risingstones/utils.ts b/lib/routes/sdo/ff14risingstones/utils.ts new file mode 100644 index 000000000..93c64eb4e --- /dev/null +++ b/lib/routes/sdo/ff14risingstones/utils.ts @@ -0,0 +1,215 @@ +import { config } from '@/config'; +import ConfigNotFoundError from '@/errors/types/config-not-found'; +import ofetch from '@/utils/ofetch'; +import type { BaseResponse, DutiesPartyDetail, FreeCompanyPartyDetail, NoviceNetworkParty, PostDetail, UserDynamic, UserPost } from './types'; +import { DataItem } from '@/types'; +import { DynamicSource, INDEX_URL, JOB, NoviceNetworkIdentity, PLAY_STYLE } from './constant'; +import { getDutiesRecruitDetail, getFreeCompanyRecruitDetail, getNoviceNetworkRecruitDetail, getPostsDetail } from './api'; +import timezone from '@/utils/timezone'; +import { parseDate } from '@/utils/parse-date'; +import { art } from '@/utils/render'; +import path from 'node:path'; + +export function checkConfig() { + if (!config.sdo.ff14risingstones || !config.sdo.ua) { + throw new ConfigNotFoundError('ff14risingstones RSS is disabled due to the lack of relevant config'); + } +} + +export function request(url: string, options?: RequestInit) { + return ofetch(url, { + ...options, + headers: { + Cookie: `ff14risingstones=${config.sdo.ff14risingstones}`, + 'User-Agent': config.sdo.ua!, + ...options?.headers, + }, + }); +} + +export async function requestAPI(url: string, options?: RequestInit) { + const response = (await request(url, options)) as BaseResponse; + + if (response.code !== 10000) { + throw new Error(response.msg); + } + return response.data; +} + +export async function generatePostFeeds(posts: UserPost[]) { + return await Promise.all( + posts.map(async (post) => { + const detail = await getPostsDetail(post.posts_id); + return { + title: `[${post.part_name}] ${post.title}`, + link: `${INDEX_URL}#/post/detail/${post.posts_id}`, + description: detail?.contentInfo.content, + pubDate: timezone(parseDate(post.created_at), +8), + updated: detail?.updated_at ? timezone(parseDate(detail.updated_at), +8) : undefined, + guid: `sdo/ff14risingstones/posts:${post.posts_id}`, + author: `${post.character_name}@${post.group_name}`, + } as DataItem; + }) + ); +} + +export async function generateDynamicFeeds(dynamics: UserDynamic[]) { + return await Promise.all( + dynamics.map(async (dynamic) => { + let title = `${dynamic.character_name}@${dynamic.group_name} ${dynamic.mask_content}`; + let link: string | undefined; + let description: string | undefined; + let detail: PostDetail | DutiesPartyDetail | FreeCompanyPartyDetail | NoviceNetworkParty | null = null; + + switch (dynamic.from) { + case DynamicSource.Post: + case DynamicSource.Strat: + if (!dynamic.from_info) { + break; + } + title += dynamic.from_info.title; + link = `${INDEX_URL}#/post/detail/${dynamic.from_info.posts_id}`; + detail = await getPostsDetail(dynamic.from_info.posts_id); + description = detail?.contentInfo.content; + break; + + case DynamicSource.NoviceNetwork: + if (!dynamic.from_info) { + break; + } + title += `[找${dynamic.from_info.identity === NoviceNetworkIdentity.Mentor ? '豆芽' : '导师'}] ${dynamic.from_info.title}`; + link = `${INDEX_URL}#/recruit/beginner?id=${dynamic.from_info.id}`; + detail = await getNoviceNetworkRecruitDetail(dynamic.from_info.id); + description = art(path.join(__dirname, 'templates/novice-network-party.art'), { + detail_mask: dynamic.from_info.detail_mask, + styles: dynamic.from_info.style.map((i) => PLAY_STYLE[i]).join(','), + target: `${dynamic.from_info.target_area_name} ${dynamic.from_info.target_group_name ?? '全区'}`, + weekday_time: detail?.weekday_time, + weekend_time: detail?.weekend_time, + }); + break; + + case DynamicSource.Duty: + if (!dynamic.from_info) { + break; + } + title += `[${dynamic.from_info.fb_type}] ${dynamic.from_info.fb_name}`; + link = `${INDEX_URL}#/recruit/party?id=${dynamic.from_info.id}`; + detail = await getDutiesRecruitDetail(dynamic.from_info.id); + + description = art(path.join(__dirname, 'templates/duties-party.art'), { + progress: dynamic.from_info.progress, + strategy: dynamic.from_info.strategy, + fb_name: dynamic.from_info.fb_name, + fb_time: dynamic.from_info.fb_time, + labelInfo: dynamic.from_info.labelInfo, + team_composition: dynamic.from_info.team_composition, + team_position: dynamic.from_info.team_position + ? { + A: Object.keys(dynamic.from_info.team_position.A) + .filter((i) => dynamic.from_info!.team_position!.A[i] !== '0') + .map((i) => `${i}: ${JOB[dynamic.from_info!.team_position!.A[i]]}`) + .join(','), + B: Object.keys(dynamic.from_info.team_position.B) + .filter((i) => dynamic.from_info!.team_position!.B[i] !== '0') + .map((i) => `${i}: ${JOB[dynamic.from_info!.team_position!.B[i]]}`) + .join(','), + C: Object.keys(dynamic.from_info.team_position.C) + .filter((i) => dynamic.from_info!.team_position!.C[i] !== '0') + .map((i) => `${i}: ${JOB[dynamic.from_info!.team_position!.C[i]]}`) + .join(','), + } + : null, + MT: JOB[dynamic.from_info.MT], + ST: JOB[dynamic.from_info.ST], + T: JOB[dynamic.from_info.T], + H: JOB[dynamic.from_info.H], + H1: JOB[dynamic.from_info.H1], + H2: JOB[dynamic.from_info.H2], + D1: JOB[dynamic.from_info.D1], + D2: JOB[dynamic.from_info.D2], + D3: JOB[dynamic.from_info.D3], + D4: JOB[dynamic.from_info.D4], + need_job: detail?.need_job.map((i) => JOB[i]).join(','), + team_detail_mask: detail?.team_detail_mask, + recruit_require_mask: detail?.recruit_require_mask, + strategy_desc_mask: detail?.strategy_desc_mask, + }); + break; + + case DynamicSource.FreeCompany: + if (!dynamic.from_info) { + break; + } + title += `[部队招待] ${dynamic.from_info.guild_name} <${dynamic.from_info.guild_tag}>`; + link = `${INDEX_URL}#/recruit/guild/detail/${dynamic.from_info.id}`; + detail = await getFreeCompanyRecruitDetail(dynamic.from_info.id); + + description = art(path.join(__dirname, 'templates/fc-party.art'), { + cover_pic: dynamic.from_info.cover_pic, + guild_name: dynamic.from_info.guild_name, + guild_tag: dynamic.from_info.guild_tag, + labelInfo: dynamic.from_info.labelInfo, + area_name: dynamic.from_info.target_area_name, + group_name: dynamic.from_info.target_group_name, + active_member_num: dynamic.from_info.active_member_num, + target_recruit_num: dynamic.from_info.target_recruit_num, + weekday_time: dynamic.from_info.weekday_time, + weekend_time: dynamic.from_info.weekend_time, + guild_address: detail?.guild_address ?? '', + create_time: detail?.create_time ?? '', + foot_pic: detail?.foot_pic ?? '', + detail_mask: dynamic.from_info.detail_mask, + }); + break; + + case DynamicSource.RolePlay: + if (!dynamic.from_info) { + break; + } + title += dynamic.from_info.rp_name; + link = `${INDEX_URL}#/recruit/roleplay/detail/${dynamic.from_info.id}`; + description = art(path.join(__dirname, 'templates/rp-party.art'), { + cover_pic: dynamic.from_info.cover_pic, + open_time: dynamic.from_info.open_time, + rp_type: `${dynamic.from_info.rp_type + .map( + (i) => + ({ + '0': '无', + '1': '轻', + '2': '中', + '3': '重', + })[i] + ) + .join('/')}RP 元素`, + create_time: dynamic.from_info.create_time, + area: `${dynamic.from_info.rp_area_name} ${dynamic.from_info.rp_group_name}`, + address: dynamic.from_info.address, + custom_label: dynamic.from_info.custom_label, + profile: dynamic.from_info.profile, + detail_mask: dynamic.from_info.detail_mask, + }); + break; + case DynamicSource.Other: + if (!dynamic.from_info) { + break; + } + title += `[${dynamic.from_info.category_name}] ${dynamic.from_info.title}`; + link = `${INDEX_URL}#/recruit/others?id=${dynamic.from_info.id}`; + description = dynamic.from_info.detail_mask; + break; + default: + // do nothing + } + return { + title, + link, + pubDate: timezone(parseDate(dynamic.created_at), +8), + guid: `sdo/ff14risingstones/dynamics:${dynamic.id}`, + author: `${dynamic.character_name}@${dynamic.group_name}`, + description, + } as DataItem; + }) + ); +} diff --git a/lib/routes/sdo/namespace.ts b/lib/routes/sdo/namespace.ts new file mode 100644 index 000000000..96b48ec45 --- /dev/null +++ b/lib/routes/sdo/namespace.ts @@ -0,0 +1,7 @@ +import type { Namespace } from '@/types'; + +export const namespace: Namespace = { + name: '盛趣游戏在线', + url: 'sdo.com', + lang: 'zh-CN', +};