Refactor cube API, add buy/pool info

This commit is contained in:
Hamcha 2019-11-23 16:20:58 +01:00
parent 1727882fb5
commit d0f49fce07
Signed by: hamcha
GPG key ID: 44AD3571EB09A39E
9 changed files with 359 additions and 246 deletions

41
cmd/buy.ts Normal file
View file

@ -0,0 +1,41 @@
import * as ejs from "ejs";
import { existsSync } from "fs";
import { Article, asyncLoadJSON, CardMarketApi, MCMDB } from "../lib";
import { cubeCards, genCube } from "../lib/cube";
async function run() {
if (process.argv.length < 3) {
console.error("Usage: yarn buy <uid>");
process.exit(1);
return;
}
const uid = process.argv[2];
const uidCards = `${uid}-cards.json`;
if (!existsSync("mcmCards.json")) {
console.error("Card db is missing! Run 'yarn convert-db' first.");
process.exit(1);
}
if (!existsSync(uidCards)) {
console.error(`Could not find ${uidCards}! Run 'yarn fetch ${uid}' first.`);
process.exit(1);
}
let db = await asyncLoadJSON<MCMDB>("mcmCards.json");
let articles = await asyncLoadJSON<Article[]>(`${uid}-cards.json`);
const cards = cubeCards(await genCube(db, articles));
let api = new CardMarketApi();
// Can only list 100 articles per request
let offset = 0;
while (offset < cards.length) {
const xml: string = await ejs.renderFile("templates/buy.ejs", {
cards: cards.slice(offset, offset + 100)
});
await api.put("/shoppingcart", null, xml);
offset += 100;
}
}
run();

View file

@ -1,126 +1,12 @@
import * as ejs from "ejs";
import { existsSync } from "fs"; import { existsSync } from "fs";
import { createServer } from "http"; import { createServer } from "http";
import { import { Article, asyncLoadJSON, MCMDB } from "../lib";
Article, import { cubeHTML, genCube } from "../lib/cube";
asyncLoadJSON,
CardItem,
dictMap,
filterDict,
leanArticle,
leanCard,
MCMDB,
onlyUnique,
spanBy,
} from "../lib";
const colorNames = {
CL: "Colorless",
MC: "Multicolor",
L: "Land",
W: "White",
U: "Blue",
B: "Black",
R: "Red",
G: "Green",
WU: "Azorius",
UB: "Dimir",
BR: "Rakdos",
RG: "Gruul",
WG: "Selesnya",
WB: "Orzhov",
UR: "Izzet",
BG: "Golgari",
WR: "Boros",
UG: "Simic",
WUG: "Bant",
WUB: "Esper",
UBR: "Grixis",
BRG: "Jund",
WRG: "Naya",
WBG: "Abzan",
WUR: "Jeskai",
UBG: "Sultai",
WBR: "Mardu",
URG: "Temur"
};
const columns: Record<string, (c: CardItem) => boolean> = {
W: c => c.types[0] != "Land" && colorid(c.colorIdentity) == "W",
U: c => c.types[0] != "Land" && colorid(c.colorIdentity) == "U",
B: c => c.types[0] != "Land" && colorid(c.colorIdentity) == "B",
R: c => c.types[0] != "Land" && colorid(c.colorIdentity) == "R",
G: c => c.types[0] != "Land" && colorid(c.colorIdentity) == "G",
MC: c => c.types[0] != "Land" && c.colorIdentity.length > 1,
CL: c => c.types[0] != "Land" && colorid(c.colorIdentity) == "CL",
L: c => c.types[0] == "Land"
};
function wubrg(a: string, b: string) {
const order = ["W", "U", "B", "R", "G"];
const indexA = order.indexOf(a);
const indexB = order.indexOf(b);
return indexA - indexB;
}
function colorid(colors: string[]): string {
if (colors.length < 1) {
return "CL";
}
return colors.sort(wubrg).join("");
}
function prettyColor(color: string) {
if (color in colorNames) {
return colorNames[color];
}
return color;
}
const allTypes = [
"Creature",
"Planeswalker",
"Instant",
"Sorcery",
"Artifact",
"Enchantment"
];
function typeSort(a: string, b: string): number {
const indexA = allTypes.indexOf(a);
const indexB = allTypes.indexOf(b);
return indexA - indexB;
}
function abcSort(a: string, b: string): number {
if (a > b) {
return 1;
}
if (b > a) {
return -1;
}
return 0;
}
// This would be properly typed but as of currently TS does not allow circular references for types
function deepSum(dict: any): number {
if (Array.isArray(dict)) {
return dict.length;
}
let total = 0;
for (let key in dict) {
if (Array.isArray(dict[key])) {
total += dict[key].length;
} else {
total += deepSum(dict[key]);
}
}
return total;
}
async function run() { async function run() {
if (process.argv.length < 3) { if (process.argv.length < 3) {
console.error("Usage: yarn fetch <uid>"); console.error("Usage: yarn cube-dev <uid>");
process.exit(1); process.exit(1);
return; return;
} }
@ -137,43 +23,10 @@ async function run() {
let db = await asyncLoadJSON<MCMDB>("mcmCards.json"); let db = await asyncLoadJSON<MCMDB>("mcmCards.json");
let articles = await asyncLoadJSON<Article[]>(`${uid}-cards.json`); let articles = await asyncLoadJSON<Article[]>(`${uid}-cards.json`);
let cards: CardItem[] = articles const cubecards = await genCube(db, articles);
.filter(art => art.idProduct in db)
.map(art => {
const card = db[art.idProduct];
return {
...leanArticle(art),
...leanCard(card)
};
});
let valid = cards
.filter(
c =>
(c.language == "Italian" || c.language == "English") &&
c.price <= 0.1 &&
(c.rarity == "rare" || c.rarity == "mythic")
)
.filter(onlyUnique);
// Filter by colors and cmc (nested)
let colorcards = filterDict(valid, columns);
let cmccards = dictMap(colorcards, (cards, col) =>
dictMap(
spanBy(cards, c =>
col == "MC" || col == "L" ? colorid(c.colorIdentity) : c.types[0]
),
typed => spanBy(typed, c => c.convertedManaCost)
)
);
const server = createServer(async (req, res) => { const server = createServer(async (req, res) => {
const template = await ejs.renderFile("templates/cube.ejs", { const template = await cubeHTML(uid, cubecards);
user: uid,
cards: valid,
cmccards,
columns,
utils: { wubrg, prettyColor, colorid, deepSum, typeSort, abcSort }
});
res.end(template); res.end(template);
}); });

View file

@ -1,64 +1,11 @@
import * as ejs from "ejs";
import { existsSync, writeFile } from "fs"; import { existsSync, writeFile } from "fs";
import { Article, asyncLoadJSON, CardItem, leanArticle, leanCard, MCMDB, onlyUnique } from "../lib"; import { Article, asyncLoadJSON, MCMDB } from "../lib";
import { cubeHTML, genCube } from "../lib/cube";
const colorNames = {
CL: "Colorless",
W: "White",
U: "Blue",
B: "Black",
R: "Red",
G: "Green",
WU: "Azorius",
UB: "Dimir",
BR: "Rakdos",
RG: "Gruul",
WG: "Selesnya",
WB: "Orzhov",
UR: "Izzet",
BG: "Golgari",
WR: "Boros",
UG: "Simic",
WUG: "Bant",
WUB: "Esper",
UBR: "Grixis",
BRG: "Jund",
WRG: "Naya",
WBG: "Abzan",
WUR: "Jeskai",
UBG: "Sultai",
WBR: "Mardu",
URG: "Temur"
};
const columns: Record<string, (c: CardItem) => boolean> = {
W: c => colorid(c.colorIdentity) == "W",
U: c => colorid(c.colorIdentity) == "U",
B: c => colorid(c.colorIdentity) == "B",
R: c => colorid(c.colorIdentity) == "R",
G: c => colorid(c.colorIdentity) == "G",
MC: c => c.colorIdentity.length > 0,
CL: c => colorid(c.colorIdentity) == "CL"
};
function wubrg(a: string, b: string) {
const order = ["W", "U", "B", "R", "G"];
const indexA = order.indexOf(a);
const indexB = order.indexOf(b);
return indexA - indexB;
}
function colorid(colors: string[]): string {
if (colors.length < 1) {
return "CL";
}
return colors.sort(wubrg).join("");
}
async function run() { async function run() {
if (process.argv.length < 3) { if (process.argv.length < 3) {
console.error("Usage: yarn fetch <uid>"); console.error("Usage: yarn cube <uid>");
process.exit(1); process.exit(1);
return; return;
} }
@ -75,31 +22,9 @@ async function run() {
let db = await asyncLoadJSON<MCMDB>("mcmCards.json"); let db = await asyncLoadJSON<MCMDB>("mcmCards.json");
let articles = await asyncLoadJSON<Article[]>(`${uid}-cards.json`); let articles = await asyncLoadJSON<Article[]>(`${uid}-cards.json`);
let cards: CardItem[] = articles const cubecards = await genCube(db, articles);
.filter(art => art.idProduct in db) const template = await cubeHTML(uid, cubecards);
.map(art => {
const card = db[art.idProduct];
return {
...leanArticle(art),
...leanCard(card)
};
});
let valid = cards
.filter(
c =>
(c.language == "Italian" || c.language == "English") &&
c.price <= 0.1 &&
(c.rarity == "rare" || c.rarity == "mythic")
)
.filter(onlyUnique);
const template = await ejs.renderFile("templates/cube.ejs", {
user: uid,
cards: valid,
columns,
utils: { wubrg, colorNames, colorid }
});
let cubeFile = `${uid}-cube.html`; let cubeFile = `${uid}-cube.html`;
writeFile(cubeFile, template, {}, err => { writeFile(cubeFile, template, {}, err => {
if (err) { if (err) {

54
cmd/pool.ts Normal file
View file

@ -0,0 +1,54 @@
import { existsSync } from "fs";
import { Article, asyncLoadJSON, CardItem, MCMDB } from "../lib";
import { cubeCards, genCube } from "../lib/cube";
async function run() {
if (process.argv.length < 3) {
console.error("Usage: yarn buy <uid>");
process.exit(1);
return;
}
const uid = process.argv[2];
const uidCards = `${uid}-cards.json`;
if (!existsSync("mcmCards.json")) {
console.error("Card db is missing! Run 'yarn convert-db' first.");
process.exit(1);
}
if (!existsSync(uidCards)) {
console.error(`Could not find ${uidCards}! Run 'yarn fetch ${uid}' first.`);
process.exit(1);
}
let db = await asyncLoadJSON<MCMDB>("mcmCards.json");
let articles = await asyncLoadJSON<Article[]>(`${uid}-cards.json`);
const cards = cubeCards(await genCube(db, articles));
let types: Record<string, CardItem[]> = {};
cards.forEach(c => {
c.subtypes.forEach(t => {
if (t in types) {
types[t].push(c);
} else {
types[t] = [c];
}
});
});
console.log("TRIBAL? Top 10 tribes:");
const entries = Object.entries(types)
.sort((a, b) => b[1].length - a[1].length)
.slice(0, 10)
.forEach(([typ, typcards]) => {
console.log(
` ${typ.padEnd(16, " ")}${typcards.length
.toString()
.padStart(3)} cards (${(
(typcards.length / cards.length) *
100
).toFixed(1)}%)`
);
});
}
run();

View file

@ -1,5 +1,5 @@
import { stringify } from "querystring"; import { stringify } from "querystring";
import { get } from "request"; import * as request from "request";
import { Article } from "./types"; import { Article } from "./types";
@ -18,28 +18,39 @@ export class CardMarketApi {
this.AccessSecret = process.env.ACCESS_SECRET; this.AccessSecret = process.env.ACCESS_SECRET;
} }
async get(path: string, params?: Record<string, any>): Promise<any> { async request(
const oauthParameters = { method: string,
path: string,
params: Record<string, any> | undefined,
options: any
): Promise<any> {
const oauth = {
realm: `${URI}${path}`, realm: `${URI}${path}`,
consumer_key: this.AppToken, consumer_key: this.AppToken,
consumer_secret: this.AppSecret, consumer_secret: this.AppSecret,
token: this.AccessToken, token: this.AccessToken,
token_secret: this.AccessSecret token_secret: this.AccessSecret
}; };
const uri = oauth.realm + (params ? `?${stringify(params)}` : "");
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
get( request(
oauthParameters.realm + (params ? `?${stringify(params)}` : ""),
{ {
oauth: oauthParameters method,
uri,
oauth,
...options
}, },
(error, response) => { (error, response) => {
if (error) { if (error) {
throw error; reject(error);
} else if (response.statusCode > 299) { } else if (response.statusCode > 299) {
throw JSON.stringify({ reject(
JSON.stringify({
statusCode: response.statusCode, statusCode: response.statusCode,
statusMessage: response.statusMessage statusMessage: response.statusMessage,
}); statusBody: response.body
})
);
} }
try { try {
resolve(JSON.parse(response.body)); resolve(JSON.parse(response.body));
@ -51,6 +62,27 @@ export class CardMarketApi {
}); });
} }
async get(path: string, params?: Record<string, any>): Promise<any> {
return await this.request("GET", path, params, {});
}
async put(
path: string,
params?: Record<string, any>,
data?: string
): Promise<any> {
return await this.request(
"PUT",
path,
params,
data
? {
body: data
}
: {}
);
}
async getAllArticles(uid: string): Promise<Article[]> { async getAllArticles(uid: string): Promise<Article[]> {
const perPage = 1000; const perPage = 1000;
let start = 0; let start = 0;

189
lib/cube.ts Normal file
View file

@ -0,0 +1,189 @@
import * as ejs from "ejs";
import { Article, CardItem, dictMap, filterDict, leanArticle, leanCard, MCMDB, onlyUnique, spanBy } from ".";
const colorNames = {
CL: "Colorless",
MC: "Multicolor",
L: "Land",
W: "White",
U: "Blue",
B: "Black",
R: "Red",
G: "Green",
WU: "Azorius",
UB: "Dimir",
BR: "Rakdos",
RG: "Gruul",
WG: "Selesnya",
WB: "Orzhov",
UR: "Izzet",
BG: "Golgari",
WR: "Boros",
UG: "Simic",
WUG: "Bant",
WUB: "Esper",
UBR: "Grixis",
BRG: "Jund",
WRG: "Naya",
WBG: "Abzan",
WUR: "Jeskai",
UBG: "Sultai",
WBR: "Mardu",
URG: "Temur"
};
const columns: Record<string, (c: CardItem) => boolean> = {
W: c => colorid(c.colorIdentity) == "W",
U: c => colorid(c.colorIdentity) == "U",
B: c => colorid(c.colorIdentity) == "B",
R: c => colorid(c.colorIdentity) == "R",
G: c => colorid(c.colorIdentity) == "G",
MC: c => c.colorIdentity.length > 1,
CL: c => colorid(c.colorIdentity) == "CL"
};
function wubrg(a: string, b: string) {
const order = ["W", "U", "B", "R", "G"];
const indexA = order.indexOf(a);
const indexB = order.indexOf(b);
return indexA - indexB;
}
function colorid(colors: string[]): string {
if (colors.length < 1) {
return "CL";
}
return colors.sort(wubrg).join("");
}
function prettyColor(color: string) {
if (color in colorNames) {
return colorNames[color];
}
return color;
}
const allTypes = [
"Creature",
"Planeswalker",
"Instant",
"Sorcery",
"Artifact",
"Enchantment"
];
function typeSort(a: string, b: string): number {
const indexA = allTypes.indexOf(a);
const indexB = allTypes.indexOf(b);
return indexA - indexB;
}
function abcSort(a: string, b: string): number {
if (a > b) {
return 1;
}
if (b > a) {
return -1;
}
return 0;
}
// This would be properly typed but as of currently TS does not allow circular references for types
function deepSum(dict: any): number {
if (Array.isArray(dict)) {
return dict.length;
}
let total = 0;
for (let key in dict) {
if (Array.isArray(dict[key])) {
total += dict[key].length;
} else {
total += deepSum(dict[key]);
}
}
return total;
}
export function cubeCards(c: CubeCards): CardItem[] {
let cards: CardItem[] = [];
dictMap(c, bytyp =>
dictMap(bytyp, bycmc =>
dictMap(bycmc, cardlist => (cards = cards.concat(cardlist)))
)
);
return cards;
}
function byRank(a: CardItem, b: CardItem): number {
return a.edhrecRank - b.edhrecRank;
}
export type ByCMC<T> = Record<number, T>;
export type ByType<T> = Record<string, T>;
export type ByColor<T> = Record<string, T>;
export type CubeCards = ByColor<ByType<ByCMC<CardItem[]>>>;
export async function genCube(
db: MCMDB,
articles: Article[]
): Promise<CubeCards> {
const cards: CardItem[] = articles
.filter(art => art.idProduct in db)
.map(art => {
const card = db[art.idProduct];
return {
...leanArticle(art),
...leanCard(card)
};
});
const valid = cards
.filter(
c =>
(c.language == "Italian" || c.language == "English") &&
c.price < 0.2 &&
(c.rarity == "rare" || c.rarity == "mythic")
)
.filter(onlyUnique);
const categorized = filterDict(valid, columns);
// Cut cards
const filtered = dictMap(categorized, (pool, col) => {
switch (col) {
case "MC":
const colors = spanBy(pool, c => colorid(c.colorIdentity));
let cards = [];
for (const color in colors) {
cards = cards.concat(colors[color].sort(byRank).slice(0, 3));
}
return cards;
case "CL":
return pool.sort(byRank).slice(0, 30);
default:
return pool.sort(byRank).slice(0, 58);
}
});
// Filter by colors and cmc (nested)
return dictMap(filtered, (cards, col) =>
dictMap(
spanBy(cards, c =>
col == "MC" || col == "L"
? colorid(c.colorIdentity)
: c.types.slice(-1)[0]
),
typed => spanBy(typed, c => c.convertedManaCost)
)
);
}
export async function cubeHTML(user, cubecards: CubeCards) {
return await ejs.renderFile("templates/cube.ejs", {
user,
cards: cubeCards(cubecards),
cubecards,
columns,
utils: { wubrg, prettyColor, colorid, deepSum, typeSort, abcSort }
});
}

View file

@ -10,7 +10,9 @@
"build-db": "ts-node cmd/convert.ts", "build-db": "ts-node cmd/convert.ts",
"find": "ts-node cmd/find.ts", "find": "ts-node cmd/find.ts",
"cube": "ts-node cmd/cube.ts", "cube": "ts-node cmd/cube.ts",
"cube-watch": "ts-node cmd/cube-dev.ts" "cube-watch": "ts-node cmd/cube-dev.ts",
"buy": "ts-node cmd/buy.ts",
"pool-info": "ts-node cmd/pool.ts"
}, },
"dependencies": { "dependencies": {
"@types/ejs": "^2.6.3", "@types/ejs": "^2.6.3",

10
templates/buy.ejs Normal file
View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8" ?>
<request>
<action>add</action>
<% cards.forEach(card => { %>
<article>
<idArticle><%= card.idArticle %></idArticle>
<amount>1</amount>
</article>
<% }); %>
</request>

View file

@ -2,6 +2,7 @@
<html> <html>
<head> <head>
<meta charset="utf-8" />
<title>Junk rare cube</title> <title>Junk rare cube</title>
<style> <style>
body { body {
@ -135,6 +136,11 @@
width: auto; width: auto;
height: 300px; height: 300px;
} }
small {
color: #aaa;
display: block;
}
</style> </style>
</head> </head>
@ -144,9 +150,10 @@
<h1>Junk rare cube (<%=cards.length%> cards)</h1> <h1>Junk rare cube (<%=cards.length%> cards)</h1>
<h2>Using cards from <a href="https://www.cardmarket.com/en/Magic/Users/<%=user%>"><b><%= user %></b></a> <h2>Using cards from <a href="https://www.cardmarket.com/en/Magic/Users/<%=user%>"><b><%= user %></b></a>
</h2> </h2>
<h2>Est. price without shipping: € <%= cards.reduce((a, c) => a + c.price, 0).toFixed(2) %></h2>
</header> </header>
<section class="columns"> <section class="columns">
<% Object.entries(cmccards).forEach(([color, colorcards]) => { %> <% Object.entries(cubecards).forEach(([color, colorcards]) => { %>
<section class="color-section color-<%=color%>"> <section class="color-section color-<%=color%>">
<header class="color-header"> <header class="color-header">
<h3><%=utils.prettyColor(color)%> (<%=utils.deepSum(colorcards)%>)</h3> <h3><%=utils.prettyColor(color)%> (<%=utils.deepSum(colorcards)%>)</h3>
@ -163,7 +170,7 @@
<li data-set="<%=card.set%>" data-num="<%=card.number%>"> <li data-set="<%=card.set%>" data-num="<%=card.number%>">
<a target="_blank" data-image="<%=card.scryfallImageUrl%>" <a target="_blank" data-image="<%=card.scryfallImageUrl%>"
href="<%= card.scryfallUrl %>"> href="<%= card.scryfallUrl %>">
<%= card.name %> <%= card.name %><small>€ <%=card.price.toFixed(2)%> - #<%=card.edhrecRank%></small>
</a> </a>
</li> </li>
<% }); %> <% }); %>