Backup douban items (currently book items only), download as a JSON file.
// ==UserScript==
// @name Backup Douban Items
// @namespace ylf8fzzd
// @version 0.1.0
// @description Backup douban items (currently book items only), download as a JSON file.
// @author ylf8fzzd
// @match https://book.douban.com/people/*/do
// @match https://book.douban.com/people/*/wish
// @match https://book.douban.com/people/*/collect
// @match https://book.douban.com/people/*/all
// @grant none
// ==/UserScript==
(() => {
'use strict';
class BackupWrapper {
doubanItems;
pageSum = 0;
itemSum = 0;
finished = false;
stopped = false;
wrapperLocationSelector;
wrapperId = 'db-backup-wrapper';
optionClass = 'backup-option';
inputId = 'backup-pages';
errorClass = 'backup-error';
buttonName = 'backup-button';
buttonStartClass = 'backup-start';
buttonStopClass = 'backup-stop';
buttonResumeClass = 'backup-resume';
progressBarClass = 'backup-progress';
downloadLinkClass = 'backup-download';
defaultPageNumber = 0;
constructor(){
this.doubanItems = new DoubanBookItems();
this.wrapperLocationSelector = '#db-usr-profile';
this.initWrapper();
}
initWrapper() {
// create wrapper
let wrapper = this.wrapper;
if (wrapper) {
wrapper.remove();
}
wrapper = document.createElement('div');
wrapper.id = this.wrapperId;
document.querySelector(this.wrapperLocationSelector).after(wrapper);
this.initStyle();
this.initOptions();
this.initErrorMsg();
this.initProgressBar();
this.initDownloadLink();
}
get wrapper() {
return document.querySelector(`#${this.wrapperId}`);
}
initStyle() {
const style = document.createElement('style');
style.innerHTML = `
#db-backup-wrapper {
font-size: 1.3em;
margin-bottom: 26px;
}
#db-backup-wrapper input {
font-size: 1em;
}
#db-backup-wrapper label {
padding: 0 10px 0 0;
}
#db-backup-wrapper .backup-error {
color: red;
}
.backup-progress .page-num, .backup-progress .item-num{
font-size: 1.5em;
}
`;
this.wrapper.append(style);
}
initOptions() {
const options = document.createElement('div');
options.className = this.optionClass;
// label
const label = document.createElement('label');
label.htmlFor = this.inputId;
label.title = '1: backup _only current page.\n' +
'2: backup current and next 1 page and so _on.\n' +
'0: backup current and all remaining pages.';
label.innerText = 'How many pages to backup?';
// input
const input = document.createElement('input');
input.id = this.inputId;
input.name = this.inputId;
input.type = 'number';
input.min = 0;
input.size = 8;
input.value = this.defaultPageNumber;
input.required = true;
// button
const button = document.createElement('button');
button.name = this.buttonName;
button.className = this.buttonStartClass;
button.title = 'Click to start preparing backup data.';
button.innerText = 'Start';
button.addEventListener('click', this.buttonEventHandler);
options.append(label);
options.append(input);
options.append(button);
this.wrapper.append(options);
}
get option() {
return this.wrapper.querySelector(`.${this.optionClass}`);
}
get optionPageNum() {
let pageNum = this.option.querySelector(`#${this.inputId}`).value;
return parseInt(pageNum); // NaN when pageNum is not a number.
}
get optionButton() {
return this.option.querySelector(`[name=${this.buttonName}]`);
}
// when Start button clicked.
_onStart() {
this.doubanItems.clearItems();
this.pageSum = 0;
this.itemSum = 0;
this.optionButton.innerText = 'Stop';
this.optionButton.className = this.buttonStopClass;
console.log('Start.');
}
// when Stop button clicked.
_onStop() {
this.stopped = true;
this.optionButton.innerText = 'Resume';
this.optionButton.className = this.buttonResumeClass;
console.log('Stop begin.');
}
// when loading pages stopped
_onStopEnd() {
this.updateDownloadLink();
console.log('Stop End.');
}
// when Resume button clicked.
_onResume() {
this.stopped = false;
this.optionButton.innerText = 'Stop';
this.optionButton.className = this.buttonStopClass;
console.log('Resume.');
}
_onPageLoaded() {
this.updateProgressBar();
console.log(`${this.pageSum} pages, ${this.itemSum} items loaded.`);
}
_onError(error) {
this.updateErrorMsg(error.message);
this._onStop();
this._onStopEnd();
console.log(error);
}
_onFinised(){
this.optionButton.className = this.buttonStartClass;
this.optionButton.innerText = 'Start';
this.finished = true;
this.stopped = false;
this.updateProgressBar();
this.updateDownloadLink();
// return to first status.
this.finished = false;
console.log('Finished.');
}
buttonEventHandler = async (event) => {
this.clearErrorMsg();
const button = event.target;
// check page number option.
const pagesToLoad = this.optionPageNum;
if (isNaN(pagesToLoad)) {
const error = TypeError('Invalid page number.');
this._onError(error);
return Promise.resolve(error);
}
if (button.className === this.buttonStartClass) {
this._onStart();
} else if (button.className === this.buttonResumeClass) {
this._onResume();
} else {
this._onStop();
return Promise.resolve('Stop begin.');
}
if (this.finished) {
return Promise.resolve('Already finished.');
}
while (true) {
// This should be checked before checking stopped or not,
// in case all pages have been loaded between start and end
// timing of stop.
if (pagesToLoad !== 0 && this.pageSum >= pagesToLoad) {
this._onFinised();
return Promise.resolve('Done.');
}
// if stopped by user.
if (this.stopped) {
this._onStopEnd();
return Promise.resolve('Stopped.');
}
let itemNum;
try {
itemNum = await this.doubanItems.loadPage(this.pageSum + 1);
} catch (error) {
this._onError(error);
return Promise.resolve(error);
}
if (itemNum === 0) {
this._onFinised();
return Promise.resolve('Done.')
}
this.pageSum += 1;
this.itemSum += itemNum;
this._onPageLoaded();
}
}
initErrorMsg() {
const error = document.createElement('div');
error.className = this.errorClass;
this.wrapper.append(error);
this.clearErrorMsg();
}
get errorMsg() {
return this.wrapper.querySelector(`.${this.errorClass}`);
}
updateErrorMsg(msg) {
const error = this.errorMsg;
error.style = 'display: block;';
error.innerText = msg;
}
clearErrorMsg() {
const error = this.errorMsg;
error.style = 'display: none;';
error.innerText = '';
}
initProgressBar() {
// init progress bar
const progressBar = document.createElement('div');
progressBar.className = this.progressBarClass;
progressBar.innerHTML = `
<span class="page-num">0</span> pages,
<span class="item-num">0</span> items loaded.
<span class="status">Loading more pages.</span>
`;
progressBar.style = 'display: none;';
this.wrapper.append(progressBar);
}
get progressBar() {
return this.wrapper.querySelector(`.${this.progressBarClass}`);
}
// update progress bar with specified page number.
// if finished, update the whole message.
updateProgressBar(){
const progressBar = this.progressBar;
progressBar.style = 'dispaly: block;';
progressBar.querySelector('.page-num').innerText = this.pageSum;
progressBar.querySelector('.item-num').innerText = this.itemSum;
if (this.stopped) {
progressBar.querySelector('.status').innerText = 'Stopped.';
} else if (!this.finished) {
progressBar.querySelector('.status').innerText = 'Loading more pages...';
} else {
progressBar.querySelector('.status').innerText = 'Finished.';
}
}
initDownloadLink() {
// init download link
const a = document.createElement('a');
a.href = '#';
a.className = this.downloadLinkClass;
a.textContent = 'Download Backup';
a.style = 'display: none;';
this.wrapper.append(a);
}
get downloadLink(){
return this.wrapper.querySelector(`.${this.downloadLinkClass}`);
}
updateDownloadLink(){
const output = this.doubanItems.getItemsData();
const contentType = 'application/json';
const filename = 'backup.json';
const blob = new Blob([output], {type: contentType});
const url = URL.createObjectURL(blob);
const a = this.downloadLink;
a.style = 'display: inline;';
a.href = url;
a.setAttribute('download', filename);
console.log('Download link updated.');
}
}
class DoubanItems {
allItemsSelector;
itemCountPerPage;
getItemData;
items = [];
getItemsData() {
const data = [];
const getItemData = this.getItemData;
for (const item of this.items) {
data.push(getItemData(item));
}
return JSON.stringify(data, null, 2);
}
clearItems() {
this.items = [];
}
// return value: promise that resovle to item number of fetched page.
async loadPage(page) {
const urlSearchParams = new URLSearchParams(window.location.search);
let currentStart = urlSearchParams.get('start');
if (!currentStart) {
currentStart = 0;
} else {
currentStart = parseInt(currentStart);
}
const newStart = currentStart + this.itemCountPerPage * (page - 1);
const baseUrl = window.location.origin + window.location.pathname + '?';
let url = baseUrl + 'start=' + newStart;
let response;
try{
response = await fetch(url);
} catch (error){
console.log(`Failed to fetch ${url}. ${error}`);
return Promise.reject(error);
}
if (response.status !== 200){
return Promise.reject(Error('Response status is not 200.'));
console.log(`Failed to fetch ${url}. Status code: ${response.status}.`);
}
console.log(`${url} fetched.`);
const html = await response.text();
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const items = doc.querySelectorAll(this.allItemsSelector);
this.items.push(...items);
return Promise.resolve(items.length);
}
}
class DoubanBookItems extends DoubanItems {
allItemsSelector = '.interest-list .subject-item';
itemCountPerPage = 15;
getItemData = (item) => {
const titleNode = item.querySelector('h2 a')
let title = titleNode ? titleNode.innerText.trim(): '';
// remove redundant newline and spaces.
title = title.replace(/[\n\r]/g, '').replace(/ +/, ' ');
const pubNode = item.querySelector('.pub');
const pub = pubNode ? pubNode.innerText.trim() : '';
const linkNode = item.querySelector('h2 a')
const link = linkNode ? linkNode.getAttribute('href') : '';
// <span class="rating4-t"></span>
const ratingNode = item.querySelector('[class^=rating]');
const rating = ratingNode ? ratingNode.getAttribute('class').
replace('rating', '').replace('-t', ''): '';
// <span class="date">2019-02-24 读过</span>
const dateAndStatusNode = item.querySelector('.date');
let date;
let status;
if (dateAndStatusNode) {
const dateAndStatus = dateAndStatusNode.innerText.replace(/[\n\r]/g, '');
date = dateAndStatus.split(/ +/)[0];
status = dateAndStatus.split(/ +/)[1];
} else {
// 未知图书: book that have been deleted by website
date = '';
status = '';
}
const commentNode = item.querySelector('.comment');
const comment = commentNode ? commentNode.innerText.trim() : "";
// <span class="tags">标签: XX ZZ</span>
const tagsNode = item.querySelector('.tags');
const tags = tagsNode ? tagsNode.innerText.replace(/[\n\r]/g, '').
replace('标签: ','').split(/ +/) : [];
return { title, pub, link, rating, date, status, tags, comment }
}
}
new BackupWrapper();
})();