mirror of
https://github.com/samuelthomas2774/nxapi.git
synced 2026-04-24 06:56:54 -05:00
Export Discord title configuration
This commit is contained in:
parent
7b9dc1c297
commit
eac5aec23c
|
|
@ -30,6 +30,10 @@ COPY bin /app/bin
|
|||
COPY resources /app/resources
|
||||
COPY --from=build /app/dist /app/dist
|
||||
|
||||
RUN ln -s /app/bin/nxapi.js /usr/local/bin/nxapi
|
||||
ENV NXAPI_DATA_PATH=/data
|
||||
ENV NODE_ENV=production
|
||||
|
||||
RUN ln -s /data/android /root/.android
|
||||
|
||||
VOLUME [ "/data" ]
|
||||
|
|
|
|||
41
data-api/Dockerfile
Normal file
41
data-api/Dockerfile
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
FROM node:17 as build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ADD package.json /app
|
||||
ADD package-lock.json /app
|
||||
|
||||
RUN npm install
|
||||
|
||||
COPY src /app/src
|
||||
COPY bin /app/bin
|
||||
ADD tsconfig.json /app
|
||||
|
||||
RUN npx tsc
|
||||
|
||||
RUN ln -s /app/bin/nxapi.js /usr/local/bin/nxapi
|
||||
ENV NXAPI_DATA_PATH=/data
|
||||
ENV NODE_ENV=development
|
||||
|
||||
COPY data-api/public /public
|
||||
WORKDIR /public
|
||||
|
||||
RUN mkdir -p data && \
|
||||
echo "Exporting Discord title configuration as JSON" && \
|
||||
DEBUG=* nxapi util export-discord-titles --format json > data/discord-titles.json && \
|
||||
echo "Exporting Discord title configuration as JSON without Discord activity configuration" && \
|
||||
DEBUG=* nxapi util export-discord-titles --format json --exclude-discord-configuration > data/discord-titles-compact.json && \
|
||||
# echo "Exporting Discord title configuration as JSON with Nintendo eShop contents" && \
|
||||
# DEBUG=* nxapi util export-discord-titles --format json --include-title-contents > data/discord-titles-with-contents.json && \
|
||||
echo "Exporting Discord title configuration as JSON with Discord applications" && \
|
||||
DEBUG=* nxapi util export-discord-titles --format json --group-discord-clients > data/discord-clients.json && \
|
||||
# echo "Exporting Discord title configuration as JSON with Discord applications and Nintendo eShop contents" && \
|
||||
# DEBUG=* nxapi util export-discord-titles --format json --group-discord-clients --include-title-contents > data/discord-clients-with-contents.json && \
|
||||
echo "Exporting Discord title configuration as CSV" && \
|
||||
DEBUG=* nxapi util export-discord-titles --format csv > data/discord-titles.csv && \
|
||||
echo "Exporting Discord title configuration as CSV without Discord activity configuration" && \
|
||||
DEBUG=* nxapi util export-discord-titles --format csv --exclude-discord-configuration > data/discord-titles-compact.csv
|
||||
|
||||
FROM nginx:alpine
|
||||
|
||||
COPY --from=build /public /usr/share/nginx/html
|
||||
13
data-api/public/index.html
Normal file
13
data-api/public/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en-GB">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="Refresh" content="0;url=https://gitlab.fancy.org.uk/samuel/nxapi" />
|
||||
<title>nxapi</title>
|
||||
</head>
|
||||
<body>
|
||||
<script>location.href = 'https://gitlab.fancy.org.uk/samuel/nxapi';</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -11,6 +11,7 @@ export const desc = 'Utilities';
|
|||
export function builder(yargs: Argv<ParentArguments>) {
|
||||
for (const command of Object.values(commands)) {
|
||||
if (command.command === 'validate-discord-titles' && !dev) continue;
|
||||
if (command.command === 'export-discord-titles' && !dev) continue;
|
||||
|
||||
// @ts-expect-error
|
||||
yargs.command(command);
|
||||
|
|
|
|||
347
src/cli/util/export-discord-titles.ts
Normal file
347
src/cli/util/export-discord-titles.ts
Normal file
|
|
@ -0,0 +1,347 @@
|
|||
import createDebug from 'debug';
|
||||
import fetch from 'node-fetch';
|
||||
import type { Arguments as ParentArguments } from '../../cli.js';
|
||||
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util.js';
|
||||
import { titles as unsorted_titles } from '../../discord/titles.js';
|
||||
import { DiscordApplicationRpc, getDiscordApplicationRpc } from './discord-activity.js';
|
||||
import { Title } from '../../discord/util.js';
|
||||
|
||||
const debug = createDebug('cli:util:export-discord-titles');
|
||||
|
||||
export const command = 'export-discord-titles';
|
||||
export const desc = 'Export custom Discord configuration for all titles as JSON or CSV';
|
||||
|
||||
export function builder(yargs: Argv<ParentArguments>) {
|
||||
return yargs.option('format', {
|
||||
describe: 'Export format (json, json-pretty-print, csv)',
|
||||
type: 'string',
|
||||
default: 'json',
|
||||
}).option('exclude-discord-configuration', {
|
||||
describe: 'Only include title and client IDs - don\'t include Discord activity configuration',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
}).option('group-discord-clients', {
|
||||
describe: 'Group titles by Discord client (only for json, json-pretty-print formats)',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
}).option('include-title-contents', {
|
||||
describe: 'Include title contents from Nintendo eShop',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
});
|
||||
}
|
||||
|
||||
type Arguments = YargsArguments<ReturnType<typeof builder>>;
|
||||
|
||||
export async function handler(argv: ArgumentsCamelCase<Arguments>) {
|
||||
if (argv.format === 'json' || argv.format === 'json-pretty-print') {
|
||||
const data = argv.groupDiscordClients ?
|
||||
await getGroupedTitlesJson(argv.excludeDiscordConfiguration, argv.includeTitleContents) :
|
||||
await getTitlesJson(argv.excludeDiscordConfiguration, argv.includeTitleContents);
|
||||
|
||||
if (argv.format === 'json-pretty-print') {
|
||||
console.log(JSON.stringify(data, null, 4));
|
||||
} else {
|
||||
console.log(JSON.stringify(data));
|
||||
}
|
||||
} else if (argv.format === 'csv') {
|
||||
if (argv.groupDiscordClients) throw new Error('--group-discord-clients is not compatible with --format csv');
|
||||
if (argv.includeTitleContents) throw new Error('--include-title-contents is not compatible with --format csv');
|
||||
|
||||
const csv = getTitlesCsv(argv.excludeDiscordConfiguration);
|
||||
console.log(csv);
|
||||
} else {
|
||||
throw new Error('Unknown format');
|
||||
}
|
||||
}
|
||||
|
||||
function getSortedTitles(exclude_discord_configuration = false): Title[] {
|
||||
const titles = [...unsorted_titles].sort((t1, t2) => t1.id > t2.id ? 1 : t2.id > t1.id ? -1 : 0);
|
||||
|
||||
return exclude_discord_configuration ? titles.map(t => ({id: t.id, client: t.client})) : titles;
|
||||
}
|
||||
|
||||
async function getTitlesWithContents(exclude_discord_configuration = false) {
|
||||
const titles = getSortedTitles(exclude_discord_configuration);
|
||||
const titles_with_contents = [];
|
||||
const titles_with_nsuids = [];
|
||||
const titles_to_fetch: {region: string; language: string; nsuids: string[];}[] = [];
|
||||
|
||||
for (const title of titles) {
|
||||
const nsuid = await getTitleFirstNsuId(title.id);
|
||||
|
||||
titles_with_nsuids.push({
|
||||
title,
|
||||
nsuid,
|
||||
});
|
||||
|
||||
if (nsuid) {
|
||||
let c = titles_to_fetch.find(c => c.region === nsuid.region && c.language === nsuid.language);
|
||||
|
||||
if (!c) {
|
||||
c = {region: nsuid.region, language: nsuid.language, nsuids: []};
|
||||
titles_to_fetch.push(c);
|
||||
}
|
||||
|
||||
c.nsuids.push(nsuid.nsuid);
|
||||
}
|
||||
}
|
||||
|
||||
const title_contents: Record<string, EcContent[]> = {};
|
||||
|
||||
for (const t of titles_to_fetch) {
|
||||
const contents: EcContent[] = title_contents[t.region + '/' + t.language] = [];
|
||||
const batched_nsuids: string[][] = [];
|
||||
let current: string[] | null = null;
|
||||
|
||||
for (const nsuid of t.nsuids) {
|
||||
if (!current) batched_nsuids.push(current = []);
|
||||
current.push(nsuid);
|
||||
|
||||
if (current.length >= 20) {
|
||||
contents.push(...await getTitleContents(current, t.region, t.language));
|
||||
current = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (current?.length) {
|
||||
contents.push(...await getTitleContents(current, t.region, t.language));
|
||||
current = null;
|
||||
}
|
||||
}
|
||||
|
||||
for (const title of titles_with_nsuids) {
|
||||
if (!title.nsuid) {
|
||||
titles_with_contents.push(title.title);
|
||||
continue;
|
||||
}
|
||||
|
||||
const contents = (title_contents[title.nsuid.region + '/' + title.nsuid.language] ?? [])
|
||||
.find(t => '' + t.id === title.nsuid!.nsuid);
|
||||
|
||||
if (contents && contents.content_type !== 'title') {
|
||||
debug('Title %s (NSU ID %s %s) is not an application', title.title.id,
|
||||
contents.id, contents.formal_name, contents.content_type);
|
||||
}
|
||||
|
||||
titles_with_contents.push({
|
||||
...title.title,
|
||||
nsuid: title.nsuid.nsuid,
|
||||
ec: contents ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
return titles_with_contents;
|
||||
}
|
||||
|
||||
async function getTitlesJson(exclude_discord_configuration = false, include_title_contents = false) {
|
||||
const titles = include_title_contents ?
|
||||
await getTitlesWithContents(exclude_discord_configuration) :
|
||||
getSortedTitles(exclude_discord_configuration);
|
||||
|
||||
return {titles};
|
||||
}
|
||||
|
||||
async function getGroupedTitlesJson(exclude_discord_configuration = false, include_title_contents = false) {
|
||||
const titles = include_title_contents ?
|
||||
await getTitlesWithContents(exclude_discord_configuration) :
|
||||
getSortedTitles(exclude_discord_configuration);
|
||||
const clients: {
|
||||
id: string;
|
||||
application: DiscordApplicationRpc;
|
||||
titles: Title[];
|
||||
}[] = [];
|
||||
|
||||
for (const title of titles) {
|
||||
let client = clients.find(c => c.id === title.client);
|
||||
|
||||
if (!client) {
|
||||
const application = await getDiscordApplicationRpc(title.client);
|
||||
|
||||
client = {
|
||||
id: title.client,
|
||||
application,
|
||||
titles: [],
|
||||
};
|
||||
|
||||
clients.push(client);
|
||||
}
|
||||
|
||||
client.titles.push(title);
|
||||
}
|
||||
|
||||
return {clients};
|
||||
}
|
||||
|
||||
function getTitlesCsv(exclude_discord_configuration = false) {
|
||||
const titles = getSortedTitles(exclude_discord_configuration);
|
||||
|
||||
const header = exclude_discord_configuration ?
|
||||
'titleid,discordclientid\n' :
|
||||
'titleid,discordclientid,titlenamesuffix,largeimagekey,largeimagetext,smallimagekey,smallimagetext\n';
|
||||
let csv = header;
|
||||
|
||||
for (const title of titles) {
|
||||
csv += exclude_discord_configuration ?
|
||||
title.id + ',' +
|
||||
title.client + '\n' :
|
||||
|
||||
title.id + ',' +
|
||||
title.client + ',' +
|
||||
(title.titleName ? JSON.stringify(title.titleName) : '') + ',' +
|
||||
(title.largeImageKey ? JSON.stringify(title.largeImageKey) : '') + ',' +
|
||||
(title.largeImageText ? JSON.stringify(title.largeImageText) : '') + ',' +
|
||||
(title.smallImageKey ? JSON.stringify(title.smallImageKey) : '') + ',' +
|
||||
(title.smallImageText ? JSON.stringify(title.smallImageText) : '') + '\n';
|
||||
}
|
||||
|
||||
return csv;
|
||||
}
|
||||
|
||||
async function getTitleNsuId(id: string, region = 'GB') {
|
||||
if (!id.match(/^0100([0-9a-f]{8})[02468ace]000$/i)) {
|
||||
throw new Error('Invalid title ID');
|
||||
}
|
||||
|
||||
const url = 'https://ec.nintendo.com/apps/' + id.toLowerCase() + '/' + region;
|
||||
|
||||
const response = await fetch(url, {
|
||||
redirect: 'manual',
|
||||
});
|
||||
debug('fetch %s %s, response %s', 'GET', url, response.status);
|
||||
|
||||
if (response.status === 404) return null;
|
||||
|
||||
if (response.status !== 303) {
|
||||
debug('Non-303 status code', await response.text());
|
||||
throw new Error('Unknown error');
|
||||
}
|
||||
|
||||
await response.arrayBuffer();
|
||||
|
||||
const location = response.headers.get('Location');
|
||||
|
||||
if (!location) {
|
||||
throw new Error('Invalid response - didn\'t include Location header');
|
||||
}
|
||||
|
||||
const redirect_url = new URL(location);
|
||||
let match;
|
||||
|
||||
if (redirect_url.hostname === 'ec.nintendo.com' && (match =
|
||||
redirect_url.pathname.match(/^\/([A-Z]{2})\/([a-z]{2})\/(title|aoc)s?\/(\d{14})$/)
|
||||
)) {
|
||||
return {nsuid: match[4], region, language: match[2]};
|
||||
} else if (redirect_url.hostname === 'www.nintendo-europe.com' && redirect_url.pathname === '/redirect/') {
|
||||
return {nsuid: redirect_url.searchParams.get('nsuid')!, region,
|
||||
language: redirect_url.searchParams.get('language')!};
|
||||
} else if (redirect_url.hostname === 'www.nintendo.com' && (match =
|
||||
redirect_url.pathname.match(/^\/pos-redirect\/(\d{14})$/)
|
||||
)) {
|
||||
return {nsuid: match[1], region, language: default_language[region as 'US'] ?? 'en'};
|
||||
}
|
||||
|
||||
debug('Unknown URL format received for title %s (%s)', id, region, location);
|
||||
|
||||
throw new Error('Unknown URL format');
|
||||
}
|
||||
|
||||
async function getTitleNsuIds(id: string, regions: string[] = ['GB']) {
|
||||
const nsuids: Record<string, {nsuid: string; region: string; language: string;} | null> = {};
|
||||
|
||||
for (const region of regions) {
|
||||
nsuids[region] = await getTitleNsuId(id, region);
|
||||
}
|
||||
|
||||
return nsuids;
|
||||
}
|
||||
|
||||
async function getTitleFirstNsuId(id: string, regions: string[] = ['GB', 'JP', 'US', 'AU']) {
|
||||
for (const region of regions) {
|
||||
const nsuid = await getTitleNsuId(id, region);
|
||||
if (nsuid) return nsuid;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
interface EcContentsResponse {
|
||||
contents: EcContent[];
|
||||
}
|
||||
interface EcContent {
|
||||
is_special_trial?: boolean;
|
||||
content_type: 'title' | 'demo' | 'aoc' | 'bundle';
|
||||
disclaimer?: string;
|
||||
dominant_colors: string;
|
||||
ex_membership_free: boolean;
|
||||
formal_name: string;
|
||||
hero_banner_url?: string;
|
||||
id: number;
|
||||
is_new?: boolean;
|
||||
membership_required?: boolean;
|
||||
public_status: 'public';
|
||||
rating_info: EcContentRating;
|
||||
release_date_on_eshop: string;
|
||||
screenshots: {
|
||||
images: {
|
||||
url: string;
|
||||
}[];
|
||||
}[];
|
||||
strong_disclaimer?: string;
|
||||
tags: unknown;
|
||||
target_titles: {
|
||||
id: number;
|
||||
}[];
|
||||
}
|
||||
interface EcContentRating {
|
||||
content_descriptors: {
|
||||
id: number;
|
||||
image_url: string;
|
||||
name: string;
|
||||
svg_image_url: string;
|
||||
type: 'descriptor';
|
||||
}[];
|
||||
rating: {
|
||||
age: number;
|
||||
id: number;
|
||||
image_url: string;
|
||||
name: string;
|
||||
provisional: boolean;
|
||||
svg_image_url: string;
|
||||
};
|
||||
rating_system: {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
const default_language = {
|
||||
'GB': 'en',
|
||||
'US': 'en',
|
||||
'AU': 'en',
|
||||
'JP': 'ja',
|
||||
};
|
||||
|
||||
async function getTitleContents(id: string | string[], region = 'GB', language = default_language[region as 'GB']) {
|
||||
if (typeof id === 'string') id = [id];
|
||||
if (id.find(id => !id.match(/^\d{14}$/i))) {
|
||||
throw new Error('Invalid NSU ID');
|
||||
}
|
||||
if (!language?.match(/^[a-z]{2}$/)) {
|
||||
throw new Error('Invalid language');
|
||||
}
|
||||
|
||||
const url = 'https://ec.nintendo.com/api/' + region + '/' + language + '/contents?id=' + id.join('&id=');
|
||||
|
||||
const response = await fetch(url);
|
||||
debug('fetch %s %s, response %s', 'GET', url, response.status);
|
||||
|
||||
if (response.status !== 200) {
|
||||
debug('Non-200 status code', await response.text());
|
||||
throw new Error('Unknown error');
|
||||
}
|
||||
|
||||
const contents = await response.json() as EcContentsResponse;
|
||||
|
||||
return contents.contents;
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
export * as captureid from './captureid.js';
|
||||
export * as validateDiscordTitles from './validate-discord-titles.js';
|
||||
export * as exportDiscordTitles from './export-discord-titles.js';
|
||||
export * as discordActivity from './discord-activity.js';
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import DiscordRPC from 'discord-rpc';
|
||||
import { ActiveEvent, CurrentUser, Friend, Game, PresenceState } from '../api/znc-types.js';
|
||||
import { defaultTitle, titles } from './titles.js';
|
||||
import { dev, getTitleIdFromEcUrl, hrduration, version } from '../util.js';
|
||||
import { dev, getTitleIdFromEcUrl, git, hrduration, version } from '../util.js';
|
||||
import { ZncDiscordPresence } from '../cli/nso/presence.js';
|
||||
|
||||
const product = 'nxapi ' + version +
|
||||
(dev ? '-' + dev.revision.substr(0, 7) + (dev.branch ? ' (' + dev.branch + ')' : '') : '');
|
||||
(git ? '-' + git.revision.substr(0, 7) + (git.branch ? ' (' + git.branch + ')' : dev ? '-dev' : '') : '');
|
||||
|
||||
export function getDiscordPresence(
|
||||
state: PresenceState, game: Game, context?: DiscordPresenceContext
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export const paths = getPaths('nxapi');
|
|||
export const dir = path.resolve(import.meta.url.substr(7), '..', '..');
|
||||
export const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8'));
|
||||
export const version = pkg.version;
|
||||
export const dev = (() => {
|
||||
export const git = (() => {
|
||||
try {
|
||||
fs.statSync(path.join(dir, '.git'));
|
||||
} catch (err) {
|
||||
|
|
@ -40,6 +40,7 @@ export const dev = (() => {
|
|||
changed_files: changed_files.length ? changed_files.split('\n') : [],
|
||||
};
|
||||
})();
|
||||
export const dev = !!git || process.env.NODE_ENV === 'development';
|
||||
|
||||
export type YargsArguments<T extends yargs.Argv> = T extends yargs.Argv<infer R> ? R : any;
|
||||
export type Argv<T = {}> = yargs.Argv<T>;
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user