260 lines
6.0 KiB
TypeScript
260 lines
6.0 KiB
TypeScript
import Ajv, { JTDDataType, ValidateFunction } from "ajv/dist/jtd";
|
|
import axios from "axios";
|
|
import { sleep } from "../../utils/sleep";
|
|
|
|
// Type examples:
|
|
// - group: https://www.metaculus.com/api2/questions/9866/
|
|
// - claim: https://www.metaculus.com/api2/questions/9668/
|
|
// - subquestion forecast: https://www.metaculus.com/api2/questions/10069/
|
|
// - basic forecast: https://www.metaculus.com/api2/questions/11005/
|
|
|
|
const RETRY_SLEEP_TIME = 5000;
|
|
|
|
const commonProps = {
|
|
id: {
|
|
type: "uint32",
|
|
},
|
|
title: {
|
|
type: "string",
|
|
},
|
|
} as const;
|
|
|
|
const predictableProps = {
|
|
publish_time: {
|
|
type: "string",
|
|
},
|
|
close_time: {
|
|
type: "string",
|
|
},
|
|
resolve_time: {
|
|
type: "string",
|
|
},
|
|
resolution: {
|
|
type: "float64",
|
|
nullable: true,
|
|
},
|
|
possibilities: {
|
|
properties: {
|
|
type: {
|
|
// Enum["binary", "continuous"], via https://github.com/quantified-uncertainty/metaforecast/pull/84#discussion_r878240875
|
|
// but metaculus might add new values in the future and we don't want the fetcher to break
|
|
type: "string",
|
|
},
|
|
},
|
|
additionalProperties: true,
|
|
},
|
|
number_of_predictions: {
|
|
type: "uint32",
|
|
},
|
|
community_prediction: {
|
|
properties: {
|
|
full: {
|
|
// q1/q2/q3 can be missing, e.g. https://www.metaculus.com/api2/questions/1633/
|
|
optionalProperties: {
|
|
q1: {
|
|
type: "float64",
|
|
},
|
|
q2: {
|
|
type: "float64",
|
|
},
|
|
q3: {
|
|
type: "float64",
|
|
},
|
|
},
|
|
additionalProperties: true,
|
|
},
|
|
},
|
|
additionalProperties: true,
|
|
},
|
|
} as const;
|
|
|
|
const pageProps = {
|
|
page_url: {
|
|
type: "string",
|
|
},
|
|
group: {
|
|
type: "uint32",
|
|
nullable: true,
|
|
},
|
|
} as const;
|
|
|
|
// these are missing in /api2/questions/ requests, and building two schemas is too much pain
|
|
const optionalPageProps = {
|
|
description: {
|
|
type: "string",
|
|
},
|
|
description_html: {
|
|
type: "string",
|
|
},
|
|
} as const;
|
|
|
|
const questionSchema = {
|
|
discriminator: "type",
|
|
mapping: {
|
|
forecast: {
|
|
properties: {
|
|
...commonProps,
|
|
...pageProps,
|
|
...predictableProps,
|
|
},
|
|
optionalProperties: {
|
|
...optionalPageProps,
|
|
},
|
|
additionalProperties: true,
|
|
},
|
|
group: {
|
|
properties: {
|
|
...commonProps,
|
|
...pageProps,
|
|
sub_questions: {
|
|
elements: {
|
|
properties: {
|
|
...commonProps,
|
|
...predictableProps,
|
|
},
|
|
additionalProperties: true,
|
|
},
|
|
},
|
|
},
|
|
optionalProperties: {
|
|
...optionalPageProps,
|
|
},
|
|
additionalProperties: true,
|
|
},
|
|
// we're not interested in claims currently (but we should be?)
|
|
claim: {
|
|
properties: {
|
|
...commonProps,
|
|
...pageProps,
|
|
},
|
|
optionalProperties: {
|
|
...optionalPageProps,
|
|
},
|
|
additionalProperties: true,
|
|
},
|
|
discussion: {
|
|
optionalProperties: {
|
|
...optionalPageProps,
|
|
},
|
|
additionalProperties: true,
|
|
},
|
|
},
|
|
} as const;
|
|
|
|
const knownQuestionTypes = Object.keys(questionSchema.mapping);
|
|
|
|
const shallowMultipleQuestionsSchema = {
|
|
properties: {
|
|
results: {
|
|
elements: {
|
|
properties: {
|
|
type: {
|
|
type: "string",
|
|
},
|
|
},
|
|
additionalProperties: true,
|
|
},
|
|
},
|
|
next: {
|
|
type: "string",
|
|
nullable: true,
|
|
},
|
|
},
|
|
additionalProperties: true,
|
|
} as const;
|
|
|
|
export type ApiCommon = JTDDataType<{
|
|
properties: typeof commonProps;
|
|
}>;
|
|
export type ApiPredictable = JTDDataType<{
|
|
properties: typeof predictableProps;
|
|
}>;
|
|
export type ApiQuestion = JTDDataType<typeof questionSchema>;
|
|
|
|
type ApiShallowMultipleQuestions = JTDDataType<
|
|
typeof shallowMultipleQuestionsSchema
|
|
>;
|
|
|
|
export type ApiMultipleQuestions = {
|
|
results: ApiQuestion[];
|
|
next: ApiShallowMultipleQuestions["next"]; // Omit<ApiShallowMultipleQuestions, "results"> doesn't work correctly here
|
|
};
|
|
|
|
const validateQuestion = new Ajv().compile<ApiQuestion>(questionSchema);
|
|
const validateShallowMultipleQuestions =
|
|
new Ajv().compile<ApiShallowMultipleQuestions>(
|
|
shallowMultipleQuestionsSchema
|
|
);
|
|
|
|
async function fetchWithRetries<T = unknown>(url: string): Promise<T> {
|
|
try {
|
|
const response = await axios.get<T>(url);
|
|
return response.data;
|
|
} catch (error) {
|
|
console.log(`Error while fetching ${url}`);
|
|
console.log(error);
|
|
if (axios.isAxiosError(error)) {
|
|
if (error.response?.headers["retry-after"]) {
|
|
const timeout = error.response.headers["retry-after"];
|
|
console.log(`Timeout: ${timeout}`);
|
|
await sleep(Number(timeout) * 1000 + 1000);
|
|
} else {
|
|
await sleep(RETRY_SLEEP_TIME);
|
|
}
|
|
}
|
|
}
|
|
const response = await axios.get<T>(url);
|
|
return response.data;
|
|
}
|
|
|
|
const fetchAndValidate = async <T = unknown>(
|
|
url: string,
|
|
validator: ValidateFunction<T>
|
|
): Promise<T> => {
|
|
console.log(url);
|
|
const data = await fetchWithRetries<object>(url);
|
|
if (validator(data)) {
|
|
return data;
|
|
}
|
|
throw new Error(
|
|
`Response validation for url ${url} failed: ` +
|
|
JSON.stringify(validator.errors)
|
|
);
|
|
};
|
|
|
|
export async function fetchApiQuestions(
|
|
next: string
|
|
): Promise<ApiMultipleQuestions> {
|
|
const data = await fetchAndValidate(next, validateShallowMultipleQuestions);
|
|
|
|
const isDefined = <T>(argument: T | undefined): argument is T => {
|
|
return argument !== undefined;
|
|
};
|
|
|
|
return {
|
|
...data,
|
|
results: data.results
|
|
.map((result) => {
|
|
if (!knownQuestionTypes.includes(result.type)) {
|
|
console.warn(`Unknown result type ${result.type}, skipping`);
|
|
return undefined;
|
|
}
|
|
if (!validateQuestion(result)) {
|
|
throw new Error(
|
|
`Response validation failed: ` +
|
|
JSON.stringify(validateQuestion.errors)
|
|
);
|
|
}
|
|
return result;
|
|
})
|
|
.filter(isDefined),
|
|
};
|
|
}
|
|
|
|
export async function fetchSingleApiQuestion(id: number): Promise<ApiQuestion> {
|
|
return await fetchAndValidate(
|
|
`https://www.metaculus.com/api2/questions/${id}/`,
|
|
validateQuestion
|
|
);
|
|
}
|