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

* Update lib/routes/sdo/ff14risingstones/posts.ts
This commit is contained in:
karasu 2025-09-09 06:40:09 +08:00 committed by GitHub
parent e3119f6207
commit e20f27e491
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 1278 additions and 0 deletions

View File

@ -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',
},

View File

@ -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<Resently[]>(`${API_URL}/home/userInfo/getResently?uuid=${uid}`);
// 获取用户信息
export const getUserInfo = async (uid: string) => await requestAPI<UserInfo>(`${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<PostDetail>(`${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<NoviceNetworkParty>(`${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<DutiesPartyDetail>(`${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<FreeCompanyPartyDetail>(`${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);

View File

@ -0,0 +1,338 @@
import type { Route } from '@/types';
export const REQUIRE_CONFIG: NonNullable<Route['features']>['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,
}

View File

@ -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),
};
}

View File

@ -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),
};
}

View File

@ -0,0 +1,41 @@
<div>
<p>进度:{{ progress }}</p>
<p>攻略:{{ strategy }}</p>
<p>时间:{{ fb_time }}</p>
<p>标签:{{ labelInfo.map(i => i.name).join('') }}</p>
{{ if team_composition === '团队' }}
<p>队伍构成</P>
<p>A队{{ team_position.A }}</p>
<p>B队{{ team_position.B }}</p>
<p>C队{{ team_position.C }}</p>
{{ else if team_composition === '满编小队' }}
<p>队伍构成</p>
<p>MT{{ MT }}</p>
<p>ST{{ ST }}</p>
<p>H1{{ H1 }}</p>
<p>H2{{ H2 }}</p>
<p>D1{{ D1 }}</p>
<p>D2{{ D2 }}</p>
<p>D3{{ D3 }}</p>
<p>D4{{ D4 }}</p>
{{ else if team_composition === '轻锐小队' }}
<p>队伍构成</p>
<p>T{{ T }}</p>
<p>H{{ H }}</p>
<p>D1{{ D1 }}</p>
<p>D2{{ D2 }}</p>
{{ /if }}
{{ if need_job }}
<p>需求职业:{{ need_job }}</P>
{{ /if }}
{{ if team_detail_mask }}
<p>队伍详情:{{ team_detail_mask }}</p>
{{ /if }}
{{ if recruit_require_mask }}
<p>招募要求:{{ recruit_require_mask }}</p>
{{ /if }}
{{ if strategy_desc_mask }}
<p>攻略说明:{{ strategy_desc_mask }}</p>
{{ /if }}
</div>

View File

@ -0,0 +1,26 @@
{{ if cover_pic }}
<img src="{{ cover_pic }}" />
{{ /if }}
<p>部队名称:{{ guild_name }} <{{ guild_tag }}></p>
<p>区服:{{ area_name }} {{ group_name }}</p>
<p>活跃成员:{{ active_member_num }}</p>
<p>招募人数:{{ target_recruit_num }}</p>
<p>活跃时间:工作日&nbsp;{{ weekday_time }}&nbsp;&nbsp;&nbsp;&nbsp;休息日&nbsp;{{ weekend_time }}</p>
{{ if guild_address }}
<p>部队地址:{{ guild_address }}</p>
{{ /if }}
{{ if create_time }}
<p>成立时间:{{ create_time }}</p>
{{ /if }}
<p>部队标签:{{ labelInfo.map(i => i.name).join('') }}</p>
<div>
{{@ detail_mask }}
</div>
{{ if foot_pic }}
{{ each foot_pic.split(',') url }}
<img src="{{ url }}" />
{{ /each }}
{{ /if }}

View File

@ -0,0 +1,9 @@
{{@ detail_mask }}
<div>
{{ if weekday_time && weekend_time }}
<p>活跃时间:工作日&nbsp;{{ weekday_time }}&nbsp;&nbsp;&nbsp;&nbsp;休息日&nbsp;{{ weekend_time }}</p>
{{ /if }}
<p>游戏风格:{{ styles }}</p>
<p>招募区服:{{ target }}</p>
</div>

View File

@ -0,0 +1,15 @@
{{ if cover_pic }}
<img src="{{ cover_pic }}" />
{{ /if }}
<p>开放时间:{{ open_time }}</p>
<p>RP 类型:{{ rp_type }}</p>
<p>创立时间:{{ create_time }}</p>
<p>区服:{{ area }}</p>
<p>地址:{{ address }}</p>
<p>标签:{{ custom_label }}</p>
<p>简介:{{ profile }}</p>
<div>
{{@ detail_mask }}
</div>

View File

@ -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;
}

View File

@ -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<NoviceNetworkParty, 'styleInfo' | 'weekday_time' | 'weekend_time'>;
}
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;

View File

@ -0,0 +1,3 @@
export type * from './dynamic';
export type * from './other';
export type * from './party';

View File

@ -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<T = any> = {
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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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<T = any>(url: string, options?: RequestInit) {
const response = (await request(url, options)) as BaseResponse<T>;
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;
})
);
}

View File

@ -0,0 +1,7 @@
import type { Namespace } from '@/types';
export const namespace: Namespace = {
name: '盛趣游戏在线',
url: 'sdo.com',
lang: 'zh-CN',
};