// ==UserScript== // @name migrate douban collection to bangumi // @name:zh-CN 迁移豆瓣收藏到 Bangumi // @namespace https://github.com/22earth // @description migrate douban collection to bangumi and export douban collection // @description:zh-cn 迁移豆瓣动画收藏到 Bangumi. // @include /^https?:\/\/(bangumi|bgm|chii)\.(tv|in)\/?$/ // @include https://movie.douban.com/mine // @include https://search.douban.com/movie/subject_search* // @author 22earth // @homepage https://github.com/22earth/gm_scripts // @version 0.0.7 // @run-at document-end // @grant GM_registerMenuCommand // @grant GM_xmlhttpRequest // @grant GM_addStyle // @grant GM_setValue // @grant GM_getValue // @grant GM_getResourceText // @require https://cdn.staticfile.org/fuse.js/6.4.0/fuse.min.js // @resource bangumiDataURL https://registry.npmmirror.com/bangumi-data/latest/files/dist/data.json // ==/UserScript== (function () { 'use strict'; function sleep(num) { return new Promise((resolve) => { setTimeout(resolve, num); }); } function randomSleep(max = 400, min = 200) { return sleep(randomNum(max, min)); } function randomNum(max, min) { return Math.floor(Math.random() * (max - min + 1)) + min; } // support GM_XMLHttpRequest let retryCounter = 0; let USER_SITE_CONFIG = {}; function getSiteConfg(url, host) { let hostname = host; if (!host) { hostname = new URL(url)?.hostname; } const config = USER_SITE_CONFIG[hostname] || {}; return config; } function mergeOpts(opts, config) { return { ...opts, ...config, headers: { ...opts?.headers, ...config?.headers, }, }; } function fetchInfo(url, type, opts = {}, TIMEOUT = 10 * 1000) { const method = opts?.method?.toUpperCase() || 'GET'; opts = mergeOpts(opts, getSiteConfg(url)); // @ts-ignore { const gmXhrOpts = { ...opts }; if (method === 'POST' && gmXhrOpts.body) { gmXhrOpts.data = gmXhrOpts.body; } if (opts.decode) { type = 'arraybuffer'; } return new Promise((resolve, reject) => { // @ts-ignore GM_xmlhttpRequest({ method, timeout: TIMEOUT, url, responseType: type, onload: function (res) { if (res.status === 404) { retryCounter = 0; reject(404); } else if (res.status === 302 && retryCounter < 5) { retryCounter++; resolve(fetchInfo(res.finalUrl, type, opts, TIMEOUT)); } if (opts.decode && type === 'arraybuffer') { retryCounter = 0; let decoder = new TextDecoder(opts.decode); resolve(decoder.decode(res.response)); } else { retryCounter = 0; resolve(res.response); } }, onerror: (e) => { retryCounter = 0; reject(e); }, ...gmXhrOpts, }); }); } } function fetchText(url, opts = {}, TIMEOUT = 10 * 1000) { return fetchInfo(url, 'text', opts, TIMEOUT); } function fetchJson(url, opts = {}) { return fetchInfo(url, 'json', opts); } var SubjectTypeId; (function (SubjectTypeId) { SubjectTypeId[SubjectTypeId["book"] = 1] = "book"; SubjectTypeId[SubjectTypeId["anime"] = 2] = "anime"; SubjectTypeId[SubjectTypeId["music"] = 3] = "music"; SubjectTypeId[SubjectTypeId["game"] = 4] = "game"; SubjectTypeId[SubjectTypeId["real"] = 6] = "real"; SubjectTypeId["all"] = "all"; })(SubjectTypeId || (SubjectTypeId = {})); function formatDate(time, fmt = 'yyyy-MM-dd') { const date = new Date(time); var o = { 'M+': date.getMonth() + 1, 'd+': date.getDate(), 'h+': date.getHours(), 'm+': date.getMinutes(), 's+': date.getSeconds(), 'q+': Math.floor((date.getMonth() + 3) / 3), S: date.getMilliseconds(), //毫秒 }; if (/(y+)/i.test(fmt)) { fmt = fmt.replace(RegExp.$1, (date.getFullYear() + '').substr(4 - RegExp.$1.length)); } for (var k in o) { if (new RegExp('(' + k + ')', 'i').test(fmt)) { fmt = fmt.replace(RegExp.$1, RegExp.$1.length == 1 ? o[k] : ('00' + o[k]).substr(('' + o[k]).length)); } } return fmt; } function dealDate(dataStr) { // 2019年12月19 let l = []; if (/\d{4}年\d{1,2}月(\d{1,2}日?)?/.test(dataStr)) { l = dataStr .replace('日', '') .split(/年|月/) .filter((i) => i); } else if (/\d{4}\/\d{1,2}(\/\d{1,2})?/.test(dataStr)) { l = dataStr.split('/'); } else if (/\d{4}-\d{1,2}(-\d{1,2})?/.test(dataStr)) { return dataStr; } else { return dataStr; } return l .map((i) => { if (i.length === 1) { return `0${i}`; } return i; }) .join('-'); } function isEqualDate(d1, d2, type = 'd') { const resultDate = new Date(d1); const originDate = new Date(d2); if (type === 'y') { return resultDate.getFullYear() === originDate.getFullYear(); } if (type === 'm') { return (resultDate.getFullYear() === originDate.getFullYear() && resultDate.getMonth() === originDate.getMonth()); } if (resultDate.getFullYear() === originDate.getFullYear() && resultDate.getMonth() === originDate.getMonth() && resultDate.getDate() === originDate.getDate()) { return true; } return false; } function normalizeQuery(query) { let newQuery = query .replace(/^(.*?~)(.*)(~[^~]*)$/, function (_, p1, p2, p3) { return p1.replace(/~/g, ' ') + p2 + p3.replace(/~/g, ' '); }) .replace(/=|=/g, ' ') .replace(/ /g, ' ') .replace(/0/g, '0') .replace(/1/g, '1') .replace(/2/g, '2') .replace(/3/g, '3') .replace(/4/g, '4') .replace(/5/g, '5') .replace(/6/g, '6') .replace(/7/g, '7') .replace(/8/g, '8') .replace(/9/g, '9') .replace(/Ⅰ/g, 'I') .replace(/Ⅱ/g, 'II') .replace(/Ⅲ/g, 'III') .replace(/Ⅳ/g, 'IV') .replace(/Ⅴ/g, 'V') .replace(/Ⅵ/g, 'VI') .replace(/Ⅶ/g, 'VII') .replace(/Ⅷ/g, 'VIII') .replace(/Ⅸ/g, 'IX') .replace(/Ⅹ/g, 'X') .replace(/[-―~〜━\[\]『』~'…!?。♥☆\/♡★‥○, 【】◆×▼’&'"*?]/g, ' ') .replace(/[.・]/g, ' ') //.replace(/ー/g, " ") .replace(/\.\.\./g, ' ') .replace(/~っ.*/, '') .replace(/\(.*?\)/g, '') .replace(/\(.*?\)/g, ' ') .replace(/<.+?>/, '') .replace(/<.+?>/, '') .replace(/\s-[^-]+?-$/, '') .trim(); // newQuery = replaceCharToSpace(newQuery); newQuery = newQuery.replace(/\s{2,}/g, ' '); // game: 14 -one & four or the other meaning- if (/^\d+$/.test(newQuery)) { return query; } return newQuery; } const SEARCH_RESULT = 'search_result'; /** * 过滤搜索结果: 通过名称以及日期 * @param items * @param subjectInfo * @param opts */ function filterResults(items, subjectInfo, opts = {}, isSearch = true) { if (!items) return; // 只有一个结果时直接返回, 不再比较日期 if (items.length === 1 && isSearch) { return items[0]; } // 使用发行日期过滤 if (subjectInfo.releaseDate && opts.releaseDate) { const obj = items.find((item) => isEqualDate(item.releaseDate, subjectInfo.releaseDate)); if (obj) { return obj; } } var results = new Fuse(items, Object.assign({}, opts)).search(subjectInfo.name); // 去掉括号包裹的,再次模糊查询 if (!results.length && /<|<|\(|(/.test(subjectInfo.name)) { results = new Fuse(items, Object.assign({}, opts)).search(subjectInfo.name .replace(/<.+>/g, '') .replace(/<.+>/g, '') .replace(/(.+)/g, '') .replace(/\(.+\)/g, '')); } if (!results.length) { return; } // 有参考的发布时间 if (subjectInfo.releaseDate) { const sameYearResults = []; const sameMonthResults = []; for (const obj of results) { const result = obj.item; if (result.releaseDate) { // 只有年的时候 if (result.releaseDate.length === 4) { if (result.releaseDate === subjectInfo.releaseDate.slice(0, 4)) { return result; } } else { if (isEqualDate(result.releaseDate, subjectInfo.releaseDate)) { return result; } } if (isEqualDate(result.releaseDate, subjectInfo.releaseDate, 'm')) { sameMonthResults.push(obj); continue; } if (isEqualDate(result.releaseDate, subjectInfo.releaseDate, 'y')) { sameYearResults.push(obj); } } } if (sameMonthResults.length) { return sameMonthResults[0].item; } if (sameYearResults.length) { return sameYearResults[0].item; } } // 比较名称 const nameRe = new RegExp(subjectInfo.name.trim()); for (const item of results) { const result = item.item; if (nameRe.test(result.name) || nameRe.test(result.greyName) || nameRe.test(result.rawName)) { return result; } } } const typeIdDict$1 = { dropped: { name: '抛弃', id: '5', }, on_hold: { name: '搁置', id: '4', }, do: { name: '在看', id: '3', }, collect: { name: '看过', id: '2', }, wish: { name: '想看', id: '1', }, }; function findInterestStatusById(id) { for (let key in typeIdDict$1) { const obj = typeIdDict$1[key]; if (obj.id === id) { return { key: key, ...obj, }; } } } async function getSearchResultByGM() { return new Promise((resolve, reject) => { const listenId = window.gm_val_listen_id; if (listenId) { GM_removeValueChangeListener(listenId); } window.gm_val_listen_id = GM_addValueChangeListener( // const listenId = GM_addValueChangeListener( SEARCH_RESULT, (n, oldValue, newValue) => { console.log('enter promise'); const now = +new Date(); if (newValue.type === SEARCH_RESULT && newValue.timestamp && newValue.timestamp < now) { // GM_removeValueChangeListener(listenId); resolve(newValue.data); } reject('mismatch timestamp'); }); }); } async function setSearchResultByGM(data) { const res = { type: SEARCH_RESULT, timestamp: +new Date(), data, }; GM_setValue(SEARCH_RESULT, res); } function getBgmHost() { return `${location.protocol}//${location.host}`; } function getSubjectId$1(url) { const m = url.match(/(?:subject|character)\/(\d+)/); if (!m) return ''; return m[1]; } function getUserId$1(url) { // https://bgm.tv/user/a_little const m = url.match(/user\/(.*)/); if (!m) return ''; return m[1]; } function insertLogInfo($sibling, txt) { const $log = document.createElement('div'); $log.classList.add('e-wiki-log-info'); // $log.setAttribute('style', 'color: tomato;'); $log.innerHTML = txt; $sibling.parentElement.insertBefore($log, $sibling); $sibling.insertAdjacentElement('afterend', $log); return $log; } function convertItemInfo$1($item) { let $subjectTitle = $item.querySelector('h3>a.l'); let itemSubject = { name: $subjectTitle.textContent.trim(), rawInfos: $item.querySelector('.info').textContent.trim(), // url 没有协议和域名 url: $subjectTitle.getAttribute('href'), greyName: $item.querySelector('h3>.grey') ? $item.querySelector('h3>.grey').textContent.trim() : '', }; let matchDate = $item .querySelector('.info') .textContent.match(/\d{4}[\-\/\年]\d{1,2}[\-\/\月]\d{1,2}/); if (matchDate) { itemSubject.releaseDate = dealDate(matchDate[0]); } const $rateInfo = $item.querySelector('.rateInfo'); if ($rateInfo) { const rateInfo = {}; if ($rateInfo.querySelector('.fade')) { rateInfo.score = $rateInfo.querySelector('.fade').textContent; rateInfo.count = $rateInfo .querySelector('.tip_j') .textContent.replace(/[^0-9]/g, ''); } else { rateInfo.score = '0'; rateInfo.count = '少于10'; } itemSubject.rateInfo = rateInfo; } const $rank = $item.querySelector('.rank'); if ($rank) { itemSubject.rank = $rank.textContent.replace('Rank', '').trim(); } const $collectInfo = $item.querySelector('.collectInfo'); const collectInfo = {}; const $comment = $item.querySelector('#comment_box'); if ($comment) { collectInfo.comment = $comment.textContent.trim(); } if ($collectInfo) { const textArr = $collectInfo.textContent.split('/'); collectInfo.date = textArr[0].trim(); textArr.forEach((str) => { if (str.match('标签')) { collectInfo.tags = str.replace(/标签:/, '').trim(); } }); const $starlight = $collectInfo.querySelector('.starlight'); if ($starlight) { $starlight.classList.forEach((s) => { if (/stars\d/.test(s)) { collectInfo.score = s.replace('stars', ''); } }); } } if (Object.keys(collectInfo).length) { collectInfo.tags = collectInfo.tags || ''; collectInfo.comment = collectInfo.comment || ''; itemSubject.collectInfo = collectInfo; } const $cover = $item.querySelector('.subjectCover img'); if ($cover && $cover.tagName.toLowerCase() === 'img') { // 替换 cover/s ---> cover/l 是大图 const src = $cover.getAttribute('src') || $cover.getAttribute('data-cfsrc'); if (src) { itemSubject.cover = src.replace('pic/cover/s', 'pic/cover/l'); } } return itemSubject; } function getItemInfos$1($doc = document) { const items = $doc.querySelectorAll('#browserItemList>li'); const res = []; for (const item of Array.from(items)) { res.push(convertItemInfo$1(item)); } return res; } function getTotalPageNum$1($doc = document) { const $multipage = $doc.querySelector('#multipage'); let totalPageNum = 1; const pList = $multipage?.querySelectorAll('.page_inner>.p'); if (pList && pList.length) { let tempNum = parseInt(pList[pList.length - 2].getAttribute('href').match(/page=(\d*)/)[1]); totalPageNum = parseInt(pList[pList.length - 1].getAttribute('href').match(/page=(\d*)/)[1]); totalPageNum = totalPageNum > tempNum ? totalPageNum : tempNum; } return totalPageNum; } function genCollectionURL$1(userId, subjectType, interestType) { const dict = { movie: 'anime', music: 'music', book: 'book', }; return `https://bgm.tv/${dict[subjectType]}/list/${userId}/${interestType}`; } async function getAllPageInfo$1(userId, subjectType, interestType) { const url = genCollectionURL$1(userId, subjectType, interestType); console.info('bgm collection page: ', url); const rawText = await fetchText(url); const $doc = new DOMParser().parseFromString(rawText, 'text/html'); const totalPageNum = getTotalPageNum$1($doc); const res = [...getItemInfos$1($doc)]; let page = 2; while (page <= totalPageNum) { let reqUrl = url; const m = url.match(/page=(\d*)/); if (m) { reqUrl = reqUrl.replace(m[0], `page=${page}`); } else { reqUrl = `${reqUrl}?page=${page}`; } await sleep(500); console.info('fetch info: ', reqUrl); const rawText = await fetchText(reqUrl); const $doc = new DOMParser().parseFromString(rawText, 'text/html'); res.push(...getItemInfos$1($doc)); page += 1; } return res; } function loadIframe$1($iframe, subjectId) { return new Promise((resolve, reject) => { $iframe.src = `/update/${subjectId}`; let timer = setTimeout(() => { timer = null; reject('bangumi iframe timeout'); }, 5000); $iframe.onload = () => { clearTimeout(timer); $iframe.onload = null; resolve(null); }; }); } async function getUpdateForm(subjectId) { const iframeId = 'e-userjs-update-interest'; let $iframe = document.querySelector(`#${iframeId}`); if (!$iframe) { $iframe = document.createElement('iframe'); $iframe.style.display = 'none'; $iframe.id = iframeId; document.body.appendChild($iframe); } await loadIframe$1($iframe, subjectId); const $form = $iframe.contentDocument.querySelector('#collectBoxForm'); return $form; // return $form.action; } /** * 更新用户收藏 * @param subjectId 条目 id * @param data 更新数据 */ async function updateInterest$1(subjectId, data) { // gh 暂时不知道如何获取,直接拿 action 了 const $form = await getUpdateForm(subjectId); const formData = new FormData($form); const obj = Object.assign({ referer: 'ajax', tags: '', comment: '', update: '保存' }, data); for (let [key, val] of Object.entries(obj)) { if (!formData.has(key)) { formData.append(key, val); } else { // 标签和吐槽可以直接清空 if (['tags', 'comment', 'rating'].includes(key)) { formData.set(key, val); } else if (!formData.get(key) && val) { formData.set(key, val); } } } await fetch($form.action, { method: 'POST', body: formData, }); } var BangumiDomain; (function (BangumiDomain) { BangumiDomain["chii"] = "chii.in"; BangumiDomain["bgm"] = "bgm.tv"; BangumiDomain["bangumi"] = "bangumi.tv"; })(BangumiDomain || (BangumiDomain = {})); var Protocol; (function (Protocol) { Protocol["http"] = "http"; Protocol["https"] = "https"; })(Protocol || (Protocol = {})); function getSearchItem($item) { let $subjectTitle = $item.querySelector('h3>a.l'); let info = { name: $subjectTitle.textContent.trim(), // url 没有协议和域名 url: $subjectTitle.getAttribute('href'), greyName: $item.querySelector('h3>.grey') ? $item.querySelector('h3>.grey').textContent.trim() : '', }; let matchDate = $item .querySelector('.info') .textContent.match(/\d{4}[\-\/\年]\d{1,2}[\-\/\月]\d{1,2}/); if (matchDate) { info.releaseDate = dealDate(matchDate[0]); } let $rateInfo = $item.querySelector('.rateInfo'); if ($rateInfo) { if ($rateInfo.querySelector('.fade')) { info.score = $rateInfo.querySelector('.fade').textContent; info.count = $rateInfo .querySelector('.tip_j') .textContent.replace(/[^0-9]/g, ''); } else { info.score = '0'; info.count = '少于10'; } } else { info.score = '0'; info.count = '0'; } return info; } function extractInfoList($doc) { return [...$doc.querySelectorAll('#browserItemList>li')].map($item => { return getSearchItem($item); }); } /** * 处理搜索页面的 html * @param info 字符串 html */ function dealSearchResults(info) { const results = []; let $doc = new DOMParser().parseFromString(info, 'text/html'); let items = $doc.querySelectorAll('#browserItemList>li>div.inner'); // get number of page let numOfPage = 1; let pList = $doc.querySelectorAll('.page_inner>.p'); if (pList && pList.length) { let tempNum = parseInt(pList[pList.length - 2].getAttribute('href').match(/page=(\d*)/)[1]); numOfPage = parseInt(pList[pList.length - 1].getAttribute('href').match(/page=(\d*)/)[1]); numOfPage = numOfPage > tempNum ? numOfPage : tempNum; } if (items && items.length) { for (const item of Array.prototype.slice.call(items)) { let $subjectTitle = item.querySelector('h3>a.l'); let itemSubject = { name: $subjectTitle.textContent.trim(), // url 没有协议和域名 url: $subjectTitle.getAttribute('href'), greyName: item.querySelector('h3>.grey') ? item.querySelector('h3>.grey').textContent.trim() : '', }; let matchDate = item .querySelector('.info') .textContent.match(/\d{4}[\-\/\年]\d{1,2}[\-\/\月]\d{1,2}/); if (matchDate) { itemSubject.releaseDate = dealDate(matchDate[0]); } let $rateInfo = item.querySelector('.rateInfo'); if ($rateInfo) { if ($rateInfo.querySelector('.fade')) { itemSubject.score = $rateInfo.querySelector('.fade').textContent; itemSubject.count = $rateInfo .querySelector('.tip_j') .textContent.replace(/[^0-9]/g, ''); } else { itemSubject.score = '0'; itemSubject.count = '少于10'; } } else { itemSubject.score = '0'; itemSubject.count = '0'; } results.push(itemSubject); } } else { return []; } return [results, numOfPage]; } /** * 搜索条目 * @param subjectInfo * @param type * @param uniqueQueryStr */ async function searchSubject(subjectInfo, bgmHost = 'https://bgm.tv', type = SubjectTypeId.all, uniqueQueryStr = '', opts = {}) { if (subjectInfo && subjectInfo.releaseDate) { subjectInfo.releaseDate; } let query = normalizeQuery((subjectInfo.name || '').trim()); if (type === SubjectTypeId.book) { // 去掉末尾的括号并加上引号 query = query.replace(/([^0-9]+?)|\([^0-9]+?\)$/, ''); query = `"${query}"`; } if (uniqueQueryStr) { query = `"${uniqueQueryStr || ''}"`; } if (!query || query === '""') { console.info('Query string is empty'); return; } const url = `${bgmHost}/subject_search/${encodeURIComponent(query)}?cat=${type}`; console.info('search bangumi subject URL: ', url); const content = await fetchText(url); const $doc = new DOMParser().parseFromString(content, 'text/html'); const rawInfoList = extractInfoList($doc); // 使用指定搜索字符串如 ISBN 搜索时, 并且结果只有一条时,不再使用名称过滤 if (uniqueQueryStr && rawInfoList && rawInfoList.length === 1) { return rawInfoList[0]; } const options = { releaseDate: opts.releaseDate, keys: ['name', 'greyName'], }; return filterResults(rawInfoList, subjectInfo, options); } /** * 通过时间查找条目 * @param subjectInfo 条目信息 * @param pageNumber 页码 * @param type 条目类型 */ async function findSubjectByDate(subjectInfo, bgmHost = 'https://bgm.tv', pageNumber = 1, type) { if (!subjectInfo || !subjectInfo.releaseDate || !subjectInfo.name) { throw new Error('invalid subject info'); } const releaseDate = new Date(subjectInfo.releaseDate); if (isNaN(releaseDate.getTime())) { throw `invalid releasedate: ${subjectInfo.releaseDate}`; } const sort = releaseDate.getDate() > 15 ? 'sort=date' : ''; const page = pageNumber ? `page=${pageNumber}` : ''; let query = ''; if (sort && page) { query = '?' + sort + '&' + page; } else if (sort) { query = '?' + sort; } else if (page) { query = '?' + page; } const url = `${bgmHost}/${type}/browser/airtime/${releaseDate.getFullYear()}-${releaseDate.getMonth() + 1}${query}`; console.info('find subject by date: ', url); const rawText = await fetchText(url); let [rawInfoList, numOfPage] = dealSearchResults(rawText); const options = { threshold: 0.3, keys: ['name', 'greyName'], }; let result = filterResults(rawInfoList, subjectInfo, options, false); if (!result) { if (pageNumber < numOfPage) { await sleep(300); return await findSubjectByDate(subjectInfo, bgmHost, pageNumber + 1, type); } else { throw 'notmatched'; } } return result; } /** * 查找条目是否存在: 通过名称搜索或者日期加上名称的过滤查询 * @param subjectInfo 条目基本信息 * @param bgmHost bangumi 域名 * @param type 条目类型 */ async function checkExist(subjectInfo, bgmHost = 'https://bgm.tv', type, opts) { const subjectTypeDict = { [SubjectTypeId.game]: 'game', [SubjectTypeId.anime]: 'anime', [SubjectTypeId.music]: 'music', [SubjectTypeId.book]: 'book', [SubjectTypeId.real]: 'real', [SubjectTypeId.all]: 'all', }; let searchOpts = {}; if (typeof opts === 'object') { searchOpts = opts; } let searchResult = await searchSubject(subjectInfo, bgmHost, type, '', searchOpts); console.info(`First: search result of bangumi: `, searchResult); if (searchResult && searchResult.url) { return searchResult; } // disableDate if ((typeof opts === 'boolean' && opts) || (typeof opts === 'object' && opts.disableDate)) { return; } searchResult = await findSubjectByDate(subjectInfo, bgmHost, 1, subjectTypeDict[type]); console.info(`Second: search result by date: `, searchResult); return searchResult; } async function checkAnimeSubjectExist$1(subjectInfo) { const result = await checkExist(subjectInfo, getBgmHost(), SubjectTypeId.anime, true); return result; } const siteUtils$1 = { name: 'Bangumi', contanerSelector: '#columnHomeB', getUserId: getUserId$1, getSubjectId: getSubjectId$1, updateInterest: updateInterest$1, checkSubjectExist: checkAnimeSubjectExist$1, getAllPageInfo: getAllPageInfo$1, }; /** * 为页面添加样式 * @param style */ /** * 下载内容 * https://stackoverflow.com/questions/14964035/how-to-export-javascript-array-info-to-csv-on-client-side * @example * download(csvContent, 'dowload.csv', 'text/csv;encoding:utf-8'); * BOM: data:text/csv;charset=utf-8,\uFEFF * @param content 内容 * @param fileName 文件名 * @param mimeType 文件类型 */ function downloadFile(content, fileName, mimeType = 'application/octet-stream') { var a = document.createElement('a'); a.href = URL.createObjectURL(new Blob([content], { type: mimeType, })); a.style.display = 'none'; a.setAttribute('download', fileName); document.body.appendChild(a); a.click(); document.body.removeChild(a); } /** * @param {String} HTML 字符串 * @return {Element} */ function htmlToElement(html) { var template = document.createElement('template'); html = html.trim(); template.innerHTML = html; // template.content.childNodes; return template.content.firstChild; } /** * 载入 iframe * @param $iframe iframe DOM * @param src iframe URL * @param TIMEOUT time out */ function loadIframe($iframe, src, TIMEOUT = 5000) { return new Promise((resolve, reject) => { $iframe.src = src; let timer = setTimeout(() => { timer = null; $iframe.onload = undefined; reject('iframe timeout'); }, TIMEOUT); $iframe.onload = () => { clearTimeout(timer); $iframe.onload = null; resolve(null); }; }); } function genCollectionURL(userId, interestType, subjectType = 'movie', start = 1) { const baseURL = `https://${subjectType}.douban.com/people/${userId}/${interestType}`; if (start === 1) { return baseURL; } else { return `${baseURL}?start=${start}&sort=time&rating=all&filter=all&mode=grid`; } } function convertBangumiScore(num) { return Math.ceil(num / 2); // if (num < 4) { // return 1; // } // if (num < 6) { // return 2; // } // if (num < 8) return 3; // if (num < 9) return 4; // if (num === 10) return 5; // return 0; } function getSubjectId(url) { const m = url.match(/movie\.douban\.com\/subject\/(\d+)/); if (m) { return m[1]; } return ''; } function getUserId(homeURL) { let m = homeURL.match(/douban.com\/people\/([^\/]*)\//); if (m) { return m[1]; } return ''; } function convertItemInfo($item) { let $subjectTitle = $item.querySelector('.info .title a'); // 默认第二项为日文名 const titleArr = $subjectTitle.textContent .trim() .split('/') .map((str) => str.trim()); const rawInfos = $item.querySelector('.info .intro').textContent.trim(); let itemSubject = { name: titleArr[1], rawInfos, url: $subjectTitle.getAttribute('href'), greyName: titleArr[0], }; const $cover = $item.querySelector('.pic img'); if ($cover && $cover.tagName.toLowerCase() === 'img') { const src = $cover.getAttribute('src'); if (src) { itemSubject.cover = src; } } const jpDateReg = /(\d+-\d\d\-\d\d)(?:\(日本\))/; const dateReg = /\d+-\d\d\-\d\d/; let m; if ((m = rawInfos.match(jpDateReg))) { itemSubject.releaseDate = m[1]; } else if ((m = rawInfos.match(dateReg))) { itemSubject.releaseDate = m[0]; } const $collectInfo = $item.querySelector('.info'); if ($collectInfo) { const collectInfo = {}; collectInfo.date = $collectInfo .querySelector('li .date') ?.textContent.trim(); collectInfo.tags = $collectInfo .querySelector('li .tags') ?.textContent.replace('标签: ', '') .trim() ?? ''; collectInfo.comment = $collectInfo .querySelector('li .comment') ?.textContent.trim() ?? ''; const $rating = $collectInfo.querySelector('[class^=rating]'); if ($rating) { const m = $rating.getAttribute('class').match(/\d/); if (m) { // 十分制 collectInfo.score = +m[0] * 2; } } itemSubject.collectInfo = collectInfo; } return itemSubject; } function getTotalPageNum($doc = document) { const numStr = $doc.querySelector('.mode > .subject-num').textContent.trim(); return Number(numStr.split('/')[1].trim()); } /** * 拿到当前页面豆瓣用户收藏信息列表 * @param $doc DOM */ function getItemInfos($doc = document) { const items = $doc.querySelectorAll('#content .grid-view > .item'); const res = []; for (const item of Array.from(items)) { res.push(convertItemInfo(item)); } return res; } /** * 获取所有分页的条目数据 * @param userId 用户id * @param subjectType 条目类型 * @param interestType 条目状态 */ async function getAllPageInfo(userId, subjectType = 'movie', interestType) { let res = []; const url = genCollectionURL(userId, interestType, subjectType); const rawText = await fetchText(url); const $doc = new DOMParser().parseFromString(rawText, 'text/html'); const totalPageNum = getTotalPageNum($doc); res = [...getItemInfos($doc)]; // 16 分割 let page = 16; while (page <= totalPageNum) { let reqUrl = genCollectionURL(userId, interestType, subjectType, page); await sleep(500); console.info('fetch info: ', reqUrl); const rawText = await fetchText(reqUrl); const $doc = new DOMParser().parseFromString(rawText, 'text/html'); res.push(...getItemInfos($doc)); page += 15; } return res; } function convertHomeSearchItem($item) { const dealHref = (href) => { if (/^https:\/\/movie\.douban\.com\/subject\/\d+\/$/.test(href)) { return href; } const urlParam = href.split('?url=')[1]; if (urlParam) { return decodeURIComponent(urlParam.split('&')[0]); } else { throw 'invalid href'; } }; const $title = $item.querySelector('.title h3 > a'); const href = dealHref($title.getAttribute('href')); const $ratingNums = $item.querySelector('.rating-info > .rating_nums'); let ratingsCount = ''; let averageScore = ''; if ($ratingNums) { const $count = $ratingNums.nextElementSibling; const m = $count.innerText.match(/\d+/); if (m) { ratingsCount = m[0]; } averageScore = $ratingNums.innerText; } let greyName = ''; const $greyName = $item.querySelector('.subject-cast'); if ($greyName) { greyName = $greyName.innerText; } return { name: $title.textContent.trim(), greyName: greyName.split('/')[0].replace('原名:', '').trim(), releaseDate: (greyName.match(/\d{4}$/) || [])[0], url: href, score: averageScore, count: ratingsCount, }; } /** * 通过首页搜索的结果 * @param query 搜索字符串 */ async function getHomeSearchResults(query, cat = '1002') { const url = `https://www.douban.com/search?cat=${cat}&q=${encodeURIComponent(query)}`; console.info('Douban search URL: ', url); const rawText = await fetchText(url); const $doc = new DOMParser().parseFromString(rawText, 'text/html'); const items = $doc.querySelectorAll('.search-result > .result-list > .result > .content'); return Array.prototype.slice .call(items) .map(($item) => convertHomeSearchItem($item)); } /** * 提取所有 search.douban.com 的条目信息 * @param $doc 页面容器 */ function getAllSearchResult($doc = document) { let items = $doc.querySelectorAll('#root .item-root'); return Array.prototype.slice .call(items) .map(($item) => convertSubjectSearchItem($item)); } /** * 提取 search.douban.com 的条目信息 * @param $item 单项搜索结果容器 DOM */ function convertSubjectSearchItem($item) { // item-root const $title = $item.querySelector('.title a'); let name = ''; let releaseDate = ''; let rawName = ''; if ($title) { const rawText = $title.textContent.trim(); rawName = rawText; const yearRe = /\((\d{4})\)$/; releaseDate = (rawText.match(yearRe) || ['', ''])[1]; let arr = rawText.split(/ (?!-)/); if (arr && arr.length === 2) { name = arr[0]; arr[1].replace(yearRe, ''); } else { arr = rawText.split(/ (?!(-|\w))/); name = arr[0]; rawText.replace(name, '').trim().replace(yearRe, '').trim(); } } let ratingsCount = ''; let averageScore = ''; const $ratingNums = $item.querySelector('.rating_nums'); if ($ratingNums) { const $count = $ratingNums.nextElementSibling; const m = $count.textContent.match(/\d+/); if (m) { ratingsCount = m[0]; } averageScore = $ratingNums.textContent; } return { name, rawName, url: $title.getAttribute('href'), score: averageScore, count: ratingsCount, releaseDate, }; } /** * 单独类型搜索入口 * @param query 搜索字符串 * @param cat 搜索类型 * @param type 获取传递数据的类型: gm 通过 GM_setValue, message 通过 postMessage */ async function getSubjectSearchResults(query, cat = '1002') { const url = `https://search.douban.com/movie/subject_search?search_text=${encodeURIComponent(query)}&cat=${cat}`; console.info('Douban search URL: ', url); const iframeId = 'e-userjs-search-subject'; let $iframe = document.querySelector(`#${iframeId}`); if (!$iframe) { $iframe = document.createElement('iframe'); $iframe.setAttribute('sandbox', 'allow-forms allow-same-origin allow-scripts'); $iframe.style.display = 'none'; $iframe.id = iframeId; document.body.appendChild($iframe); } // 这里不能使用 await 否则数据加载完毕了监听器还没有初始化 loadIframe($iframe, url, 1000 * 10); return await getSearchResultByGM(); } async function sendSearchResults() { const searchItems = getAllSearchResult(); setSearchResultByGM(searchItems); } async function updateInterest(subjectId, data) { const interestObj = findInterestStatusById(data.interest); let query = ''; if (data.interest !== undefined) { query = 'interest=' + interestObj.key; } let url = `https://movie.douban.com/j/subject/${subjectId}/interest?${query}`; const collectInfo = await fetchJson(url); const interestStatus = collectInfo.interest_status; const tags = collectInfo.tags; const $doc = new DOMParser().parseFromString(collectInfo.html, 'text/html'); const $form = $doc.querySelector('form'); const formData = new FormData($form); const sendData = { interest: interestObj.key, tags: data.tags, comment: data.comment, rating: convertBangumiScore(+data.rating) + '', }; if (tags && tags.length) { sendData.tags = tags.join(' '); } if (interestStatus) { sendData.interest = interestStatus; } if (data.privacy === '1') { // @ts-ignore sendData.privacy = 'on'; } for (let [key, val] of Object.entries(sendData)) { if (!formData.has(key)) { formData.append(key, val); } else if (formData.has(key) && !formData.get(key) && val) { formData.set(key, val); } } // share-shuo: douban 删除分享广播 if (formData.has('share-shuo')) { formData.delete('share-shuo'); } await fetch($form.action, { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest', }, body: formData, }); } /** * * @param subjectInfo 条目信息 * @param type 默认使用主页搜索 * @returns 搜索结果 */ async function checkAnimeSubjectExist(subjectInfo, type = 'home_search') { let query = (subjectInfo.name || '').trim(); if (!query) { console.info('Query string is empty'); return Promise.reject(); } let rawInfoList; let searchResult; const options = { keys: ['name', 'greyName'], }; if (type === 'home_search') { rawInfoList = await getHomeSearchResults(query); } else { rawInfoList = await getSubjectSearchResults(query); } searchResult = filterResults(rawInfoList, subjectInfo, options, true); console.info(`Search result of ${query} on Douban: `, searchResult); if (searchResult && searchResult.url) { return searchResult; } } const siteUtils = { name: '豆瓣', contanerSelector: '#content .aside', getUserId, getSubjectId, getAllPageInfo, updateInterest, checkSubjectExist: checkAnimeSubjectExist, }; function insertControl(contanerSelector, name) { GM_addStyle(` .e-userjs-export-tool-container input { margin-bottom: 12px; } .e-userjs-export-tool-container .title { color: #F09199; font-weight: bold; font-size: 14px; margin: 12px 0; display: inline-block; } .e-userjs-export-tool-container .import-btn{ margin-top: 12px; } .e-userjs-export-tool-container .export-btn { display: none; } .e-userjs-export-tool-container .retry-btn { display: none; } .ui-button { display: inline-block; line-height: 20px; font-size: 14px; text-align: center; color: #4c5161; border-radius: 4px; border: 1px solid #d0d0d5; padding: 9px 15px; min-width: 80px; background-color: #fff; background-repeat: no-repeat; background-position: center; text-decoration: none; box-sizing: border-box; transition: border-color .15s, box-shadow .15s, opacity .15s; font-family: inherit; cursor: pointer; overflow: visible; background-color: #2a80eb; color: #fff; } `); const $parent = document.querySelector(contanerSelector); const $container = htmlToElement(`
${name}主页 URL:

`); $parent.appendChild($container); return $container; } let bangumiData = null; const typeIdDict = { dropped: { name: '抛弃', id: '5', }, on_hold: { name: '搁置', id: '4', }, do: { name: '在看', id: '3', }, collect: { name: '看过', id: '2', }, wish: { name: '想看', id: '1', }, }; function getBangumiSubjectId(name = '', greyName = '') { if (!bangumiData) return; const obj = bangumiData.items.find((item) => { let cnNames = []; if (item.titleTranslate && item.titleTranslate['zh-Hans']) { cnNames = item.titleTranslate['zh-Hans']; } return (item.title === name || item.title === greyName || cnNames.includes(greyName)); }); return obj?.sites?.find((item) => item.site === 'bangumi').id; } function genCSVContent(infos) { const header = '\ufeff名称,别名,发行日期,地址,封面地址,收藏日期,我的评分,标签,吐槽,其它信息,类别,同步情况,搜索结果信息'; let csvContent = ''; const keys = Object.keys(infos); keys.forEach((key) => { infos[key].forEach((item) => { csvContent += `\r\n${item.name || ''},${item.greyName || ''},${item.releaseDate || ''}`; const subjectUrl = item.url; csvContent += `,${subjectUrl}`; const cover = item.cover || ''; csvContent += `,${cover}`; const collectInfo = item.collectInfo || {}; const collectDate = collectInfo.date || ''; csvContent += `,${collectDate}`; const score = collectInfo.score || ''; csvContent += `,${score}`; const tag = collectInfo.tags || ''; csvContent += `,${tag}`; const comment = collectInfo.comment || ''; csvContent += `,"${comment}"`; const rawInfos = item.rawInfos || ''; csvContent += `,"${rawInfos}"`; csvContent += `,"${typeIdDict[key].name}"`; csvContent += `,${item.syncStatus || ''}`; // 新增搜索结果信息 let searchResultStr = ''; if (item.syncSubject) { const obj = item.syncSubject; searchResultStr = `${obj.name};${obj.greyName || ''};${obj.url || ''};${obj.rawName || ''}`; } // 同步信息 csvContent += `,"${searchResultStr}"`; }); }); return header + csvContent; } // 区分是否为动画 function isJpMovie(item) { return item.rawInfos.indexOf('日本') !== -1; } function clearLogInfo($container) { $container .querySelectorAll('.e-wiki-log-info') .forEach((node) => node.remove()); } function init(site) { let targetUtils; let originUtils; if (site === 'bangumi') { targetUtils = siteUtils; originUtils = siteUtils$1; } else { targetUtils = siteUtils$1; originUtils = siteUtils; } const $container = insertControl(originUtils.contanerSelector, targetUtils.name); const $input = $container.querySelector('input'); const $importBtn = $container.querySelector('.import-btn'); const $exportBtn = $container.querySelector('.export-btn'); const $retryBtn = $container.querySelector('.retry-btn'); const interestInfos = { do: [], collect: [], wish: [], dropped: [], on_hold: [], }; $exportBtn.addEventListener('click', async (e) => { const $text = e.target; $text.value = '导出中...'; let name = 'Bangumi'; if (site === 'bangumi') { name = '豆瓣'; } let strName = `${name}动画的收藏`; const csv = genCSVContent(interestInfos); // $text.value = '导出完成'; $text.style.display = 'none'; downloadFile(csv, `${strName}-${formatDate(new Date())}.csv`); }); $retryBtn.addEventListener('click', async (e) => { try { bangumiData = JSON.parse(GM_getResourceText('bangumiDataURL')); } catch (e) { console.log('parse JSON:', e); } const userId = getUserIdFromInput($input.value, targetUtils.getUserId); if (!userId) return; const arr = getInterestTypeArr(); for (let type of arr) { const res = interestInfos[type]; for (let i = 0; i < res.length; i++) { let item = res[i]; if (!item.syncStatus) { item = await migrateCollection(originUtils, item, site, type); } res[i] = item; } } clearLogInfo($container); $exportBtn.style.display = 'inline-block'; $retryBtn.style.display = 'inline-block'; }); $importBtn.addEventListener('click', async (e) => { try { bangumiData = JSON.parse(GM_getResourceText('bangumiDataURL')); } catch (e) { console.log('parse JSON:', e); } const userId = getUserIdFromInput($input.value, targetUtils.getUserId); if (!userId) return; const arr = getInterestTypeArr(); for (let type of arr) { try { const res = (await targetUtils.getAllPageInfo(userId, 'movie', type)); for (let i = 0; i < res.length; i++) { let item = res[i]; item = await migrateCollection(originUtils, item, site, type); res[i] = item; } interestInfos[type] = [...res]; } catch (error) { console.error(error); } } clearLogInfo($container); $exportBtn.style.display = 'inline-block'; $retryBtn.style.display = 'inline-block'; }); } function getUserIdFromInput(val, fn) { if (!val) { alert(`请输入${name}主页地址`); return ''; } const userId = fn(val); if (!userId) { alert(`无效${name}主页地址`); return ''; } return userId; } function getInterestTypeArr() { const $container = document.querySelector('.e-userjs-export-tool-container'); const $select = $container.querySelector('#movie-type-select'); // const arr: InterestType[] = ['wish']; let arr = ['do', 'collect', 'wish']; if ($select && $select.value) { arr = [$select.value]; } return arr; } async function migrateCollection(siteUtils, item, site, type) { const subjectItem = { ...item }; const $container = document.querySelector('.e-userjs-export-tool-container'); const $btn = $container.querySelector('.import-btn'); // 在 Bangumi 上 非日语的条目跳过 if (site === 'bangumi' && !isJpMovie(subjectItem)) { return subjectItem; } let subjectId = ''; // 使用 bangumi data if (site === 'bangumi') { subjectId = getBangumiSubjectId(subjectItem.name, subjectItem.greyName); } if (!subjectId) { try { await randomSleep(1000, 400); const result = await siteUtils.checkSubjectExist({ name: subjectItem.name, releaseDate: subjectItem.releaseDate, }); if (result && result.url) { subjectId = siteUtils.getSubjectId(result.url); subjectItem.syncSubject = result; } } catch (error) { console.error(error); } } if (subjectId) { clearLogInfo($container); const nameStr = `《${subjectItem.name}》`; insertLogInfo($btn, `更新收藏 ${nameStr} 中...`); await siteUtils.updateInterest(subjectId, { interest: typeIdDict[type].id, ...subjectItem.collectInfo, rating: subjectItem.collectInfo.score || '', }); subjectItem.syncStatus = '成功'; await randomSleep(2000, 1000); insertLogInfo($btn, `更新收藏 ${nameStr} 成功`); } return subjectItem; } if (location.href.match(/bgm.tv|bangumi.tv|chii.in/)) { init('bangumi'); } if (location.href.match(/movie.douban.com/)) { init('douban'); } if (location.href.match(/search\.douban\.com\/movie\/subject_search/)) { if (window.top !== window.self) { sendSearchResults(); } } })();