再更新一下,接之前的版本油猴脚本,抓取LeetCode题解[2],在原来的基础上增加了单独抓取单篇题解的功能,再做这个的时候其实遇到个问题,主要难点在于如何监听浏览器地址栏的变化,另开一个文章来说《无刷新页面监听URL变化实现》
至于新增这个功能的原因如下,在使用初版的功能全量的抓取了自己的题解文章之后,对于每天新增的题解不能单独抓取补充到indexDB之中,只能再次全量抓取一下,显然不太方便,所以又改进了下增加了这样的功能
另外我试了下,这个抓取用户题解文章列表的接口是不做权限验证的,也就是说A用户可以抓取B用户的所有题解列表,我找了下使用场景,当A用户点开某个B用户的LeetCode个人主页的时候,下面有一块展示区域是防止当前B用户的题解列表的。这是一个正常使用的场景,也就是说,你可以抓取任何你喜欢的用户的所有题解列表
另外修改了一点样式。在右边单独建了小的Panel把按钮都放进去,到处分散的不大好看,
下一步内容,解析题目、题解中的图片标签,抓取图片内容并base64之后作为文本保存下来,使题解内容脱离对力扣图片资源的依赖
// ==UserScript==
// @name LeetCode My Solution Collector
// @namespace http://blog.CheungQ.me/
// @version 0.3
// @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/";
/**
* 下载文件的
* @param value
* @param type
* @param name
*/
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}}
/**
* indexDB封装的
*/
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);
// console.log({eventName,eventParam})
}
};
let _historyWrap = function(type) {
const orig = history[type];
return function() {
const rv = orig.apply(this, arguments);
publishCustomEvent(type,arguments)
return rv;
};
};
history.pushState = _historyWrap('pushState');
history.replaceState = _historyWrap('replaceState');
let newElement = (tagName, styles) => {
let el = document.createElement(tagName)
if (styles && styles instanceof Object){
for (let styleKey in styles) {
if (el.style.hasOwnProperty(styleKey)){
el.style[styleKey] = styles[styleKey]
}
}
}
return el
}
let createPanel = (innerElements)=>{
let div = newElement("div",{
width: '2px',
background: 'rgb(56 56 56 / 77%)',
display: 'block',
position: 'fixed',
right: '0',
top: '100px',
paddingTop: '5px',
paddingRight: '4px',
borderRadius: '4px'
});
let wrapper = newElement("div",{
width: '100%',
height: 'auto',
display: 'block',
float: 'right',
overflow: 'hidden',
})
let icon = newElement("i",{
position: 'absolute',
left: '-20px',
top: '0px',
width: '0',
height: '0',
margin: '0 auto',
border: '10px solid transparent',
borderRightColor: 'gray',
})
icon.addEventListener('click',()=>{
div.style.width = div.style.width === '2px'?'auto':'2px'
if (icon.style.borderLeftColor === 'gray'){
icon.style.borderRightColor = 'gray'
icon.style.borderLeftColor = 'transparent'
icon.style.left = '-20px'
}else{
icon.style.borderLeftColor = 'gray'
icon.style.borderRightColor = 'transparent'
icon.style.left = '-10px'
}
})
if (innerElements){
for (let innerElement of innerElements) {
wrapper.append(innerElement)
}
}
div.append(icon)
div.append(wrapper)
document.body.append(div)
return div
}
let createButton = (func, btnText, additionStyles) => {
let btn = document.createElement("button")
let styles = {
padding: '5px',
color: 'white',
background: 'rgb(0, 0, 0)',
borderRadius: '5px',
float: 'right',
clear: 'both',
width: '55px',
marginBottom: '5px',
marginLeft: '7px',
fontSize: '12px',
}
if (additionStyles){
styles = {...styles, ...additionStyles}
}
for (let styleKey in styles) {
btn.style[styleKey] = styles[styleKey]
}
btn.innerText = btnText
btn.addEventListener("click", func)
return btn;
}
let solutionPath = (path) =>{
if (typeof path === 'string' && path.substr(0,10) === '/problems/'){
let pathArr = path.split("/");
if (pathArr.length === 6 && pathArr[3] === 'solution'){
return {
problem:pathArr[2],
solution:pathArr[4]
};
}
}
return false;
}
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",
};
//在这里可以定义一些你不需要的字段信息的Key
const KEYS_TO_BE_DELETED = {
QUESTION:[
'content',
'companyTagStats',
'envInfo',
'codeSnippets',
'contributors',
'langToValidPlayground',
],
SOLUTION:[
// 'author',
'next',
'prev',
'question',
]
}
const regex = /!\[(.+)\]\((.+)\)/g;
(function () {
'use strict';
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 });
}
})
//题解详情页单独抓取
let singleSolutionBtn = createButton(() => {
let {problem,solution} = solutionPath(window.location.pathname);
getQuestionAndSolution(problem, solution)
}, "本题解")
if (!solutionPath(window.location.pathname)){
singleSolutionBtn.style.display = 'none'
}
let statusBtn = createButton(null,"Status",{background:'gray'})
let panel = createPanel([
createButton(() => doErgodic(), "列表"),
createButton(() =>
db.getAll(storeName, null,0).then((r =>{
doSave(JSON.stringify(r),'text/latex', 'solutions.json')
}))
, "下载"),
singleSolutionBtn,
statusBtn,
])
// console.log(db);
let historyChangeHandler = (e)=>{
let {detail} = e
singleSolutionBtn.style.display = 'none'
let pathInfo = solutionPath(detail[2]);
if (pathInfo === false){
return
}
singleSolutionBtn.style.display = 'inherit'
}
window.addEventListener('pushState',historyChangeHandler,true)
window.addEventListener('replaceState',historyChangeHandler,true)
window.addEventListener('popstate',()=>{
if (!solutionPath(window.location.pathname)){
singleSolutionBtn.style.display = 'none'
}else{
singleSolutionBtn.style.display = 'inherit'
}
})
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,getQuestionAndSolutionList,false)
}
let eventListenerUnRegister = ()=>{
window.removeEventListener(CQ_EVENT_TYPES.NEXT_SOLUTION_PAGE,ergodicNextPage)
window.removeEventListener(CQ_EVENT_TYPES.NEW_SOLUTION_ARTICLES,getQuestionAndSolutionList)
}
let ergodicNextPage = (e)=>{
let {detail: {page}} = e
if (typeof page === "number"){
solutionPageQuery(page)
}
}
let getQuestionAndSolution = (questionTitleSlug, solutionSlug) =>{
Promise.all([
getQuestion(questionTitleSlug),
getSolution(solutionSlug)
]).then(res=>{
let [question,solutionArticle] = res;
for (let key of KEYS_TO_BE_DELETED.QUESTION) {
if (question.hasOwnProperty(key)){
delete question[key]
}
}
for (let key of KEYS_TO_BE_DELETED.SOLUTION) {
if (solutionArticle.hasOwnProperty(key)){
delete solutionArticle[key]
}
}
db.put(storeName, {slug: solutionSlug,question,solutionArticle})
statusBtn.style.background = 'gray'
}).catch((errorMessage) => {
console.error(errorMessage)
statusBtn.style.background = 'gray'
})
}
let getQuestionAndSolutionList = (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});
getQuestionAndSolution(questionTitleSlug,slug)
}
}
}
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)
})
})
}
})();
发表评论