Skip to content

Commit

Permalink
Move API requests to the content script (#67)
Browse files Browse the repository at this point in the history
This is a workaround for CF putting API endpoints behind a Cloudflare
human check. Why do they do this? I can only assume they don't know
this is happening, or they do but they don't care about API service.
  • Loading branch information
meooow25 authored Sep 29, 2024
1 parent f04aa0a commit de82ac9
Show file tree
Hide file tree
Showing 9 changed files with 143 additions and 64 deletions.
Empty file modified build.sh
100644 → 100755
Empty file.
3 changes: 2 additions & 1 deletion carrot/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
},
"permissions": [
"storage",
"unlimitedStorage"
"unlimitedStorage",
"*://*.codeforces.com/*"
],
"background": {
"page": "src/background/background.html",
Expand Down
61 changes: 49 additions & 12 deletions carrot/src/background/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,24 @@ import Ratings from './cache/ratings.js';
import TopLevelCache from './cache/top-level-cache.js';
import predict, { Contestant, PredictResult } from './predict.js';
import PredictResponse from './predict-response.js';
import * as api from './cf-api.js';
import { Api } from './cf-api.js';
import compareVersions from '../util/version-compare.js';

const DEBUG_FORCE_PREDICT = false;

const UNRATED_HINTS = ['unrated', 'fools', 'q#', 'kotlin', 'marathon', 'teams'];
const EDU_ROUND_RATED_THRESHOLD = 2100;

const CONTESTS = new Contests(api);
const RATINGS = new Ratings(api, LOCAL);
const CONTESTS_COMPLETE = new ContestsComplete(api);
const API = new Api(fetchFromContentScript);
const CONTESTS = new Contests(API);
const RATINGS = new Ratings(API, LOCAL);
const CONTESTS_COMPLETE = new ContestsComplete(API);
const TOP_LEVEL_CACHE = new TopLevelCache();

/* ----------------------------------------------- */
/* Message listener */
/* ----------------------------------------------- */

browser.runtime.onMessage.addListener((message, sender) => {
let responsePromise;
if (message.type === 'PREDICT') {
Expand All @@ -40,7 +45,37 @@ browser.runtime.onMessage.addListener((message, sender) => {
});
});

// Prediction related code starts.
/* ----------------------------------------------- */
/* Content script fetch */
/* ----------------------------------------------- */

async function fetchFromContentScript(path, queryParamList) {
const tabs = await browser.tabs.query({
// This is the same as host permissions in the manifest
url: ['*://*.codeforces.com/*'],
});

if (tabs.length === 0) {
throw new Error('No Codeforces tab open :<');
}

// Prefer a loaded tab
let tab = tabs.find(tab => tab.status === 'complete');
if (tab === undefined) {
tab = tabs[0];
}

const msg = {
type: 'API_FETCH',
path,
queryParamList,
};
return await browser.tabs.sendMessage(tab.id, msg);
}

/* ----------------------------------------------- */
/* Prediction */
/* ----------------------------------------------- */

function isUnratedByName(contestName) {
const lower = contestName.toLowerCase();
Expand Down Expand Up @@ -150,9 +185,9 @@ async function getPredicted(contest) {
return new PredictResponse(predictResults, PredictResponse.TYPE_PREDICTED, contest.fetchTime);
}

// Prediction related code ends.

// Cache related code starts.
/* ----------------------------------------------- */
/* Cache stuff */
/* ----------------------------------------------- */

async function maybeUpdateContestList() {
const prefs = await settings.getPrefs();
Expand Down Expand Up @@ -188,9 +223,9 @@ async function maybeUpdateRatings() {
}
}

// Cache related code ends.

// Badge related code starts.
/* ----------------------------------------------- */
/* Badge stuff */
/* ----------------------------------------------- */

function setErrorBadge(sender) {
const tabId = sender.tab.id;
Expand All @@ -201,7 +236,9 @@ function setErrorBadge(sender) {
browser.browserAction.setBadgeBackgroundColor({ color: 'hsl(355, 100%, 30%)', tabId });
}

// Badge related code ends.
/* ----------------------------------------------- */
/* Bug fixes */
/* ----------------------------------------------- */

browser.runtime.onInstalled.addListener((details) => {
if (details.previousVersion && compareVersions(details.previousVersion, '0.6.2') <= 0) {
Expand Down
4 changes: 2 additions & 2 deletions carrot/src/background/cache/contests-complete.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,13 @@ export class ContestsComplete {
return this.contests.get(contestId);
}

const { contest, problems, rows } = await this.api.contest.standings(contestId);
const { contest, problems, rows } = await this.api.contestStandings(contestId);
let ratingChanges;
let oldRatings;
let isRated = Contest.IsRated.LIKELY;
if (contest.phase === 'FINISHED') {
try {
ratingChanges = await this.api.contest.ratingChanges(contestId);
ratingChanges = await this.api.contestRatingChanges(contestId);
if (ratingChanges) {
if (ratingChanges.length > 0) {
isRated = Contest.IsRated.YES;
Expand Down
2 changes: 1 addition & 1 deletion carrot/src/background/cache/contests.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export default class Contests {
}
this.lastAttemptTime = now;
try {
const contests = await this.api.contest.list();
const contests = await this.api.contestList();
this.contestMap = new Map(contests.map((c) => [c.id, c]));
} catch (er) {
console.warn('Unable to fetch contest list: ' + er);
Expand Down
2 changes: 1 addition & 1 deletion carrot/src/background/cache/ratings.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export default class Ratings {
}

async cacheRatings() {
const users = await this.api.user.ratedList(false);
const users = await this.api.userRatedList(false);
const ratings = Object.fromEntries(users.map((u) => [u.handle, u.rating]));
await this.storage.set(RATINGS, ratings);
await this.storage.set(RATINGS_TIMESTAMP, Date.now());
Expand Down
76 changes: 32 additions & 44 deletions carrot/src/background/cf-api.js
Original file line number Diff line number Diff line change
@@ -1,58 +1,46 @@
/**
* Utility to fetch data from the Codeforces API.
*/
export class Api {
constructor(fetchFromContentScript) {
// We fetch from the content script as a workaround for CF putting API
// endpoints behind a Cloudflare human check. The content script should
// have the necessary cookies to get through and receive a response.
this.fetchFromContentScript = fetchFromContentScript;
}

const API_URL_PREFIX = 'https://codeforces.com/api/';

async function apiFetch(path, queryParams) {
const url = new URL(API_URL_PREFIX + path);
for (const [key, value] of Object.entries(queryParams)) {
if (value !== undefined) {
url.searchParams.append(key, value);
async fetch(path, queryParams) {
let queryParamList = [];
for (const [key, value] of Object.entries(queryParams)) {
if (value !== undefined) {
queryParamList.push([key, value]);
}
}
return await this.fetchFromContentScript(path, queryParamList);
}
const resp = await fetch(url);
const text = await resp.text();
if (resp.status !== 200) {
throw new Error(`CF API: HTTP error ${resp.status}: ${text}`)
}
let json;
try {
json = JSON.parse(text);
} catch (_) {
throw new Error(`CF API: Invalid JSON: ${text}`);
}
if (json.status !== 'OK' || json.result === undefined) {
throw new Error(`CF API: Error: ${text}`);
}
return json.result;
}

export const contest = {
async list(gym = undefined) {
return await apiFetch('contest.list', { gym: gym });
},
async contestList(gym = undefined) {
return await this.fetch('contest.list', { gym });
}

async standings(
async contestStandings(
contestId, from = undefined, count = undefined, handles = undefined, room = undefined,
showUnofficial = undefined) {
return await apiFetch('contest.standings', {
contestId: contestId,
from: from,
count: count,
return await this.fetch('contest.standings', {
contestId,
from,
count,
handles: handles && handles.length ? handles.join(';') : undefined,
room: room,
showUnofficial: showUnofficial,
room,
showUnofficial,
});
},
}

async ratingChanges(contestId) {
return await apiFetch('contest.ratingChanges', { contestId: contestId });
},
};
async contestRatingChanges(contestId) {
return await this.fetch('contest.ratingChanges', { contestId });
}

export const user = {
async ratedList(activeOnly = undefined) {
return await apiFetch('user.ratedList', { activeOnly: activeOnly });
},
};
async userRatedList(activeOnly = undefined) {
return await this.fetch('user.ratedList', { activeOnly });
}
}
34 changes: 34 additions & 0 deletions carrot/src/content/content.js
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,38 @@ const state = {
error: null,
};

/* ----------------------------------------------- */
/* API stuff */
/* ----------------------------------------------- */

const API_PATH = '/api/';

async function apiFetch(path, queryParamList) {
const url = new URL(location.origin + API_PATH + path);
for (const [key, value] of queryParamList) {
url.searchParams.append(key, value);
}
const resp = await fetch(url);
const text = await resp.text();
if (resp.status !== 200) {
throw new Error(`CF API: HTTP error ${resp.status}: ${text}`)
}
let json;
try {
json = JSON.parse(text);
} catch (_) {
throw new Error(`CF API: Invalid JSON: ${text}`);
}
if (json.status !== 'OK' || json.result === undefined) {
throw new Error(`CF API: Error: ${text}`);
}
return json.result;
}

/* ----------------------------------------------- */
/* main */
/* ----------------------------------------------- */

function main() {
// On any Codeforces ranklist page.
const matches = location.pathname.match(/contest\/(\d+)\/standings/);
Expand Down Expand Up @@ -388,5 +420,7 @@ browser.runtime.onMessage.addListener((message) => {
} else if (message.type == 'UPDATE_COLS') {
updateColumnVisibility(message.prefs);
return Promise.resolve();
} else if (message.type = 'API_FETCH') {
return apiFetch(message.path, message.queryParamList);
}
});
25 changes: 22 additions & 3 deletions carrot/tests/rounds.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import * as path from 'https://deno.land/[email protected]/path/mod.ts';

import { Contestant } from '../src/background/predict.js';
import * as api from '../src/background/cf-api.js';
import { Api } from '../src/background/cf-api.js';

const DATA_DIR = path.join(path.fromFileUrl(import.meta.url), '../data');
const DATA_FILE_REGEX = /^(round-.*)-data.json$/;
const API_URL_PREFIX = 'https://codeforces.com/api/'

export class DataRow {
constructor(
Expand Down Expand Up @@ -54,10 +55,28 @@ async function main() {
Deno.exit(1);
}

const { rows } = await api.contest.standings(contestId);
const api = new Api(
async (path: string, queryParamList: [string, string][]): Promise<any> => {
const url = new URL(API_URL_PREFIX + path);
for (const [key, value] of queryParamList) {
url.searchParams.append(key, value);
}
const resp = await fetch(url);
if (resp.status !== 200) {
throw new Error(`CF API: HTTP error ${resp.status}`)
}
const json = await resp.json();
if (json.status !== 'OK') {
throw new Error(`CF API: Error: ${json.status}`);
}
return json.result;
}
);

const { rows } = await api.contestStandings(contestId);
const rowMap = new Map<string, any>(rows.map((r: any) => [r.party.members[0].handle, r]));

const changes = await api.contest.ratingChanges(contestId);
const changes = await api.contestRatingChanges(contestId);

const output = changes.map((c: any) => {
const row = rowMap.get(c.handle);
Expand Down

0 comments on commit de82ac9

Please sign in to comment.