diff --git a/package-lock.json b/package-lock.json
index c447dc6..bd2247f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -28,6 +28,7 @@
"@types/textversionjs": "^1.1.1",
"@types/tunnel": "^0.0.3",
"airtable": "^0.11.1",
+ "ajv": "^8.11.0",
"algoliasearch": "^4.10.3",
"autoprefixer": "^10.1.0",
"axios": "^0.25.0",
@@ -3498,6 +3499,21 @@
"node": ">=8.0.0"
}
},
+ "node_modules/ajv": {
+ "version": "8.11.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz",
+ "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2",
+ "uri-js": "^4.2.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
"node_modules/algoliasearch": {
"version": "4.10.3",
"resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-4.10.3.tgz",
@@ -5771,6 +5787,11 @@
"url": "https://github.com/sponsors/jaydenseric"
}
},
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
+ },
"node_modules/fast-equals": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-2.0.4.tgz",
@@ -7454,6 +7475,11 @@
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
"license": "MIT"
},
+ "node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="
+ },
"node_modules/json-stable-stringify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.0.1.tgz",
@@ -37587,6 +37613,14 @@
"node": ">=0.10.0"
}
},
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
@@ -39027,6 +39061,14 @@
"tslib": "^2.0.3"
}
},
+ "node_modules/uri-js": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+ "dependencies": {
+ "punycode": "^2.1.0"
+ }
+ },
"node_modules/url-parse-lax": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-3.0.0.tgz",
@@ -42453,6 +42495,17 @@
"node-fetch": "^2.6.7"
}
},
+ "ajv": {
+ "version": "8.11.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz",
+ "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==",
+ "requires": {
+ "fast-deep-equal": "^3.1.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2",
+ "uri-js": "^4.2.2"
+ }
+ },
"algoliasearch": {
"version": "4.10.3",
"resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-4.10.3.tgz",
@@ -44096,6 +44149,11 @@
"resolved": "https://registry.npmjs.org/extract-files/-/extract-files-9.0.0.tgz",
"integrity": "sha512-CvdFfHkC95B4bBBk36hcEmvdR2awOdhhVUYH6S/zrVj3477zven/fJMYg7121h4T1xHZC+tetUpubpAhxwI7hQ=="
},
+ "fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
+ },
"fast-equals": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-2.0.4.tgz",
@@ -45304,6 +45362,11 @@
"resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="
},
+ "json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="
+ },
"json-stable-stringify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.0.1.tgz",
@@ -67926,6 +67989,11 @@
"integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=",
"dev": true
},
+ "require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="
+ },
"require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
@@ -68920,6 +68988,14 @@
"tslib": "^2.0.3"
}
},
+ "uri-js": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+ "requires": {
+ "punycode": "^2.1.0"
+ }
+ },
"url-parse-lax": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-3.0.0.tgz",
diff --git a/package.json b/package.json
index f141b9e..80e9ced 100644
--- a/package.json
+++ b/package.json
@@ -46,6 +46,7 @@
"@types/textversionjs": "^1.1.1",
"@types/tunnel": "^0.0.3",
"airtable": "^0.11.1",
+ "ajv": "^8.11.0",
"algoliasearch": "^4.10.3",
"autoprefixer": "^10.1.0",
"axios": "^0.25.0",
diff --git a/src/backend/platforms/metaculus.ts b/src/backend/platforms/metaculus.ts
deleted file mode 100644
index 710f3d3..0000000
--- a/src/backend/platforms/metaculus.ts
+++ /dev/null
@@ -1,205 +0,0 @@
-/* Imports */
-import axios from "axios";
-
-import { average } from "../../utils";
-import { sleep } from "../utils/sleep";
-import toMarkdown from "../utils/toMarkdown";
-import { FetchedQuestion, Platform } from "./";
-
-/* Definitions */
-const platformName = "metaculus";
-let now = new Date().toISOString();
-let DEBUG_MODE = "off";
-let SLEEP_TIME = 5000;
-
-/* Support functions */
-async function fetchMetaculusQuestions(next: string) {
- // Numbers about a given address: how many, how much, at what price, etc.
- let response;
- let data;
- try {
- response = await axios({
- url: next,
- method: "GET",
- headers: { "Content-Type": "application/json" },
- });
- data = response.data;
- } catch (error) {
- console.log(`Error in async function fetchMetaculusQuestions(next)`);
- 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 + SLEEP_TIME);
- } else {
- await sleep(SLEEP_TIME);
- }
- }
- } finally {
- try {
- response = await axios({
- url: next,
- method: "GET",
- headers: { "Content-Type": "application/json" },
- });
- data = response.data;
- } catch (error) {
- console.log(error);
- return { results: [] };
- }
- }
- // console.log(response)
- return data;
-}
-
-async function fetchMetaculusQuestionDescription(slug: string) {
- try {
- let response = await axios({
- method: "get",
- url: "https://www.metaculus.com" + slug,
- }).then((response) => response.data);
- return response;
- } catch (error) {
- console.log(`Error in: fetchMetaculusQuestionDescription`);
- console.log(
- `We encountered some error when attempting to fetch a metaculus page. Trying again`
- );
- if (
- axios.isAxiosError(error) &&
- typeof error.response != "undefined" &&
- typeof error.response.headers != "undefined" &&
- typeof error.response.headers["retry-after"] != "undefined"
- ) {
- const timeout = error.response.headers["retry-after"];
- console.log(`Timeout: ${timeout}`);
- await sleep(Number(timeout) * 1000 + SLEEP_TIME);
- } else {
- await sleep(SLEEP_TIME);
- }
- try {
- let response = await axios({
- method: "get",
- url: "https://www.metaculus.com" + slug,
- }).then((response) => response.data);
- // console.log(response)
- return response;
- } catch (error) {
- console.log(
- `We encountered some error when attempting to fetch a metaculus page.`
- );
- console.log("Error", error);
- throw "Giving up";
- }
- }
-}
-
-export const metaculus: Platform = {
- name: platformName,
- label: "Metaculus",
- color: "#006669",
- version: "v1",
- async fetcher() {
- // let metaculusQuestionsInit = await fetchMetaculusQuestions(1)
- // let numQueries = Math.round(Number(metaculusQuestionsInit.count) / 20)
- // console.log(`Downloading... This might take a while. Total number of queries: ${numQueries}`)
- // for (let i = 4; i <= numQueries; i++) { // change numQueries to 10 if one want to just test }
- let all_questions = [];
- let next = "https://www.metaculus.com/api2/questions/";
- let i = 1;
- while (next) {
- if (i % 20 == 0) {
- console.log("Sleeping for 500ms");
- await sleep(SLEEP_TIME);
- }
- console.log(`\nQuery #${i}`);
- let metaculusQuestions = await fetchMetaculusQuestions(next);
- let results = metaculusQuestions.results;
- let j = false;
- for (let result of results) {
- if (result.publish_time < now && now < result.resolve_time) {
- await sleep(SLEEP_TIME / 2);
- let questionPage = await fetchMetaculusQuestionDescription(
- result.page_url
- );
- if (!questionPage.includes("A public prediction by")) {
- // console.log(questionPage)
- let descriptionraw = questionPage.split(
- `
`
- )[1]; //.split(`
`)[1]
- let descriptionprocessed1 = descriptionraw.split("
")[0];
- let descriptionprocessed2 = toMarkdown(descriptionprocessed1);
- let description = descriptionprocessed2;
-
- let isbinary = result.possibilities.type == "binary";
- let options: FetchedQuestion["options"] = [];
- if (isbinary) {
- let probability = Number(result.community_prediction.full.q2);
- options = [
- {
- name: "Yes",
- probability: probability,
- type: "PROBABILITY",
- },
- {
- name: "No",
- probability: 1 - probability,
- type: "PROBABILITY",
- },
- ];
- }
- let id = `${platformName}-${result.id}`;
- let interestingInfo: FetchedQuestion = {
- id,
- title: result.title,
- url: "https://www.metaculus.com" + result.page_url,
- description,
- options,
- qualityindicators: {
- numforecasts: Number(result.number_of_predictions),
- },
- extra: {
- resolution_data: {
- publish_time: result.publish_time,
- resolution: result.resolution,
- close_time: result.close_time,
- resolve_time: result.resolve_time,
- },
- },
- //"status": result.status,
- //"publish_time": result.publish_time,
- //"close_time": result.close_time,
- //"type": result.possibilities.type, // We want binary ones here.
- //"last_activity_time": result.last_activity_time,
- };
- if (Number(result.number_of_predictions) >= 10) {
- console.log(`- ${interestingInfo.title}`);
- all_questions.push(interestingInfo);
- if ((!j && i % 20 == 0) || DEBUG_MODE == "on") {
- console.log(interestingInfo);
- j = true;
- }
- }
- } else {
- console.log("- [Skipping public prediction]");
- }
- }
- }
- next = metaculusQuestions.next;
- i = i + 1;
- }
-
- return all_questions;
- },
-
- calculateStars(data) {
- const { numforecasts } = data.qualityindicators;
- let nuno = () =>
- (numforecasts || 0) > 300 ? 4 : (numforecasts || 0) > 100 ? 3 : 2;
- let eli = () => 3;
- let misha = () => 3;
- let starsDecimal = average([nuno(), eli(), misha()]);
- let starsInteger = Math.round(starsDecimal);
- return starsInteger;
- },
-};
diff --git a/src/backend/platforms/metaculus/api.ts b/src/backend/platforms/metaculus/api.ts
new file mode 100644
index 0000000..1d15a83
--- /dev/null
+++ b/src/backend/platforms/metaculus/api.ts
@@ -0,0 +1,213 @@
+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: {
+ properties: {
+ 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 apiQuestionSchema = {
+ 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,
+ },
+ },
+} as const;
+
+const apiMultipleQuestionsSchema = {
+ properties: {
+ results: {
+ elements: apiQuestionSchema,
+ },
+ 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
;
+export type ApiMultipleQuestions = JTDDataType<
+ typeof apiMultipleQuestionsSchema
+>;
+
+const validateApiQuestion = new Ajv().compile(apiQuestionSchema);
+const validateApiMultipleQuestions = new Ajv().compile(
+ apiMultipleQuestionsSchema
+);
+
+async function fetchWithRetries(url: string): Promise {
+ try {
+ const response = await axios.get(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(url);
+ return response.data;
+}
+
+const fetchAndValidate = async (
+ url: string,
+ validator: ValidateFunction
+): Promise => {
+ console.log(url);
+ const data = await fetchWithRetries