Public
Edited
Oct 7, 2024
Insert cell
Insert cell
Insert cell
search
Insert cell
searchResults = getMods({ text: search })
Insert cell
JSON.stringify(searchResults.data[0])
Insert cell
searchResults.data.map(({ title, preview_image }) => ({ title, preview_image }))
Insert cell
searchResults.data[0].preview_image
Insert cell
htl.html`<img src="https://ugcmods.bethesda.net/image/eyJidWNrZXQiOiJ1Z2Ntb2RzLmJldGhlc2RhLm5ldCIsImtleSI6InB1YmxpYy9jb250ZW50L1NLWVJJTS81ODE0L0NMQVNTSUZJQ0FUSU9OX1BSRVZJRVdfSU1BR0UvVzNQUGNPS3BEWXJ0XzUxdG9Nd2h0QT09LzgzODQ0MzcwMzQ4NTU5NzM3MzYucG5nIiwiZWRpdHMiOnsicmVzaXplIjp7IndpZHRoIjoyMjh9fSwib3V0cHV0Rm9ybWF0Ijoid2VicCJ9">`
Insert cell
decodedModUrl = {
const url =
"https://ugcmods.bethesda.net/image/eyJidWNrZXQiOiJ1Z2Ntb2RzLmJldGhlc2RhLm5ldCIsImtleSI6InB1YmxpYy9jb250ZW50L1NLWVJJTS81ODE0L0NMQVNTSUZJQ0FUSU9OX1BSRVZJRVdfSU1BR0UvVzNQUGNPS3BEWXJ0XzUxdG9Nd2h0QT09LzgzODQ0MzcwMzQ4NTU5NzM3MzYucG5nIiwgImVkaXRzIjp7InJlc2l6ZSI6eyJ3aWR0aCI6MjI4fX0sICJvdXRwdXRGb3JtYXQiOiJ3ZWJwIn0=";

// Extract the base64 string and decode it
const base64String = url.match(
/https:\/\/ugcmods\.bethesda\.net\/image\/([^?]+)/
)[1];
const decodedJson = atob(base64String);

// Parse and display the JSON object
return decodedJson;
}
Insert cell
Insert cell
createModCard = (mod) => {
const hasPreviewImage =
mod.preview_image && mod.preview_image.s3bucket && mod.preview_image.s3key;

const imageUrl = hasPreviewImage
? `https://ugcmods.bethesda.net/image/${btoa(
JSON.stringify({
bucket: mod.preview_image.s3bucket,
key: mod.preview_image.s3key,
edits: { resize: { width: 120, height: 90 } },
outputFormat: "webp"
})
)}`
: defaultImageUrl;

return htl.html`
<article class="card col">
<header class="grid" style="align-items: center;">
<figure class="col-3" style="margin: 0;">
<img src="${imageUrl}" alt="${
mod.title
}" style="width: 100%; height: auto; border-radius: 4px;">
</figure>
<div class="col-9">
<h4>${mod.title}</h4>
<p style="margin: 0;"><strong>Author:</strong> ${
mod.author_displayname || "Unknown"
}</p>
<p style="margin: 0;"><strong>Downloads:</strong> ${
mod.stats?.totals?.downloads || 0
}</p>
</div>
</header>
<footer style="margin-top: 10px;">
<details>
<summary>Description</summary>
<p>${md`${mod.description || "No description available."}`}</p>
</details>
</footer>
</article>`;
}
Insert cell
displayModCards = (mods) => htl.html`
<style>
.mod-container {
display: flex;
flex-wrap: wrap;
justify-content: space-evenly;
gap: 20px;
}

.mod-card {
flex: 1 1 300px;
max-width: 350px; /* Adjust max width to ensure consistency */
border: 1px solid #ddd;
border-radius: 8px;
padding: 10px;
box-shadow: 0px 4px 12px rgba(0,0,0,0.1); /* Subtle shadow */
}

/* Ensure uniformity in card height */
.mod-card .card-content {
min-height: 180px;
display: flex;
flex-direction: column;
justify-content: space-between;
}

.mod-card header {
display: flex;
align-items: flex-start;
}

.mod-card header img {
width: 60px;
height: 60px;
margin-right: 10px;
object-fit: cover;
border-radius: 4px;
}

.mod-card h4 {
margin: 0 0 5px 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis; /* Prevent long titles from overflowing */
}

.mod-card footer {
width: 100%;
}

details {
width: 100%;
}
</style>

<div class="mod-container">
${mods.map(
(mod) => htl.html`
<article class="card mod-card">
<div class="card-content">
${createModCard(mod)}
</div>
</article>`
)}
</div>
`
Insert cell
displayModCards(searchResults.data)
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
handleUserSession = (router) => {
router.post("/login", async ({ request, response }) => {});
router.post("/logout", async ({ request, response }) => {});
}
Insert cell
serveMods = (router) => {
const baseUrl = "https://api.bethesda.net/ugcmods/v2/content";
router.get("/mods/:id?", async ({ params: { id }, request, response }) => {
const url = `${baseUrl}/${id || ""}${new URL(request.url).search}`;
const apiResponse = await fetch(url, {
headers: {
"User-Agent": userAgent,
"x-bnet-key": getBNetKey(),
// prevents filtering of some content
"accept-language": "en,en-US;q=0.9,ru;q=0.8"
}
});
response.headers.set(
"Content-Type",
apiResponse.headers.get("Content-Type") || "application/json"
);
response.body = apiResponse.body;
});
}
Insert cell
Insert cell
Insert cell
window.location.origin
Insert cell
document.referrer
Insert cell
clientInIframe = window !== window.parent
Insert cell
clientIsObservableHQ = {
try {
// Check if we are in the top frame or have access to the top frame's hostname.
return (
window.top.location.hostname === "observablehq.com" &&
!window.top.location.pathname.startsWith("/embed/")
);
} catch (e) {
// If we can't access window.top due to cross-origin issues, fallback to current window or referrer check.
const referrerDomain = document.referrer
? new URL(document.referrer).hostname
: "";
const referrerPath = document.referrer
? new URL(document.referrer).pathname
: "";
return (
(window.location.hostname === "observablehq.com" &&
!window.location.pathname.startsWith("/embed/")) ||
(referrerDomain === "observablehq.com" &&
!referrerPath.startsWith("/embed/"))
);
}
}
Insert cell
Insert cell
getMods = async (args = {}) => {
// TODO if "text" is given then don't provide sort or time period options
const options = Object.assign(
{
size: 20,
//sort: "rating",
//time_period: "monthly",
product: "SKYRIM",
hardware_platforms: "XBOXONE,XBOXSERIESX"
},
args
);
// https://api.bethesda.net/ugcmods/v2/content?product=SKYRIM&sort=rating&time_period=monthly&size=20&hardware_platforms=XBOXONE%2CXBOXSERIESX
const result = await fetchJson(
urls[0] + "/mods?" + new URLSearchParams(options)
);
return result?.json?.platform?.response || { data: [] };
}
Insert cell
// it appears 149 is the max size allowed
mods = (await getMods({ size: 149, text: "ussep" })).data
Insert cell
mods
Type Table, then Shift-Enter. Ctrl-space for more options.

Insert cell
mod = {
const result = await fetchJson(`${urls[0]}/mods/${mods[0].content_id}`);
return result?.json?.platform?.response || {};
}
Insert cell
// it appears 100 is the max batch size allowed
batchIds = mods.slice(0, 100).map((mod) => mod.content_id)
Insert cell
batch = {
const params = new URLSearchParams({
content_ids: batchIds.join(","),
product: "SKYRIM",
size: batchIds.length
});
const result = await fetchJson(`${urls[0]}/mods?${params}`);
return result?.json?.platform?.response;
}
Insert cell
Swapy = require("https://unpkg.com/swapy/dist/swapy.min.js")
Insert cell
order.container
Insert cell
order = {
const container = html`
<div class="container">
<div class="section-1" data-swapy-slot="foo">
<div class="content-a" data-swapy-item="a">
<p>Item 1</p>
</div>
</div>

<div class="section-2" data-swapy-slot="bar">
<div class="content-b" data-swapy-item="b">
<p>Item 2</p>
</div>
</div>

<div class="section-3" data-swapy-slot="baz">
<div class="content-c" data-swapy-item="c">
<p>Item 3</p>
</div>
</div>
</div>`;
const swapy = Swapy.createSwapy(container);
return { swapy, container };
}
Insert cell
async function fetchJson(url, { method = "GET", ...options } = {}) {
const start = new Date();
let status = -1;
try {
const response = await fetch(url, { method, ...options });
status = response.status;
const json = await response.json();
return { status, json };
} catch (error) {
return { status, error };
} finally {
console.log(`[${status}] (${new Date() - start}ms) ${method} ${url}`);
}
}
Insert cell
Insert cell
ids = mods.map((mod) => mod.content_id)
Insert cell
{
const data = [
ids.toString(),
$encode.encodeIds(ids),
$encode.decodeIds($encode.encodeIds(ids)).toString(),
ids.slice(0, 100).toString(),
$encode.encodeIds(ids.slice(0, 100)),
$encode.decodeIds($encode.encodeIds(ids.slice(0, 100))).toString()
];
data.push(data.map((s) => s.length));
data.push(data[0] === data[2]);
return data;
}
Insert cell
md`${$encode.encodeIds(mods.map((m) => m.content_id))}`
Insert cell

One platform to build and deploy the best data apps

Experiment and prototype by building visualizations in live JavaScript notebooks. Collaborate with your team and decide which concepts to build out.
Use Observable Framework to build data apps locally. Use data loaders to build in any language or library, including Python, SQL, and R.
Seamlessly deploy to Observable. Test before you ship, use automatic deploy-on-commit, and ensure your projects are always up-to-date.
Learn more