Skip to content

Commit 581ecbb

Browse files
committed
add: chrome extension for fetching predictions on leetcode itself
1 parent 29ea831 commit 581ecbb

File tree

6 files changed

+241
-1
lines changed

6 files changed

+241
-1
lines changed

chrome-extension/background.js

+45
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
2+
if (
3+
changeInfo.status === "complete" &&
4+
/^https:\/\/leetcode.com\/contest\/[a-zA-Z1-9-]*\/ranking\//.test(
5+
tab.url
6+
)
7+
) {
8+
chrome.scripting
9+
.executeScript({
10+
target: { tabId: tabId },
11+
files: ["./foreground.js"],
12+
})
13+
.then(() => {
14+
console.log("Injected the foreground script.");
15+
})
16+
.catch((err) => console.error(err));
17+
console.log(tabId);
18+
console.log(changeInfo);
19+
console.log(tab);
20+
}
21+
});
22+
23+
const BASE_URL = new URL("http://127.0.0.1:8080/api/v1/predictions"); // TODO: replace it
24+
25+
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
26+
if (request.message === "get_predictions") {
27+
const url = BASE_URL;
28+
url.searchParams.set("contestId", request.data.contestId);
29+
let handles = "";
30+
request.data.handles.forEach((handle, index) => {
31+
console.log(handle, index);
32+
handles +=
33+
handle + (index !== request.data.handles.length - 1 ? ";" : "");
34+
});
35+
console.log(handles);
36+
url.searchParams.set("handles", handles);
37+
fetch(url)
38+
.then((res) => res.json())
39+
.then((res) => {
40+
sendResponse(res);
41+
})
42+
.catch((err) => console.error(err));
43+
return true;
44+
}
45+
});

chrome-extension/foreground.js

+155
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
if (!window.CFPredictorInjected) {
2+
window.CFPredictorInjected = true;
3+
let predictionsTimer;
4+
let isListenerActive = false;
5+
const setEventListener = () => {
6+
try {
7+
const tbody = document.querySelector("tbody");
8+
if (!tbody) {
9+
setTimeout(setEventListener, 100);
10+
}
11+
const trs = tbody.querySelectorAll("tr");
12+
if (trs.length <= 1) {
13+
// listen only if there is more than one row
14+
isListenerActive = false;
15+
return;
16+
}
17+
const tds = trs[1].querySelectorAll("td");
18+
if (tds.length <= 1) {
19+
isListenerActive = false;
20+
return;
21+
}
22+
isListenerActive = true;
23+
tds[1].addEventListener("DOMCharacterDataModified", () => {
24+
window.clearTimeout(predictionsTimer);
25+
predictionsTimer = setTimeout(fetchPredictions, 1000);
26+
});
27+
} catch (err) {
28+
console.error(err);
29+
}
30+
};
31+
32+
let rowsChanged = new Map();
33+
let colInserted = false;
34+
35+
const fetchPredictions = async () => {
36+
const thead = document.querySelector("thead");
37+
if (!thead) {
38+
predictionsTimer = setTimeout(fetchPredictions, 100);
39+
return;
40+
}
41+
console.log("fetching!");
42+
const tbody = document.querySelector("tbody");
43+
const rows = tbody.querySelectorAll("tr");
44+
const contestId = document
45+
.querySelector(".ranking-title-wrapper")
46+
.querySelector("span")
47+
.querySelector("a")
48+
.innerHTML.toLowerCase()
49+
.replace(/\s/g, "-");
50+
const handlesMap = new Map();
51+
52+
const handles = [...rows].map((row, index) => {
53+
try {
54+
const tds = row.querySelectorAll("td");
55+
if (tds.length >= 2) {
56+
let handle, url;
57+
try {
58+
handle = tds[1].querySelector("a").innerText.trim();
59+
url = tds[1].querySelector("a").getAttribute("href");
60+
} catch {
61+
handle = tds[1].querySelector("span").innerText.trim();
62+
url = ""; // TODO: get data_region in this case
63+
}
64+
const data_region = /^https:\/\/leetcode-cn.com/.test(url)
65+
? "CN"
66+
: "US";
67+
handlesMap.set(
68+
(data_region + "/" + handle).toLowerCase(),
69+
index
70+
);
71+
return handle;
72+
}
73+
} catch (err) {
74+
console.error(err);
75+
}
76+
});
77+
78+
chrome.runtime.sendMessage(
79+
{
80+
message: "get_predictions",
81+
data: { contestId, handles },
82+
},
83+
(response) => {
84+
if (response.status === "OK") {
85+
if (!colInserted) {
86+
const th = document.createElement("th");
87+
th.innerText = "Δ";
88+
thead.querySelector("tr").appendChild(th);
89+
colInserted = true;
90+
}
91+
const rowsUpdated = new Map();
92+
for (item of response.items) {
93+
try {
94+
const id = (
95+
item.data_region +
96+
"/" +
97+
item._id
98+
).toLowerCase();
99+
if (handlesMap.has(id)) {
100+
const rowIndex = handlesMap.get(id);
101+
const row = rows[rowIndex];
102+
let td;
103+
if (rowsChanged.has(rowIndex)) {
104+
td = row.lastChild;
105+
} else {
106+
td = document.createElement("td");
107+
}
108+
const delta =
109+
Math.round(item.delta * 100) / 100;
110+
td.innerText = delta > 0 ? "+" + delta : delta;
111+
if (delta > 0) {
112+
td.style.color = "green";
113+
} else {
114+
td.style.color = "gray";
115+
}
116+
td.style.fontWeight = "bold";
117+
118+
if (!rowsChanged.has(rowIndex)) {
119+
row.appendChild(td);
120+
}
121+
rowsUpdated.set(rowIndex, true);
122+
}
123+
} catch (err) {
124+
console.error(err);
125+
}
126+
}
127+
for (rowIndex of rowsChanged.keys()) {
128+
if (!rowsUpdated.has(rowIndex)) {
129+
try {
130+
const row = rows[rowIndex];
131+
row.lastChild.innerText = "";
132+
} catch (err) {
133+
console.error(err);
134+
}
135+
}
136+
}
137+
for (rowIndex of rowsUpdated.keys()) {
138+
rowsChanged.set(rowIndex, true);
139+
}
140+
}
141+
}
142+
);
143+
};
144+
fetchPredictions();
145+
setEventListener();
146+
147+
// event listener for "click" event on page-btn class items
148+
[...document.querySelectorAll(".page-btn")].forEach((item) => {
149+
item.addEventListener("click", (e) => {
150+
if (!isListenerActive) {
151+
setEventListener();
152+
}
153+
});
154+
});
155+
}

chrome-extension/manifest.json

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"manifest_version": 3,
3+
"name": "Leetcode Predictor",
4+
"version": "1.0.0",
5+
"description": "Browser extension for Leetcode rating predictions.",
6+
"short_name": "LCPredictor",
7+
"background": {
8+
"service_worker": "background.js"
9+
},
10+
"action":{
11+
"default_popup":"./popup.html",
12+
"default_icons":{
13+
14+
}
15+
},
16+
"options_page":"./options.html",
17+
"permissions":[
18+
"activeTab",
19+
"tabs",
20+
"storage",
21+
"scripting"
22+
],
23+
"host_permissions":[
24+
"<all_urls>"
25+
]
26+
}

chrome-extension/options.html

Whitespace-only changes.

chrome-extension/popup.html

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8">
5+
<meta http-equiv="X-UA-Compatible" content="IE=edge">
6+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
7+
<title>Document</title>
8+
</head>
9+
<body>
10+
11+
</body>
12+
</html>

controllers/predictionsController.js

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
const Contest = require("../models/contest");
22

33
exports.get = function (req, res) {
4+
res.set("Access-Control-Allow-Origin", "*");
5+
46
if (!req.query.contestId || !req.query.handles) {
57
res.status(400).send("Invalid query params");
68
return;
@@ -12,7 +14,7 @@ exports.get = function (req, res) {
1214
return handle.trim();
1315
})
1416
.filter((handle) => handle != "");
15-
handles.length = Math.min(handles.length, 25);
17+
handles.length = Math.min(handles.length, 50);
1618

1719
Contest.aggregate(
1820
[

0 commit comments

Comments
 (0)