Export Discord title configuration

This commit is contained in:
Samuel Elliott 2022-04-30 13:23:50 +01:00
parent 7b9dc1c297
commit eac5aec23c
No known key found for this signature in database
GPG Key ID: 8420C7CDE43DC4D6
8 changed files with 411 additions and 3 deletions

View File

@ -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
View 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

View 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>

View File

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

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

View File

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

View File

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

View File

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