generated from nhcarrigan/template
feat: add scripts for managing discourse
This commit is contained in:
@@ -20,3 +20,8 @@ GITEA_TOKEN="op://Private/Gitea/token"
|
|||||||
|
|
||||||
# DefectDojo
|
# DefectDojo
|
||||||
DOJO_TOKEN="op://Private/DefectDojo/token"
|
DOJO_TOKEN="op://Private/DefectDojo/token"
|
||||||
|
|
||||||
|
# Discourse
|
||||||
|
DISCOURSE_URL="op://Environment Variables - Development/Ephemere/Discourse URL"
|
||||||
|
DISCOURSE_API_KEY="op://Environment Variables - Development/Ephemere/Discourse Key"
|
||||||
|
DISCOURSE_API_USERNAME="op://Environment Variables - Development/Ephemere/Discourse Username"
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
/**
|
||||||
|
* @copyright NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { backoffAndRetry } from "../utils/backoffAndRetry.js";
|
||||||
|
import type { CategoryGet, CategoryList } from "../interfaces/discourse.js";
|
||||||
|
|
||||||
|
const discourseUrl = process.env.DISCOURSE_URL;
|
||||||
|
const discourseApiKey = process.env.DISCOURSE_API_KEY;
|
||||||
|
const discourseApiUsername = process.env.DISCOURSE_API_USERNAME;
|
||||||
|
|
||||||
|
if (
|
||||||
|
discourseUrl === undefined
|
||||||
|
|| discourseApiKey === undefined
|
||||||
|
|| discourseApiUsername === undefined
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"DISCOURSE_URL, DISCOURSE_API_KEY, or DISCOURSE_API_USERNAME is not set",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryResponse = await backoffAndRetry<CategoryList>(
|
||||||
|
`${discourseUrl}/categories.json`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required header format.
|
||||||
|
"Api-Key": discourseApiKey,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required header format.
|
||||||
|
"Api-Username": discourseApiUsername,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (categoryResponse === null) {
|
||||||
|
throw new Error("Failed to get categories");
|
||||||
|
}
|
||||||
|
const categories = categoryResponse;
|
||||||
|
|
||||||
|
// Collect all categories including subcategories
|
||||||
|
const allCategories: Array<{ id: number; name: string }> = [];
|
||||||
|
const processedIds = new Set<number>();
|
||||||
|
|
||||||
|
// Process top-level categories and their subcategories
|
||||||
|
for (const category of categories.category_list.categories) {
|
||||||
|
// Add top-level category if not already processed
|
||||||
|
if (!processedIds.has(category.id)) {
|
||||||
|
allCategories.push({ id: category.id, name: category.name });
|
||||||
|
processedIds.add(category.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(category.subcategory_ids)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add subcategories if they exist
|
||||||
|
for (const subcategoryId of category.subcategory_ids) {
|
||||||
|
if (!processedIds.has(subcategoryId)) {
|
||||||
|
const subcategoryRequest = await backoffAndRetry<CategoryGet>(
|
||||||
|
`${discourseUrl}/c/${subcategoryId.toString()}/show.json`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required header format.
|
||||||
|
"Api-Key": discourseApiKey,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required header format.
|
||||||
|
"Api-Username": discourseApiUsername,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (subcategoryRequest === null) {
|
||||||
|
console.error(`Failed to get subcategory ${subcategoryId.toString()}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (subcategoryRequest.category?.name === undefined) {
|
||||||
|
console.error(`Failed to get subcategory ${subcategoryId.toString()}`);
|
||||||
|
console.log(Object.keys(subcategoryRequest));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
allCategories.push({
|
||||||
|
id: subcategoryId,
|
||||||
|
name: subcategoryRequest.category.name,
|
||||||
|
});
|
||||||
|
processedIds.add(subcategoryId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update all categories (including subcategories)
|
||||||
|
for (const category of allCategories) {
|
||||||
|
const { id, name } = category;
|
||||||
|
console.log(`Updating category ${id.toString()}: ${name}`);
|
||||||
|
const updateRequest = await backoffAndRetry<CategoryGet>(
|
||||||
|
`${discourseUrl}/categories/${id.toString()}`,
|
||||||
|
{
|
||||||
|
body: JSON.stringify({
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required body format.
|
||||||
|
auto_close_based_on_last_post: true,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required body format.
|
||||||
|
auto_close_hours: 672,
|
||||||
|
name: name,
|
||||||
|
}),
|
||||||
|
headers: {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required header format.
|
||||||
|
"Api-Key": discourseApiKey,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required header format.
|
||||||
|
"Api-Username": discourseApiUsername,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required header format.
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
method: "PUT",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (updateRequest === null) {
|
||||||
|
console.error(`Failed to update category ${id.toString()}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (updateRequest.category?.auto_close_hours === undefined) {
|
||||||
|
console.error(`Failed to update category ${id.toString()}`);
|
||||||
|
console.error(JSON.stringify(updateRequest, null, 2));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const { auto_close_hours: returnedAutoCloseHours } = updateRequest.category;
|
||||||
|
console.log(
|
||||||
|
`Updated category ${id.toString()}: ${name} to auto_close after ${returnedAutoCloseHours.toString()} hours`,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
/**
|
||||||
|
* @copyright NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { backoffAndRetry } from "../utils/backoffAndRetry.js";
|
||||||
|
import { sleep } from "../utils/sleep.js";
|
||||||
|
import type { Topic, TopicList } from "../interfaces/discourse.js";
|
||||||
|
|
||||||
|
const discourseUrl = process.env.DISCOURSE_URL;
|
||||||
|
const discourseApiKey = process.env.DISCOURSE_API_KEY;
|
||||||
|
const discourseApiUsername = process.env.DISCOURSE_API_USERNAME;
|
||||||
|
|
||||||
|
if (
|
||||||
|
discourseUrl === undefined
|
||||||
|
|| discourseApiKey === undefined
|
||||||
|
|| discourseApiUsername === undefined
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"DISCOURSE_URL, DISCOURSE_API_KEY, or DISCOURSE_API_USERNAME is not set",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiHeaders = {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required header format.
|
||||||
|
"Api-Key": discourseApiKey,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required header format.
|
||||||
|
"Api-Username": discourseApiUsername,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches a single page of topics from Discourse.
|
||||||
|
* @param page - The page number to fetch.
|
||||||
|
* @returns The topics from that page, or null if the request failed.
|
||||||
|
*/
|
||||||
|
const fetchTopicsPage = async(page: number): Promise<Array<Topic> | null> => {
|
||||||
|
const url = `${discourseUrl}/latest.json?page=${page.toString()}`;
|
||||||
|
console.log(`Fetching topics page ${page.toString()}...`);
|
||||||
|
|
||||||
|
const response = await backoffAndRetry<TopicList>(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
headers: apiHeaders,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response === null) {
|
||||||
|
console.error(`Failed to fetch page ${page.toString()}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { topic_list: topicList } = response;
|
||||||
|
const { topics } = topicList;
|
||||||
|
console.log(
|
||||||
|
`Page ${page.toString()}: Received ${topics.length.toString()} topics`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return topics;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the last activity date for a topic.
|
||||||
|
* @param topic - The topic to get the activity date for.
|
||||||
|
* @returns The date when the topic was last active.
|
||||||
|
*/
|
||||||
|
const getLastActivityDate = (topic: Topic): Date => {
|
||||||
|
if (topic.last_posted_at !== null) {
|
||||||
|
return new Date(topic.last_posted_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (topic.bumped_at !== null) {
|
||||||
|
return new Date(topic.bumped_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(topic.created_at);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches open topics from Discourse until we hit topics older than 6 months.
|
||||||
|
* The /latest.json endpoint returns topics in a nested structure, so we need
|
||||||
|
* to handle it differently than standard paginated endpoints.
|
||||||
|
* @param sixMonthsAgo - The date cutoff for stopping pagination (6 months ago).
|
||||||
|
* @returns Open topics that are newer than 6 months old.
|
||||||
|
*/
|
||||||
|
const fetchAllOpenTopics = async(sixMonthsAgo: Date): Promise<Array<Topic>> => {
|
||||||
|
const allTopics: Array<Topic> = [];
|
||||||
|
let page = 0;
|
||||||
|
let topics: Array<Topic> | null = await fetchTopicsPage(page);
|
||||||
|
|
||||||
|
while (topics !== null && topics.length > 0) {
|
||||||
|
let foundOldTopic = false;
|
||||||
|
|
||||||
|
for (const topic of topics) {
|
||||||
|
// Skip closed topics
|
||||||
|
if (topic.closed) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastActivityDate = getLastActivityDate(topic);
|
||||||
|
|
||||||
|
// If we hit a topic older than 6 months, stop fetching
|
||||||
|
if (lastActivityDate < sixMonthsAgo) {
|
||||||
|
foundOldTopic = true;
|
||||||
|
console.log(
|
||||||
|
`Found topic older than 6 months (${lastActivityDate.toISOString()}), stopping pagination.`,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
allTopics.push(topic);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (foundOldTopic) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Continue to next page
|
||||||
|
page = page + 1;
|
||||||
|
topics = await fetchTopicsPage(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Total open topics fetched: ${allTopics.length.toString()}`);
|
||||||
|
return allTopics;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes a topic by ID.
|
||||||
|
* @param topicId - The ID of the topic to close.
|
||||||
|
* @param topicTitle - The title of the topic (for logging).
|
||||||
|
* @returns Whether the topic was successfully closed.
|
||||||
|
*/
|
||||||
|
const closeTopic = async(
|
||||||
|
topicId: number,
|
||||||
|
topicTitle: string,
|
||||||
|
): Promise<boolean> => {
|
||||||
|
const url = `${discourseUrl}/t/${topicId.toString()}/status`;
|
||||||
|
console.log(`Closing topic ${topicId.toString()}: ${topicTitle}`);
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
body: JSON.stringify({
|
||||||
|
enabled: true,
|
||||||
|
status: "closed",
|
||||||
|
}),
|
||||||
|
headers: {
|
||||||
|
...apiHeaders,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required header format.
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
method: "PUT",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 429) {
|
||||||
|
console.log("Rate limited, waiting 5 seconds...");
|
||||||
|
await sleep(5000);
|
||||||
|
return await closeTopic(topicId, topicTitle);
|
||||||
|
}
|
||||||
|
console.error(
|
||||||
|
`Failed to close topic ${topicId.toString()}: ${response.status.toString()} ${response.statusText}`,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Successfully closed topic ${topicId.toString()}: ${topicTitle}`);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Main execution
|
||||||
|
const daysInactive = 28;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approximately 6 months in days.
|
||||||
|
*/
|
||||||
|
const daysSixMonths = 180;
|
||||||
|
|
||||||
|
const cutoffDate = new Date();
|
||||||
|
cutoffDate.setDate(cutoffDate.getDate() - daysInactive);
|
||||||
|
|
||||||
|
const sixMonthsAgo = new Date();
|
||||||
|
sixMonthsAgo.setDate(sixMonthsAgo.getDate() - daysSixMonths);
|
||||||
|
|
||||||
|
console.log(`Fetching open topics (stopping at topics older than 6 months)...`);
|
||||||
|
console.log(
|
||||||
|
`Will close topics inactive for ${daysInactive.toString()}+ days but less than ${daysSixMonths.toString()} days old`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`Cutoff date: ${cutoffDate.toISOString()} (topics inactive for ${daysInactive.toString()} days)`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`Stop fetching at: ${sixMonthsAgo.toISOString()} (6 months ago)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const openTopics = await fetchAllOpenTopics(sixMonthsAgo);
|
||||||
|
|
||||||
|
console.log(`\nChecking ${openTopics.length.toString()} open topics for inactivity...`);
|
||||||
|
|
||||||
|
const topicsToClose: Array<Topic> = [];
|
||||||
|
|
||||||
|
for (const topic of openTopics) {
|
||||||
|
const lastActivityDate = getLastActivityDate(topic);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only close topics that are inactive for 28+ days but less than 6 months old.
|
||||||
|
*/
|
||||||
|
if (lastActivityDate < cutoffDate && lastActivityDate >= sixMonthsAgo) {
|
||||||
|
topicsToClose.push(topic);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Found ${topicsToClose.length.toString()} topics to close\n`);
|
||||||
|
|
||||||
|
if (topicsToClose.length === 0) {
|
||||||
|
console.log("No topics need to be closed.");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close topics with rate limiting
|
||||||
|
let closedCount = 0;
|
||||||
|
let failedCount = 0;
|
||||||
|
|
||||||
|
for (const topic of topicsToClose) {
|
||||||
|
const success = await closeTopic(topic.id, topic.title);
|
||||||
|
if (success) {
|
||||||
|
closedCount = closedCount + 1;
|
||||||
|
} else {
|
||||||
|
failedCount = failedCount + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a small delay between requests to respect rate limits.
|
||||||
|
* (backoffAndRetry handles 429s, but we want to avoid hitting them).
|
||||||
|
*/
|
||||||
|
await sleep(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nCompleted:`);
|
||||||
|
console.log(` Closed: ${closedCount.toString()}`);
|
||||||
|
console.log(` Failed: ${failedCount.toString()}`);
|
||||||
|
console.log(` Total: ${topicsToClose.length.toString()}`);
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/naming-convention -- The names of the properties match the API responses. */
|
||||||
|
/**
|
||||||
|
* @copyright NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface CategoryList {
|
||||||
|
category_list: {
|
||||||
|
can_create_category: boolean;
|
||||||
|
can_create_topic: boolean;
|
||||||
|
categories: Array<{
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
text_color: string;
|
||||||
|
style_type: string;
|
||||||
|
emoji: string;
|
||||||
|
icon: string;
|
||||||
|
slug: string;
|
||||||
|
topic_count: number;
|
||||||
|
post_count: number;
|
||||||
|
position: number;
|
||||||
|
description: string;
|
||||||
|
description_text: string;
|
||||||
|
description_excerpt: string;
|
||||||
|
topic_url: string;
|
||||||
|
read_restricted: boolean;
|
||||||
|
permission: number;
|
||||||
|
notification_level: number;
|
||||||
|
can_edit: boolean;
|
||||||
|
topic_template: string;
|
||||||
|
has_children: boolean;
|
||||||
|
subcategory_count: number;
|
||||||
|
sort_order: string;
|
||||||
|
sort_ascending: string;
|
||||||
|
show_subcategory_list: boolean;
|
||||||
|
num_featured_topics: number;
|
||||||
|
default_view: string;
|
||||||
|
subcategory_list_style: string;
|
||||||
|
default_top_period: string;
|
||||||
|
default_list_filter: string;
|
||||||
|
minimum_required_tags: number;
|
||||||
|
navigate_to_first_post_after_read: boolean;
|
||||||
|
topics_day: number;
|
||||||
|
topics_week: number;
|
||||||
|
topics_month: number;
|
||||||
|
topics_year: number;
|
||||||
|
topics_all_time: number;
|
||||||
|
is_uncategorized: boolean;
|
||||||
|
subcategory_ids: Array<number>;
|
||||||
|
subcategory_list: Array<{
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}>;
|
||||||
|
uploaded_logo: string;
|
||||||
|
uploaded_logo_dark: string;
|
||||||
|
uploaded_background: string;
|
||||||
|
uploaded_background_dark: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CategoryGet {
|
||||||
|
category?: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
text_color: string;
|
||||||
|
style_type: string;
|
||||||
|
emoji: string;
|
||||||
|
icon: string;
|
||||||
|
slug: string;
|
||||||
|
topic_count: number;
|
||||||
|
post_count: number;
|
||||||
|
position: number;
|
||||||
|
description: string;
|
||||||
|
description_text: string;
|
||||||
|
description_excerpt: string;
|
||||||
|
topic_url: string;
|
||||||
|
read_restricted: boolean;
|
||||||
|
permission: number;
|
||||||
|
notification_level: number;
|
||||||
|
can_edit: boolean;
|
||||||
|
topic_template: string;
|
||||||
|
form_template_ids: Array<unknown>;
|
||||||
|
has_children: boolean;
|
||||||
|
subcategory_count: number;
|
||||||
|
sort_order: string;
|
||||||
|
sort_ascending: string;
|
||||||
|
show_subcategory_list: boolean;
|
||||||
|
num_featured_topics: number;
|
||||||
|
default_view: string;
|
||||||
|
subcategory_list_style: string;
|
||||||
|
default_top_period: string;
|
||||||
|
default_list_filter: string;
|
||||||
|
minimum_required_tags: number;
|
||||||
|
navigate_to_first_post_after_read: boolean;
|
||||||
|
custom_fields: Record<string, unknown>;
|
||||||
|
allowed_tags: Array<unknown>;
|
||||||
|
allowed_tag_groups: Array<unknown>;
|
||||||
|
allow_global_tags: boolean;
|
||||||
|
required_tag_groups: Array<{
|
||||||
|
name: string;
|
||||||
|
min_count: number;
|
||||||
|
}>;
|
||||||
|
category_setting: {
|
||||||
|
auto_bump_cooldown_days: number;
|
||||||
|
num_auto_bump_daily: number;
|
||||||
|
require_reply_approval: boolean;
|
||||||
|
require_topic_approval: boolean;
|
||||||
|
};
|
||||||
|
category_localizations: Array<unknown>;
|
||||||
|
read_only_banner: string;
|
||||||
|
available_groups: Array<unknown>;
|
||||||
|
auto_close_hours: string;
|
||||||
|
auto_close_based_on_last_post: boolean;
|
||||||
|
allow_unlimited_owner_edits_on_first_post: boolean;
|
||||||
|
default_slow_mode_seconds: string;
|
||||||
|
group_permissions: Array<{
|
||||||
|
permission_type: number;
|
||||||
|
group_name: string;
|
||||||
|
group_id: number;
|
||||||
|
}>;
|
||||||
|
email_in: string;
|
||||||
|
email_in_allow_strangers: boolean;
|
||||||
|
mailinglist_mirror: boolean;
|
||||||
|
all_topics_wiki: boolean;
|
||||||
|
can_delete: boolean;
|
||||||
|
allow_badges: boolean;
|
||||||
|
topic_featured_link_allowed: boolean;
|
||||||
|
search_priority: number;
|
||||||
|
uploaded_logo: string;
|
||||||
|
uploaded_logo_dark: string;
|
||||||
|
uploaded_background: string;
|
||||||
|
uploaded_background_dark: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Topic {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
fancy_title: string;
|
||||||
|
slug: string;
|
||||||
|
posts_count: number;
|
||||||
|
reply_count: number;
|
||||||
|
highest_post_number: number;
|
||||||
|
image_url: string | null;
|
||||||
|
created_at: string;
|
||||||
|
last_posted_at: string | null;
|
||||||
|
bumped: boolean;
|
||||||
|
bumped_at: string | null;
|
||||||
|
archetype: string;
|
||||||
|
unseen: boolean;
|
||||||
|
pinned: boolean;
|
||||||
|
unpinned: boolean | null;
|
||||||
|
visible: boolean;
|
||||||
|
closed: boolean;
|
||||||
|
archived: boolean;
|
||||||
|
bookmarked: boolean | null;
|
||||||
|
liked: boolean | null;
|
||||||
|
tags: Array<string>;
|
||||||
|
tags_descriptions: Record<string, string>;
|
||||||
|
like_count: number;
|
||||||
|
views: number;
|
||||||
|
category_id: number;
|
||||||
|
featured_link: string | null;
|
||||||
|
has_accepted_answer: boolean;
|
||||||
|
posters: Array<{
|
||||||
|
extras: string | null;
|
||||||
|
description: string;
|
||||||
|
user_id: number;
|
||||||
|
primary_group_id: number | null;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TopicList {
|
||||||
|
topic_list: {
|
||||||
|
can_create_topic: boolean;
|
||||||
|
draft: string | null;
|
||||||
|
draft_key: string;
|
||||||
|
draft_sequence: number;
|
||||||
|
per_page: number;
|
||||||
|
topics: Array<Topic>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { CategoryList, CategoryGet, Topic, TopicList };
|
||||||
@@ -9,23 +9,27 @@ import { sleep } from "./sleep.js";
|
|||||||
/**
|
/**
|
||||||
* Wraps the native fetch method in logic to back off
|
* Wraps the native fetch method in logic to back off
|
||||||
* and retry on 429 errors.
|
* and retry on 429 errors.
|
||||||
|
* @type {T} - The type of the response.
|
||||||
* @param url - The URL to fetch.
|
* @param url - The URL to fetch.
|
||||||
* @param options - The fetch options.
|
* @param options - The fetch options.
|
||||||
|
* @returns The response, or null on error.
|
||||||
*/
|
*/
|
||||||
export const backoffAndRetry
|
export const backoffAndRetry
|
||||||
= async(url: string, options: RequestInit = {}): Promise<void> => {
|
= async<T>(url: string, options: RequestInit = {}): Promise<T | null> => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, options);
|
const response = await fetch(url, options);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
if (response.status === 429) {
|
if (response.status === 429) {
|
||||||
await sleep(5000);
|
await sleep(5000);
|
||||||
await backoffAndRetry(url, options);
|
return await backoffAndRetry(url, options);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
throw new Error(`Request failed with status ${response.status.toString()}`);
|
throw new Error(`Request failed with status ${response.status.toString()}`);
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- This is a workaround to avoid type errors.
|
||||||
|
return await response.json() as T;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Fetch error: ${JSON.stringify(error, null, 2)}`);
|
console.error(`Fetch error: ${JSON.stringify(error, null, 2)}`);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user