接上篇,油猴脚本,抓取LeetCode题解,补充改版了下,顺便为啥要写这个呢?原因其实很简单,因为之前写了很多题解,都是直接在LeetCode上写的,加起来有快400篇了吧,然后自己这边博客每篇都要搬运一下,比较麻烦费事,所以才想着写了这么个工具。

这次改进做了如下

  1. 引入了IndexDBWrapper(https://www.npmjs.com/package/indexdbwrapper/v/1.0.4),封装了IndexDB的操作过程
  2. 直接任何力扣页面都可以操作了,账户需要登录
  3. 直接抓取我的题解了列表,之后根据列表再抓取题目和自己写的题解
  4. 使用了CustomEvent,抓取了列表之后,发起一个事件,页面同时监听事件,得到列表后进行抓取详细内容
  5. 定义了MY_ACCOUNT,需要手动替换为自己的账号ID
  6. 完成后点击下载按钮生成一个json文件下载
  7. 代码中对返回结果内容的字段进行了一些删除,删掉不需要的字段信息,缩小下载文件的大小

之后要做的事情,既然之前自己的写的题解已经拿到了,那么就是想办法再展示出来了,下一步可能想把下载得到的文件通过一个前端项目展示出来,不过这里还是有点问题,部分题解中含有图片内容,这个内容还是存在LeetCode服务器上的,这个还需要另外再想办法获取下来

下面是代码,另附直接的下载的文件

// ==UserScript==
// @name LeetCode My Solution Collector
// @namespace http://blog.CheungQ.me/
// @version 0.2
// @description 帮助你收集获取你在LeetCode上写的所有题解,将下面的MY_ACCOUNT 值修改为你的用户ID,最简单的查询方法,开的你LeetCode主页,地址栏URL中的最后一段就是了。例如:“https://leetcode.cn/u/张三的用户ID/”
// @author CheungQ@qq.com
// @match https://leetcode.cn/*
// @grant none
// ==/UserScript==



const MY_ACCOUNT = "在这里输入你的用户ID";
const LEETCODE_GRAPHQL = "https://leetcode.cn/graphql/";

class DBWrapper{constructor(name,version,{onupgradeneeded,onversionchange=this._onversionchange}={}){this._name=name;this._version=version;this._onupgradeneeded=onupgradeneeded;this._onversionchange=onversionchange;this._db=null}get db(){return this._db}async open(){if(this._db)return;this._db=await new Promise((resolve,reject)=>{let openRequestTimedOut=false;setTimeout(()=>{openRequestTimedOut=true;reject(new Error("The open request was blocked and timed out"))},this.OPEN_TIMEOUT);const openRequest=indexedDB.open(this._name,this._version);openRequest.onerror=()=>reject(openRequest.error);openRequest.onupgradeneeded=evt=>{if(openRequestTimedOut){openRequest.transaction.abort();evt.target.result.close()}else if(this._onupgradeneeded){this._onupgradeneeded(evt)}};openRequest.onsuccess=({target})=>{const db=target.result;if(openRequestTimedOut){db.close()}else{db.onversionchange=this._onversionchange.bind(this);resolve(db)}}});return this}async getKey(storeName,query){return(await this.getAllKeys(storeName,query,1))[0]}async getAll(storeName,query,count){return await this.getAllMatching(storeName,{query,count})}async getAllKeys(storeName,query,count){return(await this.getAllMatching(storeName,{query,count,includeKeys:true})).map(({key})=>key)}async getAllMatching(storeName,{index,query=null,direction="next",count,includeKeys}={}){return await this.transaction([storeName],"readonly",(txn,done)=>{const store=txn.objectStore(storeName);const target=index?store.index(index):store;const results=[];target.openCursor(query,direction).onsuccess=({target})=>{const cursor=target.result;if(cursor){const{primaryKey,key,value}=cursor;results.push(includeKeys?{primaryKey,key,value}:value);if(count&&results.length>=count){done(results)}else{cursor.continue()}}else{done(results)}}})}async transaction(storeNames,type,callback){await this.open();return await new Promise((resolve,reject)=>{const txn=this._db.transaction(storeNames,type);txn.onabort=({target})=>reject(target.error);txn.oncomplete=()=>resolve();callback(txn,value=>resolve(value))})}async _call(method,storeName,type,...args){const callback=(txn,done)=>{txn.objectStore(storeName)[method](...args).onsuccess=({target})=>{done(target.result)}};return await this.transaction([storeName],type,callback)}_onversionchange(){this.close()}close(){if(this._db){this._db.close();this._db=null}}static async deleteDatabase(name){await new Promise((resolve,reject)=>{const request=indexedDB.deleteDatabase(name);request.onerror=({target})=>{reject(target.error)};request.onblocked=()=>{reject(new Error("Delete blocked"))};request.onsuccess=()=>{resolve()}})}}DBWrapper.prototype.OPEN_TIMEOUT=2000;(function(){const methodsToWrap={readonly:["get","count","getKey","getAll","getAllKeys"],readwrite:["add","put","clear","delete"]};for(const[mode,methods]of Object.entries(methodsToWrap)){for(const method of methods){if(method in IDBObjectStore.prototype){DBWrapper.prototype[method]=async function(storeName,...args){return await this._call(method,storeName,mode,...args)}}}}})();
(function(){
try{
new window.CustomEvent('T');
}catch(e){
let CustomEvent = function(event, params){
params = params || { bubbles: false, cancelable: false, detail: undefined };
let evt = document.createEvent('CustomEvent');
evt.initCustomEvent(event, params.bubbles, params.cancelable, params.detail);
return evt;
};
CustomEvent.prototype = window.Event.prototype;
window.CustomEvent = CustomEvent;
}
})();
let publishCustomEvent = async ( eventName, eventParam , domElement) => {
if (domElement === undefined){
domElement = window;
}
if ( domElement instanceof EventTarget){
let customEvent = new CustomEvent(eventName, {detail:eventParam});
domElement.dispatchEvent(customEvent);
}
};

let doSave = (value,type,name)=>{let blob;if(typeof window.Blob=="function"){blob=new Blob([value],{type:type})}else{let BlobBuilder=window.BlobBuilder||window.MozBlobBuilder||window.WebKitBlobBuilder||window.MSBlobBuilder;let bb=new BlobBuilder();bb.append(value);blob=bb.getBlob(type)}let URL=window.URL||window.webkitURL;let blobUrl=URL.createObjectURL(blob);let anchor=document.createElement("a");if('download'in anchor){anchor.style.visibility="hidden";anchor.href=blobUrl;anchor.download=name;document.body.appendChild(anchor);let evt=document.createEvent("MouseEvents");evt.initEvent("click",true,true);anchor.dispatchEvent(evt);document.body.removeChild(anchor)}else if(navigator.msSaveBlob){navigator.msSaveBlob(blob,name)}else{location.href=blobUrl}}
const CQ_EVENT_TYPES = {
NEW_SOLUTION_ARTICLES:"NEW_SOLUTION_ARTICLES",
NEXT_SOLUTION_PAGE:"NEXT_SOLUTION_PAGE",
GET_SOLUTION_CONTENT:"GET_SOLUTION_CONTENT",
GET_QUESTION_CONTENT:"GET_QUESTION_CONTENT",
};

(function () {
'use strict';
let createButton = (func, btnText, top) => {
let btn = document.createElement("button")
btn.style.position = "fixed"
btn.style.right = 0
btn.style.top = top
btn.style.padding = "10px"
btn.style.color = "white"
btn.style.zIndex = 99999
btn.style.background = '#000000';
btn.style.borderRadius= '10px';
btn.innerText = btnText
btn.addEventListener("click", func)
document.body.append(btn)
return btn;
}

let dbName = 'solutionArticle', version = 1, storeName = 'mySolution'
let db = new DBWrapper(dbName,version,{
onupgradeneeded: e => {
const db = e.target.result;
const objStore = db.createObjectStore(storeName, {
// autoIncrement: true,
keyPath: 'slug'
});
objStore.createIndex("questionFrontendId", "question.questionFrontendId", { unique: !1 });
}
})
// console.log(db);
createButton(() => {
doErgodic();
}, "列表", "160px")
createButton(() => {
db.getAll(storeName, null,0).then((r =>{
doSave(JSON.stringify(r),'text/latex', 'solutions.json')
}))
}, "下载", "220px")

let statusBtn = createButton(null,"Status","100px")

let doErgodic = ()=>{
eventListenerRegister();
db.open();
solutionPageQuery(0)
// db.close();
// eventListenerUnRegister();
}

let eventListenerRegister = ()=>{
window.addEventListener(CQ_EVENT_TYPES.NEXT_SOLUTION_PAGE,ergodicNextPage,false)
window.addEventListener(CQ_EVENT_TYPES.NEW_SOLUTION_ARTICLES,getQuestionAndSolution,false)
}
let eventListenerUnRegister = ()=>{
window.removeEventListener(CQ_EVENT_TYPES.NEXT_SOLUTION_PAGE,ergodicNextPage)
window.removeEventListener(CQ_EVENT_TYPES.NEW_SOLUTION_ARTICLES,getQuestionAndSolution)
}


let ergodicNextPage = (e)=>{
let {detail: {page}} = e
if (typeof page === "number"){
solutionPageQuery(page)
}
}

let getQuestionAndSolution = (e) =>{
let {detail: {list}} = e
// console.log(list)
if (list instanceof Array){
for (let item of list) {
statusBtn.style.background = 'green'
let {node: {slug, question: {questionTitleSlug}}} = item;
// console.log({slug,questionTitleSlug});
Promise.all([
getQuestion(questionTitleSlug),
getSolution(slug)
]).then(res=>{
let [question,solutionArticle] = res;
// 在这里可以删掉一些你不需要的字段信息
delete question.content
delete question.companyTagStats
delete question.envInfo
delete question.codeSnippets
delete question.contributors
delete question.langToValidPlayground
delete solutionArticle.author
delete solutionArticle.next
delete solutionArticle.prev
delete solutionArticle.question
db.put(storeName, {slug,question,solutionArticle})
statusBtn.style.background = 'gray'
}).catch((errorMessage) => {
console.error(errorMessage)
statusBtn.style.background = 'gray'
})
}
}
}

let getQuestion = (slug) => {
let p = {
"operationName": "questionData",
"variables": {"titleSlug": slug},
"query": "query questionData($titleSlug: String!) {\n question(titleSlug: $titleSlug) {\n questionId\n questionFrontendId\n categoryTitle\n boundTopicId\n title\n titleSlug\n content\n translatedTitle\n translatedContent\n isPaidOnly\n difficulty\n likes\n dislikes\n isLiked\n similarQuestions\n contributors {\n username\n profileUrl\n avatarUrl\n __typename\n }\n langToValidPlayground\n topicTags {\n name\n slug\n translatedName\n __typename\n }\n companyTagStats\n codeSnippets {\n lang\n langSlug\n code\n __typename\n }\n stats\n hints\n solution {\n id\n canSeeDetail\n __typename\n }\n status\n sampleTestCase\n metaData\n judgerAvailable\n judgeType\n mysqlSchemas\n enableRunCode\n envInfo\n book {\n id\n bookName\n pressName\n source\n shortDescription\n fullDescription\n bookImgUrl\n pressImgUrl\n productUrl\n __typename\n }\n isSubscribed\n isDailyQuestion\n dailyRecordStatus\n editorType\n ugcQuestionId\n style\n exampleTestcases\n jsonExampleTestcases\n __typename\n }\n}\n"
}
return leetCodeRequest(p,data=>data.data.question)
}

let getSolution = (slug) =>{
let param = {
"operationName": "solutionDetailArticle",
"variables": {
"slug": slug,
"orderBy": "DEFAULT"
},
"query": "query solutionDetailArticle($slug: String!, $orderBy: SolutionArticleOrderBy!) {\n solutionArticle(slug: $slug, orderBy: $orderBy) {\n ...solutionArticle\n content\n question {\n questionTitleSlug\n __typename\n }\n position\n next {\n slug\n title\n __typename\n }\n prev {\n slug\n title\n __typename\n }\n __typename\n }\n}\n\nfragment solutionArticle on SolutionArticleNode {\n rewardEnabled\n canEditReward\n uuid\n title\n slug\n sunk\n chargeType\n status\n identifier\n canEdit\n canSee\n reactionType\n reactionsV2 {\n count\n reactionType\n __typename\n }\n tags {\n name\n nameTranslated\n slug\n tagType\n __typename\n }\n createdAt\n thumbnail\n author {\n username\n profile {\n userAvatar\n userSlug\n realName\n __typename\n }\n __typename\n }\n summary\n topic {\n id\n commentCount\n viewCount\n __typename\n }\n byLeetcode\n isMyFavorite\n isMostPopular\n isEditorsPick\n hitCount\n videosInfo {\n videoId\n coverUrl\n duration\n __typename\n }\n __typename\n}\n"
}
return leetCodeRequest(param,data=>data.data.solutionArticle )
}


let solutionPageQuery = (page) => {
let pageSize = 50;
let param = {
"operationName": "solutionArticles",
"variables": {
"userSlug": MY_ACCOUNT,
"skip": page * pageSize,
"first": 50,
"query": ""
},
"query": "query solutionArticles($userSlug: String!, $skip: Int, $first: Int, $query: String) {\n solutionArticles(userSlug: $userSlug, skip: $skip, first: $first, query: $query) {\n totalNum\n edges {\n node {\n ...solutionArticle\n question {\n questionTitle\n questionTitleSlug\n __typename\n }\n __typename\n }\n __typename\n }\n __typename\n }\n}\n\nfragment solutionArticle on SolutionArticleNode {\n ipRegion\n rewardEnabled\n canEditReward\n uuid\n title\n slug\n sunk\n chargeType\n status\n identifier\n canEdit\n canSee\n reactionType\n reactionsV2 {\n count\n reactionType\n __typename\n }\n tags {\n name\n nameTranslated\n slug\n tagType\n __typename\n }\n createdAt\n thumbnail\n author {\n username\n profile {\n userAvatar\n userSlug\n realName\n __typename\n }\n __typename\n }\n summary\n topic {\n id\n commentCount\n viewCount\n __typename\n }\n byLeetcode\n isMyFavorite\n isMostPopular\n isEditorsPick\n hitCount\n videosInfo {\n videoId\n coverUrl\n duration\n __typename\n }\n __typename\n}\n"
}
return leetCodeRequest(param,data=>{
let {edges} = data.data.solutionArticles;
if (edges instanceof Array && edges.length > 0){
publishCustomEvent(CQ_EVENT_TYPES.NEW_SOLUTION_ARTICLES,{list:edges})
publishCustomEvent(CQ_EVENT_TYPES.NEXT_SOLUTION_PAGE,{page:page+1})
}
})
}


let leetCodeRequest = (param,callback) =>{
let options = {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify(param)
}
return new Promise((resolve, reject) => {
fetch(LEETCODE_GRAPHQL, options).then((res) => {
if (res.ok) {
//如果取数据成功
res.json().then((data) => {
resolve(callback(data))
})
} else {
console.error(res.status);
reject(res)
//查看获取状态
}
}).catch((res) => {
//输出一些错误信息
console.error(res.status);
reject(res)
})
})
}
// Your code here...
})();