Greasy Fork is available in English.
Upload QCs from your favorite agent to Imgur + QC server
// ==UserScript== // @name FR:Reborn - Agents extension // @namespace https://www.reddit.com/user/RobotOilInc // @version 2.3.4 // @description Upload QCs from your favorite agent to Imgur + QC server // @author RobotOilInc // @match https://www.basetao.com/*my_account/order/* // @match https://basetao.com/*my_account/order/* // @match https://www.cssbuy.com/*name=orderlist* // @match https://cssbuy.com/*name=orderlist* // @match https://superbuy.com/order* // @match https://www.superbuy.com/order* // @match https://wegobuy.com/order* // @match https://www.wegobuy.com/order* // @grant GM_addStyle // @grant GM_getResourceText // @grant GM_getValue // @grant GM_setValue // @grant GM_openInTab // @grant GM_registerMenuCommand // @license MIT // @homepageURL https://www.fashionreps.page/ // @supportURL https://greasyfork.org/en/scripts/426977-fr-reborn-agents-extension // @include https://www.basetao.com/index/orderphoto/itemimg/* // @include https://basetao.com/index/orderphoto/itemimg/* // @require https://unpkg.com/[email protected]/dist/sweetalert2.js // @require https://unpkg.com/[email protected]/src/logger.js // @require https://unpkg.com/[email protected]/spark-md5.js // @require https://unpkg.com/@zip.js/[email protected]/dist/zip-full.js // @require https://unpkg.com/[email protected]/dist/FileSaver.js // @require https://unpkg.com/[email protected]/dist/jquery.js // @require https://unpkg.com/[email protected]/src/jquery.ajax-retry.js // @require https://unpkg.com/@sentry/[email protected]/build/bundle.js // @require https://unpkg.com/@sentry/[email protected]/build/bundle.tracing.js // @require https://unpkg.com/[email protected]/dist/swagger-client.browser.js // @require https://greasyfork.org/scripts/11562-gm-config-8/code/GM_config%208+.js?version=66657 // @resource sweetalert2 https://unpkg.com/[email protected]/dist/sweetalert2.min.css // @run-at document-end // @icon https://i.imgur.com/mYBHjAg.png // ==/UserScript== // Define default toast const Toast = Swal.mixin({ showConfirmButton: false, timerProgressBar: true, position: 'top-end', timer: 4000, toast: true, didOpen: (toast) => { toast.addEventListener('mouseenter', Swal.stopTimer); toast.addEventListener('mouseleave', Swal.r###meTimer); }, }); /** * @param text {string} * @param type {null|('success'|'error'|'warning'|'info')} */ const Snackbar = function (text, type = null) { Toast.fire({ title: text, icon: type != null ? type : 'info' }); }; /** * @return {Promise<boolean>} */ const ConfirmDialog = async function () { return new Promise((resolve) => { Swal.fire({ title: 'Are you sure?', icon: 'warning', showCancelButton: true, confirmButtonColor: '#3085d6', cancelButtonColor: '#d33', confirmButtonText: 'Yes', }).then((r###lt) => resolve(r###lt.isConfirmed)); }); }; class ImgurError extends Error { /** * @param message {string} * @param previous {Error} */ constructor(message, previous) { super(message); this.name = 'ImgurError'; this.previous = previous; } } class ImgurSlowdownError extends ImgurError { constructor(message, previous) { super(`Imgur is telling us to slow down:\n${message}`, previous); } } // Possible websites const WEBSITE_1688 = '1688'; const WEBSITE_TAOBAO = 'taobao'; const WEBSITE_TMALL = 'tmall'; const WEBSITE_YUPOO = 'yupoo'; const WEBSITE_WEIDIAN = 'weidian'; const WEBSITE_XIANYU = 'xianyu'; const WEBSITE_UNKNOWN = 'unknown'; /** * @internal * @param url {string} * @returns {string} */ const ensureNonEncodedURL = (url) => { if (url === decodeURIComponent(url || '')) { return url; } // Grab the encoded URL const encodedURL = new URL(url).searchParams.get('url') || ''; if (encodedURL.length === 0) { return url; } // Decode said encoded URL const decodedURL = decodeURIComponent(encodedURL); if (decodedURL.length === 0) { return url; } return decodedURL; }; /** * @param url {string} * @returns {boolean} */ const isUrl = (url) => { try { return Boolean(new URL(url)); } catch (e) { return false; } }; /** * @param originalUrl {string} * @param website {string} * @returns {string} */ const cleanPurchaseUrl = (originalUrl, website) => { const url = ensureNonEncodedURL(originalUrl); const idMatches = url.match(/[?&]id=(\d+)|[?&]itemID=(\d+)|\/?[albums]\/(\d+)|offer\/(\d+)/i); const authorMatches = url.match(/https?:\/\/(.+)\.x\.yupoo\.com/); if (website === WEBSITE_TAOBAO && idMatches[1].length !== 0) { return `https://item.taobao.com/item.htm?id=${idMatches[1]}`; } if (website === WEBSITE_TMALL && idMatches[1].length !== 0) { return `https://detail.tmall.com/item.htm?id=${idMatches[1]}`; } if (website === WEBSITE_XIANYU && idMatches[1].length !== 0) { return `https://2.taobao.com/item.htm?id=${idMatches[1]}`; } if (website === WEBSITE_WEIDIAN && idMatches[2].length !== 0) { return `https://weidian.com/item.html?itemID=${idMatches[2]}`; } if (website === WEBSITE_YUPOO && idMatches[3].length !== 0 && authorMatches[1].length !== 0) { return `https://${authorMatches[1]}.x.yupoo.com/albums/${idMatches[3]}`; } if (website === WEBSITE_1688 && idMatches[4].length !== 0) { return `https://detail.1688.com/offer/${idMatches[4]}.html`; } // Just return the original URL with some clean up return originalUrl.replace('http://', 'https://').replace('?uid=1', '').trim(); }; /** * @param originalUrl {string} * @returns {string} */ const determineWebsite = (originalUrl) => { if (originalUrl.indexOf('1688.com') !== -1) { return WEBSITE_1688; } // Check more specific taobao first if (originalUrl.indexOf('market.m.taobao.com') !== -1 || originalUrl.indexOf('2.taobao.com') !== -1) { return WEBSITE_XIANYU; } if (originalUrl.indexOf('taobao.com') !== -1) { return WEBSITE_TAOBAO; } if (originalUrl.indexOf('detail.tmall.com') !== -1) { return WEBSITE_TMALL; } if (originalUrl.indexOf('weidian.com') !== -1 || originalUrl.indexOf('koudai.com') !== -1) { return WEBSITE_WEIDIAN; } if (originalUrl.indexOf('yupoo.com') !== -1) { return WEBSITE_YUPOO; } return WEBSITE_UNKNOWN; }; const removeWhitespaces = (item) => item.trim().replace(/\s(?=\s)/g, ''); /** * @param input {string} * @param maxLength {number} must be an integer * @returns {string} */ const truncate = function (input, maxLength) { function isHighSurrogate(codePoint) { return codePoint >= 0xd800 && codePoint <= 0xdbff; } function isLowSurrogate(codePoint) { return codePoint >= 0xdc00 && codePoint <= 0xdfff; } function getLength(segment) { if (typeof segment !== 'string') { throw new Error('Input must be string'); } const charLength = segment.length; let byteLength = 0; let codePoint = null; let prevCodePoint = null; for (let i = 0; i < charLength; i++) { codePoint = segment.charCodeAt(i); // handle 4-byte non-BMP chars // low surrogate if (isLowSurrogate(codePoint)) { // when parsing previous hi-surrogate, 3 is added to byteLength if (prevCodePoint != null && isHighSurrogate(prevCodePoint)) { byteLength += 1; } else { byteLength += 3; } } else if (codePoint <= 0x7f) { byteLength += 1; } else if (codePoint >= 0x80 && codePoint <= 0x7ff) { byteLength += 2; } else if (codePoint >= 0x800 && codePoint <= 0xffff) { byteLength += 3; } prevCodePoint = codePoint; } return byteLength; } if (typeof input !== 'string') { throw new Error('Input must be string'); } const charLength = input.length; let curByteLength = 0; let codePoint; let segment; for (let i = 0; i < charLength; i += 1) { codePoint = input.charCodeAt(i); segment = input[i]; if (isHighSurrogate(codePoint) && isLowSurrogate(input.charCodeAt(i + 1))) { i += 1; segment += input[i]; } curByteLength += getLength(segment); if (curByteLength === maxLength) { return input.slice(0, i + 1); } if (curByteLength > maxLength) { return input.slice(0, i - segment.length + 1); } } return input; }; /** * @param url {string} * @returns {Promise<string>} */ const toDataURL = (url) => fetch(url) .then((response) => response.blob()) .then((blob) => new Promise((resolve, reject) => { const reader = new FileReader(); reader.onloadend = () => resolve(reader.r###lt); reader.onerror = reject; reader.readAsDataURL(blob); })); /** * @param base64Data {string} * @returns {Promise<string>} */ const WebpToJpg = function (base64Data) { return new Promise((resolve) => { const image = new Image(); image.src = base64Data; image.onload = () => { const canvas = document.createElement('canvas'); const context = canvas.getContext('2d'); canvas.width = image.width; canvas.height = image.height; context.drawImage(image, 0, 0); resolve(canvas.toDataURL('image/jpeg')); }; }); }; /** * Waits for an element satisfying selector to exist, then resolves promise with the element. * Useful for resolving race conditions. */ /** * @param selector {string} * @returns {Promise<Element>} */ const elementReady = function (selector) { return new Promise((resolve) => { // Check if the element already exists const element = document.querySelector(selector); if (element) { resolve(element); } // It doesn't so, so let's make a mutation observer and wait new MutationObserver((mutationRecords, observer) => { // Query for elements matching the specified selector Array.from(document.querySelectorAll(selector)).forEach((foundElement) => { // Resolve the element that we found resolve(foundElement); // Once we have resolved we don't need the observer anymore. observer.disconnect(); }); }).observe(document.documentElement, { childList: true, subtree: true }); }); }; class BaseTaoElement { constructor($element, data) { this.element = $element; this.data = data; // Set the order id this.orderId = data.oid; // Item name this.title = truncate(removeWhitespaces(data.goodsname), 255); // Item and shipping prices this.itemPrice = `CNY ${data.goodsprice}`; this.freightPrice = `CNY ${data.sendprice}`; // URL related stuff this.url = data.goodsurl; this.website = determineWebsite(this.url); // QC images location this.qcImag###rl = `https://www.basetao.com/best-taobao-agent-service/purchase/order_img/${data.oid}.html`; // Item sizing (if any) let sizing = removeWhitespaces(data.goodssize); sizing = (sizing !== '-' && sizing.toLowerCase() !== 'no') ? truncate(sizing, 255) : ''; this.sizing = sizing.length !== 0 ? sizing : null; // Item color (if any) let color = removeWhitespaces(data.goodscolor); color = (color !== '-' && color.toLowerCase() !== 'no') ? truncate(color, 255) : ''; this.color = color.length !== 0 ? color : null; // Item weight const weight = removeWhitespaces(data.orderweight); this.weight = weight.length !== 0 ? `${weight} gram` : null; // Image url storage, for later this.imageUrls = []; // Set at a later date, if ever this.albumId = null; } /** * @return {string} */ get albumUrl() { return `https://imgur.com/a/${this.albumId}`; } /** * @param imageUrls {string[]} */ set images(imageUrls) { this.imageUrls = imageUrls; } /** * @returns {string} */ get purchaseUrl() { return cleanPurchaseUrl(this.url, this.website); } } const ImgurIcon = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAA7EAAAOxAGVKw4bAAACl0lEQVQ4jX2TS0jUURSHv3v/d9TRMh0tzYrQiF7qpgijaFERRhlkxSwyiggXKdEiCjLBCi10UVirau0mCgzMVQQ96EHv1GxRWWBoKTbK2H+c+2gx6ZRIZ3U238c59/yuAFjQebQSqAeKcSj+VwINdAHn+re13hJ/4Ju4/2IziQB2K6B+JlgKQSglg0wVJF2lEhAeFseAH2HQj4ADBPUKKJ6C4nBxdRVrQ0VkBjL4aWOMuTi/nEHjkEKyVGWy42EzX8eHAYrV5M7Ot7jLETa1r+TE4F0ej39DegrpKYSnkCrRN4fWsSiYkxA4lJyEo40DzPmeQnjPWc5kb2RlaghrNNZonNFYneits0ghkqs63xK7MITpjVFVtZneD1958eA9+7JLEuA0SQoS30wkBbp5hKZD+ykpLaSxqY0zDQdIX59LXV9nEvxLMksEGNX+lEB5Hw3OOYJpqVxurWVBxWJqP9xAS4n0EpGQgP0DZMkURuLR5ATRqM/J49ep2FlGwfaFHOluI6ZjOBPHGo2whtPzk28SkmkMx8b+OrcTOOeYm5tF11g/Ezo2NbK0hguLy5n7RnMpdyvr0gsYjUcxLhkcCYJdlRtoPNdGub+McP4anNEIa2lZspPYvSGuXuvgYPg8zTmbeT7y+d9AZgUr40IKZZwhLz+b9o4Gbqtuls+ez8T9CLU1V/D9CYy1hMpyEMeyMJ6bjLOWONFVUlqIcIL8vGzKt9RR9DKDwfZvVB++REtLNaWlRQRWBDE1s5Nworq8oFr1Y3BgJJyaFmBveCOPH/Vwp+MZkUiUL33fef3qE32BYdJP5SHS5PTPVOP5uud9UK16Z7Rd9vRJb64xTlrjcM4xOjrOr4WWYN28JCzQCN4CNf3bWm/9BkZ7QCBmf01HAAAAAElFTkSuQmCC'; const Loading = 'data:image/gif;base64,R0lGODlhEAAQAPMAAP////r6+paWlr6+vnx8fIyMjOjo6NDQ0ISEhLa2tq6urvDw8MjIyODg4J6enqampiH/C05FVFNDQVBFMi4wAwEAAAAh+QQJCgAAACwAAAAAEAAQAAAETBDISau9NQjCiUxDYGmdhBCFkRUlcLCFOA3oNgXsQG2HRh0EAYWDIU6MGSSAR1G4ghRa7KjIUXCog6QzpRhYiC1HILsOEuJxGcNuTyIAIfkECQoAAAAsAAAAABAAEAAABGIQSGkQmzjLQkTTWDAgRGmAgMGVhAIESxZwBUMgSyAUATYQPIBg8OIQJwLCQbJkdjAlUCA6KfU0VEmyGWgenpNfcCAoEo6SmWtBYtCukxhAwQKeQAYWYgAHNZIFKBoMCHcTEQAh+QQJCgAAACwAAAAAEAAQAAAEWhDIOZejGDNysgyDQBAIGWRGMa7jgAVq0TUj0lEDUZxArvAU0a1nAAQOrsnIA1gqCZ6AUzI4nAxJwIEgyAQUhCQsjDmUCI1jDEhlrQrFV+ksGLApWwYz41jsIwAh+QQJCgAAACwAAAAAEAAQAAAEThDISau9IIQahiCEMGhCQxkFqBLFZ0pBWhzSkYIvMLAb/OGTBII2+QExSEBjuexhVgrKAZGgqKKTGGFgBc00Np71cVsVDJVo5ydyJt/wCAAh+QQJCgAAACwAAAAAEAAQAAAEWhDISau9OAxBiBjBtRRdSRTGpRRHeJBFOKWALAXkAKQNoSwWBgFRQAA4Q5DkgOwwhCXBYTJAdAQAopVhWSgIjR1gcLLVQrQbrBV4CcwSA8l0Alo0yA8cw+9TIgAh+QQJCgAAACwAAAAAEAAQAAAEWhDISau9WA5CxAhWMDDAwXGFQR0IgQRgWRBF7JyEQgXzIC2MFkc1MQkonMbAhyQ0Y5pBg0MREA4UwwnBWGhoUIAC55DwaAcQrIXATgyzE/bwCQ2sBGZmz7dEAAA7'; class Imgur { /** * @param version {string} * @param config {GM_config} * @param agent {string} * @constructor */ constructor(version, config, agent) { this.version = version; this.agent = agent; if (config.get('imgurApi') === 'imgur') { this.headers = { authorization: `Client-ID ${config.get('imgurClientId')}`, 'Content-Type': 'application/json', }; this.host = config.get('imgurApiHost'); return; } if (config.get('imgurApi') === 'rapidApi') { this.headers = { authorization: `Bearer ${config.get('rapidApiBearer')}`, 'x-rapidapi-key': config.get('rapidApiKey'), 'x-rapidapi-host': config.get('rapidApiHost'), }; this.host = config.get('rapidApiHost'); return; } throw new Error('Invalid Imgur API has been chosen'); } /** * @param options * @returns {Promise<*|null>} */ async CreateAlbum(options) { const requestData = { url: `https://${this.host}/3/album`, type: 'POST', headers: this.headers, data: JSON.stringify({ title: options.title, }), }; Sentry.addBreadcrumb({ category: 'Imgur', message: 'Creating album', data: requestData, }); Logger.debug('Creating album', requestData); return $.ajax(requestData).retry({ times: 3 }).catch((err) => { // Check if Imgur is being a bitch if (typeof err.responseJSON === 'undefined') { // Store request so we know what was asked this._storeRequestError(err); throw new ImgurError('Could not make an album, because Imgur is returning empty responses. Please try again later...', err); } this._handleImgurError(err); }); } /** * @param base64Image {string} * @param albumDeleteHash {string} * @param purchaseUrl {string} * @returns {Promise<boolean>} */ async AddBase64ImageToAlbum(base64Image, albumDeleteHash, purchaseUrl) { // First step, upload the image const requestData = { url: `https://${this.host}/3/image`, headers: this.headers, type: 'POST', data: JSON.stringify({ album: albumDeleteHash, type: 'base64', image: base64Image, description: this._getImageDescription(purchaseUrl), }), }; Logger.debug('Adding image to album', requestData); Sentry.addBreadcrumb({ category: 'Imgur', message: 'Adding image to album', data: requestData, }); await $.ajax(requestData).retry({ times: 3 }).catch((err) => { // Check if Imgur is being a bitch if (typeof err.responseJSON === 'undefined') { // Store request so we know what was asked this._storeRequestError(err); } this._handleImgurError(err); }); } /** * @param imageUrl {string} * @param albumDeleteHash {string} * @param purchaseUrl {string} * @returns {Promise<*|null>} */ async AddImageToAlbum(imageUrl, albumDeleteHash, purchaseUrl) { // First step, upload the image const requestData = { url: `https://${this.host}/3/image`, headers: this.headers, type: 'POST', data: JSON.stringify({ album: albumDeleteHash, image: imageUrl, description: this._getImageDescription(purchaseUrl), }), }; Logger.debug('Adding image to album', requestData); Sentry.addBreadcrumb({ category: 'Imgur', message: 'Adding image to album', data: requestData, }); await $.ajax(requestData).retry({ times: 3 }).catch((err) => { // Check if Imgur is being a bitch if (typeof err.responseJSON === 'undefined') { // Store request so we know what was asked this._storeRequestError(err); } this._handleImgurError(err); }); } /** * @param deleteHash {string} */ RemoveAlbum(deleteHash) { const requestData = { url: `https://${this.host}/3/album/${deleteHash}`, headers: this.headers, type: 'DELETE', }; Sentry.addBreadcrumb({ category: 'Imgur', message: 'Removing album', data: requestData, }); $.ajax(requestData).retry({ times: 3 }).catch(() => {}); } _getAlbumDescription() { return `Auto uploaded using FR:Reborn - ${this.agent} ${this.version}`; } /** * @param purchaseUrl {string} */ _getImageDescription(purchaseUrl) { return purchaseUrl.length === 0 ? this._getAlbumDescription() : `W2C: ${purchaseUrl}`; } /** * @private * @param err {Error} */ _storeRequestError(err) { Sentry.addBreadcrumb({ category: 'Imgur', message: `Imgur returned: '${err.statusText}'`, data: err, level: Sentry.Severity.Error, }); } /** * @private * @param err {Error} */ _handleImgurError(err) { // If there is a server error, let the user now if (err.status === 503 || (err.responseJSON && err.responseJSON.status === 503)) { throw new ImgurError('Imgur is either down, over-capacity or you did too many requests. Try again later', err); } // If we uploaded too many files, re-throw as proper error (checking via old response setup, new response setup and a simple fallback) if (err.responseJSON && err.responseJSON.data && err.responseJSON.data.error && err.responseJSON.data.error.code === 429) { throw new ImgurSlowdownError(err.responseJSON.data.error.message, err); } else if (err.responseJSON && err.responseJSON.errors && err.responseJSON.errors.length === 1 && err.responseJSON.errors[0] && err.responseJSON.errors[0].code === 429) { throw new ImgurSlowdownError(err.responseJSON.errors[0].detail, err); } else if (err.status === 429 || (err.responseJSON && err.responseJSON.status === 429)) { throw new ImgurSlowdownError('Too Many Requests', err); } // Store request so we know what was asked this._storeRequestError(err); // If we have error data from Imgur, throw it (checking via the old response setup and the new one) if (err.responseJSON && err.responseJSON.data && err.responseJSON.data.error) { throw new ImgurError(`An error happened when uploading the image:\n${err.responseJSON.data.error.message}`, err); } else if (err.responseJSON && err.responseJSON.errors && err.responseJSON.errors.length !== 0 && err.responseJSON.errors[0].detail) { throw new ImgurSlowdownError(`An error happened when uploading the image:\n${err.responseJSON.errors[0].detail}`, err); } // If not, just show the full JSON throw new ImgurError(`An error happened when uploading the image:\n${err.responseJSON}`, err); } } const buildSwaggerHTTPError = function (response) { // Build basic error (and use response as extra) const error = new Error(`${response.body.detail}: ${response.url}`); // Add status and status code error.status = response.body.status; error.statusCode = response.body.status; return error; }; class QC { /** * @param version {string} * @param client {SwaggerClient} * @param userHash {string} * @param identifier {string} * @param agent {string} */ constructor(version, client, userHash, identifier, agent) { this.version = version; this.client = client; this.userHash = userHash; this.identifier = identifier; this.agent = agent; } /** * @param element {BaseTaoElement|CSSBuyElement|WeGoBuyElement} * @returns {Promise<null|string>} */ existingAlbumByOrderId(element) { const request = { url: element.url, orderId: element.orderId }; return this.client.apis.QualityControl.uploaded(request).then((response) => { if (typeof response.body === 'undefined') { return null; } if (!response.body.success) { return null; } // Force add the album ID to the element element.albumId = response.body.albumId; // eslint-disable-line no-param-reassign return response.body.albumId; }).catch((reason) => { Logger.error(`Could not check if the album for order '${element.orderId}' exists on the QC server`, reason); // For some reason we couldn't fetch information, just return, server probably down or something if (reason.message.includes('Failed to fetch') || reason.message.includes('NetworkError when attempting to fetch resource')) { return '-1'; } // Add breadcrumb with actual request we did Sentry.addBreadcrumb({ category: 'Swagger', message: 'existingAlbumByOrderId', data: { request }, level: Sentry.Severity.Debug, }); // Add breadcrumb with the error Sentry.addBreadcrumb({ category: 'Swagger - Error', message: 'existingAlbumByOrderId', data: { error: reason }, level: Sentry.Severity.Error, }); // Swagger HTTP error if (typeof reason.response !== 'undefined') { Sentry.captureException(buildSwaggerHTTPError(reason)); return '-1'; } Sentry.captureException(new Error(`Could not check if the album for order '${element.orderId}' exists on the QC server`)); return '-1'; }); } /** * @param url {string} * @returns {Promise<boolean>} */ exists(url) { const request = { url }; return this.client.apis.QualityControl.exists(request).then((response) => { if (typeof response.body === 'undefined') { return null; } if (!response.body.success) { return null; } return response.body.exists; }).catch((reason) => { Logger.error(`Could not check if any album exists on the QC server, URL: '${url}'`, reason); // For some reason we couldn't fetch information, just return, server probably down or something if (reason.message.includes('Failed to fetch') || reason.message.includes('NetworkError when attempting to fetch resource') || reason.message.includes('response status is 200')) { return '-1'; } // Add breadcrumb with actual request we did Sentry.addBreadcrumb({ category: 'Swagger', message: 'exists', data: { request }, level: Sentry.Severity.Debug, }); // Add breadcrumb with the error Sentry.addBreadcrumb({ category: 'Swagger - Error', message: 'exists', data: { error: reason }, level: Sentry.Severity.Error, }); // Swagger HTTP error if (typeof reason.response !== 'undefined') { Sentry.captureException(buildSwaggerHTTPError(reason)); return false; } Sentry.captureException(new Error(`Could not check if any album exists on the QC server, URL: '${url}'`)); return false; }); } /** * @param element {BaseTaoElement|CSSBuyElement|WeGoBuyElement} * @param album {string} */ uploadQc(element, album) { const request = { method: 'post', requestContentType: 'application/json', requestBody: { usernameHash: this.userHash, identifier: this.identifier, albumId: album, color: element.color, orderId: element.orderId, purchaseUrl: element.purchaseUrl, sizing: element.sizing, itemPrice: element.itemPrice, freightPrice: element.freightPrice, weight: element.weight, source: `${this.agent} to Imgur ${this.version}`, website: element.website, }, }; Logger.log('Adding new QC to FR: Reborn', request); return this.client.apis.QualityControl.postQualityControlCollection({}, request).catch((reason) => { Logger.error('Could not upload QC to the QC server', reason); // For some reason we couldn't fetch information, just return, server probably down or something if (reason.message.includes('Failed to fetch') || reason.message.includes('NetworkError when attempting to fetch resource')) { return; } // If the order already exists, just ignore the error if (reason.message.includes('orderId: This value is already used')) { return; } // Add breadcrumb with actual request we did Sentry.addBreadcrumb({ category: 'Swagger', message: 'postQualityControlCollection', data: { request, element }, level: Sentry.Severity.Debug, }); // Add breadcrumb with the error Sentry.addBreadcrumb({ category: 'Swagger - Error', message: 'postQualityControlCollection', data: { error: reason }, level: Sentry.Severity.Error, }); // Swagger HTTP error if (typeof reason.response !== 'undefined') { Sentry.captureException(buildSwaggerHTTPError(reason.response)); return; } Sentry.captureException(new Error('Could not upload QC to the QC server')); }); } } class BaseTao { constructor() { this.setup = false; } /** * @param hostname {string} * @returns {boolean} */ supports(hostname) { return hostname.includes('basetao.com'); } /** * @returns {string} */ name() { return 'BaseTao'; } /** * @param client {Promise<SwaggerClient>} * @returns {Promise<BaseTao>} */ async build(client) { // If already build before, just return if (this.setup) { return this; } // Get the username let username = $('[aria-labelledby="profileDropdown"] a:first').text(); if (typeof username === 'undefined' || username == null || username === '') { Snackbar('You need to be logged in to use this extension.'); throw new Error('You need to be logged in to use this extension.'); } // Trim the username username = username.trim(); // Hash the username (and add an extra space, since this was a bug in the past) const userHash = SparkMD5.hash(`${username} `); // Ensure we know who triggered errors Sentry.setUser({ id: userHash, username }); // Build all the clients this.imgurClient = new Imgur(GM_info.script.version, GM_config, this.name()); this.qcClient = new QC(GM_info.script.version, await client, userHash, username, this.name()); // Mark that this agent has been set up this.setup = true; return this; } /** * @return {Promise<void>} */ async process() { if (this.setup === false) { throw new Error('Agent is not setup, so cannot be used'); } // Make copy of the current this, so we can use it later const agent = this; // Get the container (unsafe, because we want the actual jQuery table) const $container = window.unsafeWindow.jQuery('#table').first(); // Start processing once the table has been loaded elementReady('.details-tr i.bi.bi-image-fill').then(() => { const rowData = $container.bootstrapTable('getData'); $container.find('i.bi.bi-image-fill').each(function () { const $element = $(this); const orderId = $element.parents('td').find('[data-row]').data('row'); agent._buildElement($element, rowData.find(agent._getRowData(orderId))); }); $container.on('load-success.bs.table', (event, data) => { $container.find('i.bi.bi-image-fill').each(function () { const $element = $(this); const orderId = $element.parents('td').find('[data-row]').data('row'); agent._buildElement($(this), data.rows.find(agent._getRowData(orderId))); }); }); // Ensure tooltips $container.tooltip({ selector: '.qc-tooltip' }); }); } /** * @private * @param $this * @param {RowData} data * @return {Promise<BaseTaoElement>} */ async _buildElement($this, data) { const element = new BaseTaoElement($this, data); const $imageIcon = element.element.parents('a').first(); // Append download button if enabled if (GM_config.get('showImagesDownloadButton')) { const $download = $('<span style="cursor: pointer;padding-left: 5px;" class="bi bi-download text-orange" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Download all photos"></span>'); $download.on('click', () => this._downloadHandler($download, element)); $imageIcon.parent().append($download); } // This plugin only works for certain websites, so check if element is supported if (element.website === WEBSITE_UNKNOWN) { const $upload = $(`<div><span style="cursor: pointer;"><img src="${ImgurIcon}" alt="Create a basic album"></span></div>`); $upload.find('span').first().after($('<span class="qc-marker qc-tooltip" style="cursor:help;margin-left:5px;color:red;font-weight: bold;" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Not a supported URL, but you can still create an album. The QC\'s are not stored and you\'ll have to create a new album if you lose the link.">✖</span>')); $upload.on('click', () => { this._uploadHandler(element); }); $this.parents('td').first().append($upload); return element; } const $loading = $(`<div><span class="qc-tooltip" style="cursor: wait;" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Loading..."><img src="${Loading}" alt="Loading..."></span></div>`); $this.parents('td').first().append($loading); // Define upload object const $upload = $(`<div><span class="qc-marker qc-tooltip" style="cursor: pointer;" style="cursor: wait;" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Upload your QC"><img src="${ImgurIcon}" alt="Upload your QC"></span></div>`); // If we couldn't talk to FR:Reborn, assume everything is dead and use the basic uploader. const albumId = await this.qcClient.existingAlbumByOrderId(element); if (albumId === '-1') { $upload.find('span').first().html($('<span class="qc-marker qc-tooltip" style="cursor:help;color:red;font-weight: bold;" data-bs-toggle="tooltip" data-bs-placement="bottom" title="FR:Reborn returned an error or could not load your album.">⚠️</span>')); $this.parents('td').first().append($upload); $loading.remove(); return element; } // Have you ever uploaded a QC? If so, link to that album const $image = $upload.find('img'); if (albumId !== null && albumId !== '-1') { $upload.find('span').first().after($('<span class="qc-marker qc-tooltip" style="cursor:help;margin-left:5px;color:green;font-weight: bold;" data-bs-toggle="tooltip" data-bs-placement="bottom" title="You have uploaded a QC">✓</span>')); $image.wrap(`<a href='${element.albumUrl}' target='_blank' data-bs-toggle="tooltip" data-bs-placement="bottom" title='Go to album'></a>`); $image.removeAttr('title'); $this.parents('td').first().append($upload); $loading.remove(); return element; } // Has anyone ever uploaded a QC, if not, show a red marker const exists = await this.qcClient.exists(element.purchaseUrl); if (!exists) { $upload.find('span').first().after($('<span class="qc-marker qc-tooltip" style="cursor:help;margin-left:5px;color:red;font-weight: bold;" data-bs-toggle="tooltip" data-bs-placement="bottom" title="No QC in database, please upload.">(!)</span>')); $upload.on('click', () => { this._uploadHandler(element); }); $this.parents('td').first().append($upload); $loading.remove(); return element; } // A previous QC exists, but you haven't uploaded yours yet, show orange marker $upload.find('span').first().after($('<span class="qc-marker qc-tooltip" style="cursor:help;margin-left:5px;color:orange;font-weight: bold;" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Your QC is not yet in the database, please upload.">(!)</span>')); $upload.on('click', () => { this._uploadHandler(element); }); $this.parents('td').first().append($upload); $loading.remove(); return element; } /** * @private * @param element {BaseTaoElement} * @returns {Promise<void>} */ async _uploadToImgur(element) { if (this.setup === false) { throw new Error('Agent is not setup, so cannot be used'); } if (element.imageUrls.length === 0) { Snackbar(`No pictures found for order ${element.orderId}, skipping,..`); return; } const $processing = $(`<span style="cursor: wait;"><img src="${Loading}" alt="Processing..."></span>`); const $base = element.element.parents('td').first().find('div').first(); $base.after($processing).hide(); // Start the process Snackbar(`Pictures for '${element.orderId}' are being uploaded...`); // Temp store deleteHash let deleteHash; try { // Create the album const response = await this.imgurClient.CreateAlbum(element); if (typeof response === 'undefined' || response == null) { return; } // Extract and build information needed deleteHash = response.data.deletehash; const albumId = response.data.id; // Upload all QC images const promises = []; $.each(element.imageUrls, (key, imageUrl) => { // Convert to base64, since Imgur cannot access our images promises.push(toDataURL(imageUrl).then(async (data) => { // Store our base64 and if the file is WEBP, convert it to JPG let base64Image = data; if (base64Image.indexOf('image/webp') !== -1) { base64Image = await WebpToJpg(base64Image); } // Remove the unnecessary `data:` part const cleanedData = base64Image.replace(/(data:image\/.*;base64,)/, ''); // Upload the image to the album return this.imgurClient.AddBase64ImageToAlbum(cleanedData, deleteHash, element.purchaseUrl); })); }); // Wait until everything has been tried to be uploaded await Promise.all(promises); // Set albumId in element, so we don't upload it again (when doing a pending haul upload) element.albumId = albumId; // eslint-disable-line no-param-reassign // Tell the user it was uploaded and open the album in the background Snackbar('Pictures have been uploaded!', 'success'); GM_openInTab(element.albumUrl, true); // Tell QC Suite about our uploaded QC's (if it's supported) if (element.website !== WEBSITE_UNKNOWN) { this.qcClient.uploadQc(element, albumId); } // Wrap the logo in a href to the new album const $image = $base.find('img'); $image.wrap(`<a href='${element.albumUrl}' target='_blank' data-bs-toggle="tooltip" data-bs-placement="bottom" title='Go to album'></a>`); $image.removeAttr('title'); // Remove processing $processing.remove(); // Update the marker const checkMarkMessage = element.website !== WEBSITE_UNKNOWN ? 'You have uploaded a QC' : 'Album has been created'; const $qcMarker = $base.find('.qc-marker:not(:first-child)').first(); $qcMarker.attr('title', checkMarkMessage) .css('cursor', 'help') .css('color', 'green') .text('✓'); // Remove the click handler $base.off(); // Show it again $base.show(); } catch (err) { // Remove the created album this.imgurClient.RemoveAlbum(deleteHash); // Reset the button $processing.remove(); $base.show(); // Show the error Snackbar(err.message, 'error'); // If it's the slow down error, don't log it if (err instanceof ImgurSlowdownError) { return; } // Log the error Sentry.captureException(err); Logger.error(err); } } /** * @private * @param $download * @param element {BaseTaoElement} */ async _downloadHandler($download, element) { if (this.setup === false) { throw new Error('Agent is not setup, so cannot be used'); } if (!await ConfirmDialog()) { return; } // Remove button so people don't do dumb shit $download.remove(); // Go to the QC pictures URL, grab all image sources and upload the element await $.get(element.qcImag###rl).then(async (data) => { if (data.indexOf('long time no operation ,please sign in again') !== -1) { Snackbar('You are no longer logged in, reloading page....', 'warning'); Logger.info('No longer logged in, reloading page for user...'); window.location.reload(); return null; } Snackbar('Zipping images, this might take a while....', 'info'); // Create a zip file writer const zipWriter = new zip.ZipWriter(new zip.Data64URIWriter('application/zip')); // Download all the images and add to the zip const promises = []; $('<div/>').html(data).find('.card > img').each(function () { const src = $(this).attr('src'); promises.push(new Promise((resolve) => toDataURL(src) .then((dataURI) => zipWriter.add(src.substring(src.lastIndexOf('/') + 1), new zip.Data64URIReader(dataURI))) .then(() => resolve()))); }); // Wait for all images to be added to the ZIP await Promise.all(promises); // Close the ZipWriter object and download to computer saveAs(await zipWriter.close(), `${element.orderId}.zip`); Snackbar(`Downloading ${element.orderId}.zip`, 'success'); return null; }).catch((err) => { Snackbar(`Could not get all images for order ${element.orderId}`); Logger.error(`Could not get all images for order ${element.orderId}`, err); }); } /** * @private * @param element {BaseTaoElement} * @returns {Promise<void>} */ async _uploadHandler(element) { if (this.setup === false) { throw new Error('Agent is not setup, so cannot be used'); } // Go to the QC pictures URL, grab all image sources and upload the element await $.get(element.qcImag###rl).then(async (data) => { if (data.indexOf('long time no operation ,please sign in again') !== -1) { Snackbar('You are no longer logged in, reloading page....', 'warning'); Logger.info('No longer logged in, reloading page for user...'); window.location.reload(); return null; } // Add all image urls to the element $('<div/>').html(data).find('main div.container.container img').each(function () { element.imageUrls.push($(this).attr('src')); }); // Finally go and upload the order return this._uploadToImgur(element); }).catch((err) => { Snackbar(`Could not get all images for order ${element.orderId}`); Logger.error(`Could not get all images for order ${element.orderId}`, err); }); } /** * @private * @param orderId */ _getRowData(orderId) { return (item) => Number(item.oid) === Number(orderId); } } class CSSBuyElement { constructor($element) { this.element = $element; // Create empty array for images this.imageUrls = []; // Temporary items const parentTableEntry = $element.parentsUntil('tbody'); const itemLink = parentTableEntry.find('td.tabletd3 > a'); const splitText = parentTableEntry.find('td.tabletd3 > span:nth-child(3)').html().split('<br>'); // Order details this.orderId = this.element.parent().attr('data-id'); // Item name this.title = truncate(removeWhitespaces(itemLink.text()), 255); // Purchase details this.website = determineWebsite(itemLink.attr('href')); this.purchaseUrl = cleanPurchaseUrl(itemLink.attr('href'), this.website); // Item price this.itemPrice = `CNY ${removeWhitespaces(parentTableEntry.find('td:nth-child(4) > span:nth-child(1)').text())}`; // Freight price this.freightPrice = `CNY ${removeWhitespaces(parentTableEntry.find('td:nth-child(5) span').text())}`; // Item weight const weight = removeWhitespaces(parentTableEntry.find('td:nth-child(7) span').text()); this.weight = weight.length !== 0 ? `${weight} gram` : null; // Item sizing and color (if any) this.color = null; this.sizing = null; try { if (splitText.length === 1) { let color = splitText[0].split(' : ')[1]; color = (typeof color !== 'undefined' && color !== '-' && color.toLowerCase() !== 'no') ? truncate(color, 255) : ''; this.color = color.length !== 0 ? color : null; } else if (splitText.length === 2) { let sizing = (splitText[0].split(' : ')[1]); sizing = (typeof sizing !== 'undefined' && sizing !== '-' && sizing.toLowerCase() !== 'no') ? truncate(sizing, 255) : ''; this.sizing = sizing.length !== 0 ? sizing : null; let color = (splitText[1].split(' : ')[1]); color = (typeof color !== 'undefined' && color !== '-' && color.toLowerCase() !== 'no') ? truncate(color, 255) : ''; this.color = color.length !== 0 ? color : null; } else if (splitText.length !== 0) { this.sizing = splitText.join('\n'); } } catch (e) { Logger.info('Could not figure out sizing/color', e); } // Set at a later date, if ever this.albumId = null; } /** * @return {string} */ get albumUrl() { return `https://imgur.com/a/${this.albumId}`; } } /* eslint-disable no-return-await */ class OSS { constructor() { this.setup = false; this.window = window.unsafeWindow; } async build() { if (this.setup) { return this; } // Try and build the OSS client try { // Grab the OSS client const WindowOSS = await this._waitForValue('OSS'); // Build the config for the bucket const config = { region: await this._waitForValue('c_region'), accessKeyId: await this._waitForValue('c_accessid'), accessKeySecret: await this._waitForValue('c_accesskey'), bucket: await this._waitForValue('c_bucket'), endpoint: `https://${await this._waitForValue('c_region')}.aliyuncs.com/`, }; // Log the config, for ease of use Logger.info('OSS config build', config); // Set up the bucket for easy use this.window.client = new WindowOSS.Wrapper(config); // Mark as ready this.setup = true; } catch (e) { throw new Error(e); } return this; } /** * @param {string} orderId * * @return Promise<object> */ async list(orderId) { if (this.setup === false) { throw new Error('OSS is not setup, so cannot be used'); } return await this.window.client.list({ 'max-keys': 100, prefix: `o/${orderId}/`, }); } _waitForValue(value) { return new Promise((resolve) => { // Check if the element already exists if (this.window[value]) { resolve(this.window[value]); return; } const _waitForGlobal = () => { if (this.window[value]) { resolve(this.window[value]); return; } // Wait until we have it setTimeout(() => { _waitForGlobal(value, resolve); }, 100); }; // It doesn't so, so let's start waiting for it _waitForGlobal(value, resolve); }); } } class CSSBuy { constructor() { this.setup = false; } /** * @param hostname {string} * @returns {boolean} */ supports(hostname) { return hostname.includes('cssbuy.com'); } /** * @returns {string} */ name() { return 'CSSBuy'; } /** * @param client {Promise<SwaggerClient>} * @returns {Promise<CSSBuy>} */ async build(client) { // If already build before, just return if (this.setup) { return this; } // Get the username const username = removeWhitespaces($(await $.get('/?go=m')).find('.userxinix > div:nth-child(1) > p').text()); if (typeof username === 'undefined' || username == null || username === '') { Snackbar('You need to be logged in to use this extension.'); return this; } // Ensure we know who triggered the error const userHash = SparkMD5.hash(username); Sentry.setUser({ id: userHash, username }); // Build all the clients this.imgurClient = new Imgur(GM_info.script.version, GM_config, this.name()); this.qcClient = new QC(GM_info.script.version, await client, userHash, username, this.name()); // Mark that this agent has been set up this.setup = true; return this; } async process() { if (this.setup === false) { throw new Error('Agent is not setup, so cannot be used'); } // Make copy of the current this, so we can use it later const agent = this; // If there is nothing to process, just return here (so we don't try to build the OSS client and die) const $elements = $(".oss-photo-view-button > a:contains('QC PIC')"); if ($elements.length === 0) { return; } // Build OSS client this.ossClient = new OSS(); await this.ossClient.build(); if (this.ossClient.setup === false) { Snackbar('Could not build the OSS client, check the console for errors.'); } // Add icons to all elements $elements.each(function () { agent._buildElement($(this)); }); } /** * @private * @param $this * @return {Promise<void>} */ async _buildElement($this) { const element = new CSSBuyElement($this); // Check if it has any images to begin with const r###lt = await this.ossClient.list(element.orderId); if (typeof r###lt.objects === 'undefined') { return; } // This plugin only works for certain websites, so check if element is supported if (element.website === WEBSITE_UNKNOWN) { const $upload = $(`<ul class="badge-lists"><li style="cursor: pointer"><img src="${ImgurIcon}" alt="Create a basic album" style="width: 100%"></li></ul>`); $upload.find('li').first().after($('<li class="btn btn-xs qc-marker" style="cursor:help;color:red;font-weight: bold;" title="Not a supported URL, but you can still create an album. The QC\'s are not stored and you\'ll have to create a new album if you lose the link.">✖</li>')); $upload.on('click', () => { this._uploadToImgur(element); }); $this.parents('ul').first().after($upload); return; } // Define column in which to show buttons const $other = $this.parents('ul').first(); // Show simple loading animation const $loading = $(`<ul class="badge-lists"><li style="cursor: wait"><img src="${Loading}" alt="Loading..." style="width: 100%"></li></ul>`); $other.after($loading); // Define upload object const $upload = $(`<ul class="badge-lists"><li class="btn btn-xs qc-marker" style="cursor: pointer"><img src="${ImgurIcon}" alt="Upload your QC" style="width: 100%"></li></ul>`); // If we couldn't talk to FR:Reborn, assume everything is dead and use the basic uploader. const albumId = await this.qcClient.existingAlbumByOrderId(element); if (albumId === '-1') { $upload.find('li').first().after($('<li class="btn btn-xs qc-marker" style="cursor:help;color:red;font-weight: bold;" title="FR:Reborn returned an error, but you can still create an album. The QC\'s are not stored and you\'ll have to create a new album if you lose the link.">⚠️</li>')); $upload.on('click', () => { this._uploadToImgur(element); }); $other.after($upload); $loading.remove(); return; } // Have you ever uploaded a QC? If so, link to that album const $image = $upload.find('img'); if (albumId !== null && albumId !== '-1') { $upload.find('li').first().after($('<li class="btn btn-xs qc-marker" style="cursor:help;color:green;font-weight: bold;" title="You have uploaded a QC">✓</li>')); $image.wrap(`<a href='https://imgur.com/a/${albumId}' target='_blank' title='Go to album'></a>`); $image.removeAttr('title'); $other.after($upload); $loading.remove(); return; } // Has anyone ever uploaded a QC, if not, show a red marker const exists = await this.qcClient.exists(element.purchaseUrl); if (!exists) { $upload.find('li').first().after($('<li class="btn btn-xs qc-marker" style="cursor:help;color:red;font-weight: bold;" title="No QC in database, please upload.">(!)</li>')); $upload.on('click', () => { this._uploadToImgur(element); }); $other.after($upload); $loading.remove(); return; } // A previous QC exists, but you haven't uploaded yours yet, show orange marker $upload.find('li').first().after($('<li class="btn btn-xs qc-marker" style="cursor:help;color:orange;font-weight: bold;" title="Your QC is not yet in the database, please upload.">(!)</li>')); $upload.on('click', () => { this._uploadToImgur(element); }); $other.after($upload); $loading.remove(); } /** * @param element {CSSBuyElement} * @returns {Promise<void>} */ async _uploadToImgur(element) { if (this.setup === false) { throw new Error('Agent is not setup, so cannot be used'); } const $processing = $(`<ul class="badge-lists"><li style="cursor: wait"><img src="${Loading}" alt="Processing..." style="width: 100%"></li></ul>`); const $base = element.element.parents('td').first().find('.badge-lists'); $base.after($processing).hide(); const r###lt = await this.ossClient.list(element.orderId); if (typeof r###lt.objects === 'undefined') { Snackbar(`No pictures found for order ${element.orderId}, skipping,..`); return; } r###lt.objects.forEach((item) => { element.imageUrls.push((item.url)); }); if (element.imageUrls.length === 0) { Snackbar(`No pictures found for order ${element.orderId}, skipping,..`); return; } // Start the process Snackbar(`Pictures for '${element.orderId}' are being uploaded...`); // Temp store deleteHash let deleteHash; try { // Create the album const response = await this.imgurClient.CreateAlbum(element); if (typeof response === 'undefined' || response == null) { return; } // Extract and build information needed deleteHash = response.data.deletehash; const albumId = response.data.id; // Upload all QC images const promises = []; $.each(element.imageUrls, (key, imageUrl) => { promises.push(this.imgurClient.AddImageToAlbum(imageUrl, deleteHash, element.purchaseUrl)); }); // Wait until everything has been tried to be uploaded await Promise.all(promises); // Set albumId in element, so we don't upload it again (when doing a pending haul upload) element.albumId = albumId; // eslint-disable-line no-param-reassign // Tell the user it was uploaded and open the album in the background Snackbar('Pictures have been uploaded!', 'success'); GM_openInTab(element.albumUrl, true); // Tell QC Suite about our uploaded QC's (if it's supported) if (element.website !== WEBSITE_UNKNOWN) { this.qcClient.uploadQc(element, albumId); } // Wrap the logo in a href to the new album const $image = $base.find('img'); $image.wrap(`<a href='${element.albumUrl}' target='_blank' title='Go to album'></a>`); $image.removeAttr('title'); // Remove processing $processing.remove(); // Update the marker const checkMarkMessage = element.website !== WEBSITE_UNKNOWN ? 'You have uploaded a QC' : 'Album has been created'; const $qcMarker = $base.find('.qc-marker:not(:first-child)').first(); $qcMarker.attr('title', checkMarkMessage) .css('cursor', 'help') .css('color', 'green') .text('✓'); // Remove the click handler $base.off(); // Show it again $base.show(); } catch (err) { // Remove the created album this.imgurClient.RemoveAlbum(deleteHash); // Reset the button $processing.remove(); $base.show(); // Show the error Snackbar(err.message, 'error'); // If it's the slow down error, don't log it if (err instanceof ImgurSlowdownError) { return; } // Log the error Sentry.captureException(err); Logger.error(err); } } } class WeGoBuyElement { constructor($element) { this.element = $element; // Order details this.orderId = removeWhitespaces($element.find('td:nth-child(1) > p').text()); this.imageUrls = $element.find('.lookPic').map((key, value) => $(value).attr('href')).get(); // Item name this.title = truncate(removeWhitespaces($element.find('.js-item-title').text()), 255); // Item sizing (if any) const sizing = removeWhitespaces($element.find('.user_orderlist_txt').text()); this.sizing = sizing.length !== 0 ? truncate(sizing, 255) : null; // Item color (WeGoBuy doesn't support separation of color, so just null) this.color = null; // Item price const itemPriceMatches = truncate(removeWhitespaces($element.parents('tbody').find('tr > td:nth-child(2)').text()), 255).match(/([A-Z]{2})\W+([.,0-9]+)/); this.itemPrice = `${itemPriceMatches[1]} ${itemPriceMatches[2]}`; // Freight price const freightPriceMatches = truncate(removeWhitespaces($element.parents('tbody').find('tr:nth-child(1) > td:nth-child(8) > p:nth-child(2)').text()), 255).match(/([A-Z]{2})\W+([.,0-9]+)/); this.freightPrice = `${freightPriceMatches[1]} ${freightPriceMatches[2]}`; // Item weight this.weight = null; // Purchase details const possibleUrl = removeWhitespaces($element.find('.js-item-title').attr('href')).trim(); this.url = isUrl(possibleUrl) ? possibleUrl : ''; this.website = determineWebsite(this.url); // Set at a later date, if ever this.albumId = null; } /** * @return {string} */ get albumUrl() { return `https://imgur.com/a/${this.albumId}`; } /** * @param imageUrls {string[]} */ set images(imageUrls) { this.imageUrls = imageUrls; } /** * @returns {string} */ get purchaseUrl() { return cleanPurchaseUrl(this.url, this.website); } } class WeGoBuy { constructor() { this.setup = false; } /** * @param hostname {string} * @returns {boolean} */ supports(hostname) { return hostname.includes('wegobuy.com') || hostname.includes('superbuy.com'); } /** * @returns {string} */ name() { return 'WeGoBuy'; } /** * @param client {Promise<SwaggerClient>} * @returns {Promise<WeGoBuy>} */ async build(client) { // If already build before, just return if (this.setup) { return this; } // Ensure the toast looks decent on SB/WGB GM_addStyle('.swal2-popup.swal2-toast .swal2-title {font-size: 1em; font-weight: bolder}'); // Get the username const username = (await $.get('/ajax/user-info')).data.user_name; if (typeof username === 'undefined' || username == null || username === '') { Snackbar('You need to be logged in to use this extension.'); return this; } // Ensure we know who triggered the error const userHash = SparkMD5.hash(username); Sentry.setUser({ id: userHash, username }); // Build all the clients this.imgurClient = new Imgur(GM_info.script.version, GM_config, this.name()); this.qcClient = new QC(GM_info.script.version, await client, userHash, username, this.name()); // Mark that this agent has been setup this.setup = true; return this; } async process() { if (this.setup === false) { throw new Error('Agent is not setup, so cannot be used'); } // Make copy of the current this, so we can use it later const agent = this; // Add icons to all elements $('.pic-list.j_picList').each(function () { agent._buildElement($(this).parents('tr')); }); } /** * @private * @param $this * @return {Promise<void>} */ async _buildElement($this) { const element = new WeGoBuyElement($this); // No pictures (like rehearsal orders), no QC options if (element.imageUrls.length === 0) { return; } // Define column in which to download button const $inspection = $this.find('td:nth-child(6)').first(); // Append download button if enabled if (GM_config.get('showImagesDownloadButton')) { const $download = $('<div style="padding:5px;"><p><span style="color: rgb(255, 140, 60);cursor: pointer;margin-top: 5px;">Download</span></p></div>'); $download.on('click', () => this._downloadHandler($download, element)); $inspection.append($download); } // This plugin only works for certain websites, so check if element is supported if (element.website === WEBSITE_UNKNOWN || element.url.length === 0) { const $upload = $(`<div style="padding:5px;"><span style="cursor: pointer;"><img src="${ImgurIcon}" alt="Create a basic album"></span></div>`); $upload.find('span').first().after($('<span class="qc-marker" style="cursor:help;margin-left:5px;color:red;font-weight: bold;" title="Not a supported URL, but you can still create an album. The QC\'s are not stored and you\'ll have to create a new album if you lose the link.">✖</span>')); $upload.on('click', () => { this._uploadToImgur(element); }); $inspection.append($upload); return; } // Show simple loading animation const $loading = $(`<div style="padding:5px;"><span style="cursor: wait;"><img src="${Loading}" alt="Loading..."></span></div>`); $inspection.append($loading); // Define upload object const $upload = $(`<div style="padding:5px;"><span class="qc-marker" style="cursor: pointer;"><img src="${ImgurIcon}" alt="Upload your QC"></span></div>`); // If we couldn't talk to FR:Reborn, assume everything is dead and use the basic uploader. const albumId = await this.qcClient.existingAlbumByOrderId(element); if (albumId === '-1') { $upload.find('span').first().after($('<span class="qc-marker" style="cursor:help;margin-left:5px;color:red;font-weight: bold;" title="FR:Reborn returned an error, but you can still create an album. The QC\'s are not stored and you\'ll have to create a new album if you lose the link.">⚠️</span>')); $upload.on('click', () => { this._uploadToImgur(element); }); $inspection.append($upload); $loading.remove(); return; } // Have you ever uploaded a QC? If so, link to that album const $image = $upload.find('img'); if (albumId !== null && albumId !== '-1') { $upload.find('span').first().after($('<span class="qc-marker" style="cursor:help;margin-left:5px;color:green;font-weight: bold;" title="You have uploaded a QC">✓</span>')); $image.wrap(`<a href='https://imgur.com/a/${albumId}' target='_blank' title='Go to album' style="display: initial;"></a>`); $image.removeAttr('title'); $inspection.append($upload); $loading.remove(); return; } // Has anyone ever uploaded a QC, if not, show a red marker const exists = await this.qcClient.exists(element.purchaseUrl); if (!exists) { $upload.find('span').first().after($('<span class="qc-marker" style="cursor:help;margin-left:5px;color:red;font-weight: bold;" title="No QC in database, please upload.">(!)</span>')); $upload.on('click', () => { this._uploadToImgur(element); }); $inspection.append($upload); $loading.remove(); return; } // A previous QC exists, but you haven't uploaded yours yet, show orange marker $upload.find('span').first().after($('<span class="qc-marker" style="cursor:help;margin-left:5px;color:orange;font-weight: bold;" title="Your QC is not yet in the database, please upload.">(!)</span>')); $upload.on('click', () => { this._uploadToImgur(element); }); $inspection.append($upload); $loading.remove(); } /** * @private * @param $download * @param element {WeGoBuyElement} */ async _downloadHandler($download, element) { if (this.setup === false) { throw new Error('Agent is not setup, so cannot be used'); } if (!await ConfirmDialog()) { return; } // Remove button so people don't do dumb shit $download.remove(); Snackbar('Zipping images, this might take a while....', 'info'); // Create a zip file writer const zipWriter = new zip.ZipWriter(new zip.BlobWriter('application/zip')); // Download all the images and add to the zip const promises = []; $.each(element.imageUrls, (key, imageUrl) => { promises.push(toDataURL(imageUrl.replace('http://', 'https://')) .then((dataURI) => zipWriter.add(imageUrl.substring(imageUrl.lastIndexOf('/') + 1), new zip.Data64URIReader(dataURI)))); }); // Wait for all images to be added to the ZIP await Promise.all(promises); // Close the ZipWriter object and download to computer saveAs(await zipWriter.close(), `${element.orderId}.zip`); Snackbar(`Downloading ${element.orderId}.zip`, 'success'); } /** * @param element {WeGoBuyElement} * @returns {Promise<void>} */ async _uploadToImgur(element) { if (this.setup === false) { throw new Error('Agent is not setup, so cannot be used'); } const $processing = $(`<div style="padding:5px;"><span style="cursor: wait;"><img src="${Loading}" alt="Processing..."></span></div>`); const $options = element.element.find('td:nth-child(6)').first(); const $base = $options.find('div').last(); $base.after($processing).hide(); // Start the process Snackbar(`Pictures for '${element.orderId}' are being uploaded...`); // Temp store deleteHash let deleteHash; try { // Create the album const response = await this.imgurClient.CreateAlbum(element); if (typeof response === 'undefined' || response == null) { return; } // Extract and build information needed deleteHash = response.data.deletehash; const albumId = response.data.id; // Upload all QC images const promises = []; $.each(element.imageUrls, (key, imageUrl) => { promises.push(this.imgurClient.AddImageToAlbum(imageUrl, deleteHash, element.purchaseUrl)); }); // Wait until everything has been tried to be uploaded await Promise.all(promises); // Set albumId in element, so we don't upload it again (when doing a pending haul upload) element.albumId = albumId; // eslint-disable-line no-param-reassign // Tell the user it was uploaded and open the album in the background Snackbar('Pictures have been uploaded!', 'success'); GM_openInTab(element.albumUrl, true); // Tell QC Suite about our uploaded QC's (if it's supported) if (element.website !== WEBSITE_UNKNOWN) { this.qcClient.uploadQc(element, albumId); } // Remove processing $processing.remove(); $base.remove(); // Add new buttons const checkMarkMessage = element.website !== WEBSITE_UNKNOWN ? 'You have uploaded a QC' : 'Album has been created'; $options.append($('<div style="padding:5px;">' + `<span class="qc-marker" style="cursor:pointer;"><a href='${element.albumUrl}' target='_blank' title='Go to album' style="display: initial;"><img src="${ImgurIcon}" alt="Go to album"></a></span>` + `<span class="qc-marker" style="cursor:help;margin-left:5px;color:green;font-weight: bold;" title="${checkMarkMessage}">✓</span>` + '</div>')); // Remove the click handler $base.off(); // Show it again $base.show(); } catch (err) { // Remove the created album this.imgurClient.RemoveAlbum(deleteHash); // Reset the button $processing.remove(); $base.show(); // Show the error Snackbar(err.message, 'error'); // If it's the slow down error, don't log it if (err instanceof ImgurSlowdownError) { return; } // Log the error Sentry.captureException(err); Logger.error(err); } } } /** * @param hostname {string} * * @returns {BaseTao|CSSBuy|WeGoBuy|null} */ function getAgent(hostname) { const agents = [new BaseTao(), new CSSBuy(), new WeGoBuy()]; let agent = null; Object.values(agents).forEach((value) => { if (agent == null && value.supports(hostname)) { agent = value; } }); return agent; } // Inject snackbar css style GM_addStyle(GM_getResourceText('sweetalert2')); // Setup proper settings menu GM_config.init('Settings', { serverSection: { label: 'QC Server settings', type: 'section', }, swaggerDocUrl: { label: 'Swagger documentation URL', type: 'text', default: 'https://www.fashionreps.page/api/doc.json', }, generalSection: { label: 'General options', type: 'section', }, showImagesDownloadButton: { label: 'Show the images download button/text', type: 'checkbox', default: 'true', }, uploadSection: { label: 'Upload API Options', type: 'section', }, imgurApi: { label: 'Select your Imgur API', type: 'radio', default: 'imgur', options: { imgur: 'Imgur API (Free)', rapidApi: 'RapidAPI (Freemium)', }, }, imgurSection: { label: 'Imgur Options', type: 'section', }, imgurApiHost: { label: 'Imgur host', type: 'text', default: 'api.imgur.com', }, imgurClientId: { label: 'Imgur Client-ID', type: 'text', default: 'e4e18b5ab582b4c', }, rapidApiSection: { label: 'RadidAPI Options', type: 'section', }, rapidApiHost: { label: 'RapidAPI host', type: 'text', default: 'imgur-apiv3.p.rapidapi.com', }, rapidApiKey: { label: 'RapidAPI key (only needed if RapidApi select above)', type: 'text', default: '', }, rapidApiBearer: { label: 'RapidAPI access token (only needed if RapidApi select above)', type: 'text', default: '', }, }); // Reload page if config changed GM_config.onclose = (saveFlag) => { if (saveFlag) { window.location.reload(); } }; // Register menu within GM GM_registerMenuCommand('Settings', GM_config.open); // Setup Sentry for proper error logging, which will make my lives 20x easier, use XHR since fetch dies. Sentry.init({ dsn: 'https://[email protected]/5802425', tunnel: 'https://www.fashionreps.page/sentry/tunnel', transport: Sentry.Transports.XHRTransport, release: GM_info.script.version, defaultIntegrations: false, integrations: [ new Sentry.Integrations.InboundFilters(), new Sentry.Integrations.FunctionToString(), new Sentry.Integrations.LinkedErrors(), new Sentry.Integrations.UserAgent(), ], environment: 'production', normalizeDepth: 5, }); // eslint-disable-next-line func-names (async function () { // Setup the logger. Logger.useDefaults(); // Log the start of the script. Logger.info(`Starting extension '${GM_info.script.name}', version ${GM_info.script.version}`); // Get the proper agent, if any const agent = getAgent(window.location.hostname); if (agent === null) { Sentry.captureMessage(`Unsupported website ${window.location.hostname}`); Logger.error('Unsupported website'); return; } Logger.info(`Agent '${agent.name()}' detected`); // Finally, try to build the proper agent and process the page try { await agent.build(new SwaggerClient({ url: GM_config.get('swaggerDocUrl') })); await agent.process(); } catch (error) { if (error.message.includes('Failed to fetch') || error.message.includes('attempting to fetch resource')) { Snackbar('We are unable to connect to FR:Reborn, features will be disabled.'); Logger.error(`We are unable to connect to FR:Reborn: ${GM_config.get('swaggerDocUrl')}`, error); return; } Snackbar(`An unknown issue has occurred when trying to setup the agent extension: ${error.message}`); Logger.error('An unknown issue has occurred', error); } }());