Greasy Fork

Greasy Fork is available in English.

Helper to return of Kindle Unlimited loans

Help with the return of Kindle Unlimited loans in Amazon.co.jp.

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name            Helper to return of Kindle Unlimited loans
// @name:ja         Kindle Unlimited 返却支援
// @namespace       https://furyutei.work
// @license         MIT
// @version         0.1.1
// @description     Help with the return of Kindle Unlimited loans in Amazon.co.jp.
// @description:ja  Amazon.co.jp の Kindle Unlimited の返却を支援
// @author          furyu
// @match           https://www.amazon.co.jp/*
// @grant           none
// @compatible      chrome
// @compatible      firefox
// @supportURL      https://github.com/furyutei/amzKindleUnlimitedHelper/issues
// @contributionURL https://memo.furyutei.work/about#%E6%B0%97%E3%81%AB%E5%85%A5%E3%81%A3%E3%81%9F%E5%BD%B9%E3%81%AB%E7%AB%8B%E3%81%A3%E3%81%9F%E3%81%AE%E3%81%8A%E6%B0%97%E6%8C%81%E3%81%A1%E3%81%AF%E3%82%AE%E3%83%95%E3%83%88%E5%88%B8%E3%81%A7
// ==/UserScript==

( async () => {
'use strict';

const
    SCRIPT_NAME = 'amzKindleUnlimitedHelper',
    DEBUG = false,
    
    CSS_STYLE_CLASS = SCRIPT_NAME + '-css-rule',
    
    TIME_INTERVAL_TO_CONFIRM_RETURN_FIRST = 5000, // 初回返却確認までの遅延時間(ミリ秒)
    TIME_INTERVAL_TO_CONFIRM_RETURN = 1000, // 返却確認間隔(ミリ秒)
    MAX_RETURN_CONFIRM_RETRY_NUMBER = 50, // 最大返却再確認回数
    
    get_log_timestamp = () => {
        return new Date().toISOString();
    },
    
    log_debug = ( ... args ) => {
        if ( ! DEBUG ) {
            return;
        }
        console.debug( '%c' + '[' + SCRIPT_NAME + '] ' + get_log_timestamp(), 'color: gray;', ... args );
    },
    
    log = ( ... args ) => {
        console.log( '%c' + '[' + SCRIPT_NAME + '] ' +  + get_log_timestamp(), 'color: teal;', ... args );
    },
    
    log_info = ( ... args ) => {
        console.info( '%c' +  '[' + SCRIPT_NAME + '] ' + get_log_timestamp(), 'color: darkslateblue;', ... args );
    },
    
    log_error = ( ... args ) => {
        console.error( '%c' + '[' + SCRIPT_NAME + '] ' + get_log_timestamp(), 'color: purple;', ... args );
    },
    
    get_csrf_token = ( doc ) => {
        if ( ! doc ) {
            doc = document;
        }
        return Array.from( doc.querySelectorAll( 'script' ) ).map( script => script.textContent.match( /\s*csrfToken\s*=\s*"(.*?)"/i ) && RegExp.$1 ).filter( csrfToken => csrfToken )[ 0 ];
    },
    
    PAGE_TYPE = {
        unknown : undefined,
        my_contents : 'My Contents',
        loaned_book : 'Loaned Book',
        orderd_product : 'Orderd Product',
    },
    
    CURRENT_PAGE_INFO = await ( async () => {
        if ( /^\/(?:hz\/mycd\/myx|mn\/dcw\/myx\.html)/.test( new URL( location.href ).pathname ) ) {
            return {
                type : PAGE_TYPE.my_contents,
                csrf_token : get_csrf_token(),
            };
        }
        
        let ebooksInstantOrderUpdate = document.querySelector( '#ebooksInstantOrderUpdate' );
        
        if ( ! ebooksInstantOrderUpdate ) {
            return { type : PAGE_TYPE.unknown };
        }
        
        let asin = ( ( document.querySelector('link[rel="canonical"]') || {} ).href || '' ).match( /\/dp\/([^/]+)/ ) && RegExp.$1,
            my_contents_url = ( ebooksInstantOrderUpdate.parentNode.querySelector( 'a.a-link-normal[href*="/mn/dcw/myx.html"]' ) || {} ).href;
        
        if ( ! my_contents_url ) {
            return { type : PAGE_TYPE.orderd_product, asin };
        }
        
        let csrf_token = await fetch( my_contents_url.replace( /#.*$/, '' ), {
                method : 'GET',
                mode : 'cors',
                credentials : 'include',
            } )
            .then( response => {
                if ( ! response.ok ) {
                    throw new Error( 'Network response was not ok' );
                }
                return response.text();
            } )
            .then( html => get_csrf_token( ( new DOMParser() ).parseFromString( html, 'text/html' ) ) )
            .catch( error => {
                log_error( 'fetch() error: url=', my_contents_url, error );
            } );
        
        return {
            type : PAGE_TYPE.loaned_book,
            asin,
            csrf_token,
        };
    } )() || {};

log_debug( 'CURRENT_PAGE_INFO=', CURRENT_PAGE_INFO );

switch ( CURRENT_PAGE_INFO.type ) {
    case PAGE_TYPE.my_contents :
    case PAGE_TYPE.loaned_book :
        if ( ! CURRENT_PAGE_INFO.csrf_token ) {
            log_error( 'CSRF token was not found' );
            return;
        }
        break;
    
    default:
        log_debug( 'This page is not supported' );
        return;
}

const
    wait = async ( wait_msec ) => await new Promise( resolve => setTimeout( resolve, ( ( ! Number.isInteger( wait_msec ) ) || wait_msec <= 0 ) ? 1 : wait_msec ) ),
    
    insert_css_rule = () => {
        const
            loading_mask_class = SCRIPT_NAME + '-loading-mask',
            css_rule_text = `
                .${loading_mask_class} {
                    position : fixed;
                    top : 0;
                    left : 0;
                    z-index : 10000;
                    width : 100%;
                    height : 100%;
                    background : black;
                    opacity : 0.5;
                }
                
                .${loading_mask_class} .loading {
                    position : absolute;
                    top : 0;
                    right : 0;
                    bottom : 0;
                    left : 0;
                    margin : auto;
                    width : 100px;
                    height : 100px;
                    color : #F3A847;
                }
                
                .${loading_mask_class} .loading svg {
                    animation: ${SCRIPT_NAME}_now_loading 1.5s linear infinite;
                }
                
                @keyframes ${SCRIPT_NAME}_now_loading {
                    0% {transform: rotate(0deg);}
                    100% {transform: rotate(360deg);}
                }
            `;
        
        let css_style = document.querySelector( '.' + CSS_STYLE_CLASS );
        
        if ( css_style ) css_style.remove();
        
        css_style = document.createElement( 'style' );
        css_style.classList.add( CSS_STYLE_CLASS );
        css_style.textContent = css_rule_text;
        
        document.querySelector( 'head' ).appendChild( css_style );
    },
    
    loading_mask = ( () => {
        const
            loading_icon_svg = '<svg version="1.1" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><circle cx="12" cy="12" fill="none" r="10" stroke-width="4" style="stroke: currentColor; opacity: 0.4;"></circle><path d="M 12,2 a 10 10 -90 0 1 9,5.6" fill="none" stroke="currentColor" stroke-width="4" />',
            loading_mask = document.createElement( 'div' );
        
        loading_mask.className = SCRIPT_NAME + '-loading-mask';
        loading_mask.insertAdjacentHTML( 'beforeend', '<div class="loading">' + loading_icon_svg + '</div>' );
        loading_mask.style.display = 'none';
        
        document.body.appendChild( loading_mask );
        
        return loading_mask;
    } )(),
    
    show_loading_mask = () => {loading_mask.style.display = 'block';},
    hide_loading_mask = () => {loading_mask.style.display = 'none';},
    
    get_loaned_info = async () => {
        let loaned_info = await fetch( 'https://www.amazon.co.jp/hz/mycd/ajax', {
                method : 'POST',
                mode : 'cors',
                credentials : 'include',
                headers : {
                    'Content-Type': 'application/x-www-form-urlencoded',
                },
                body : ( () => {
                    let search_params = new URLSearchParams();
                    
                    search_params.append( 'csrfToken', CURRENT_PAGE_INFO.csrf_token );
                    search_params.append( 'data', JSON.stringify( {
                        param : {
                            OwnershipData : {
                                sortOrder : 'DESCENDING',
                                sortIndex : 'DATE',
                                startIndex : 0,
                                batchSize : 100, // default: 18 / ~725あたりで不安定になる(GET https://www.amazon.co.jp/500 404)
                                contentType : 'ALL',
                                totalContentCount : 0,
                                itemStatus : [ 'Active', ],
                                originType : [ 'ku', ],
                            },
                        },
                    } ) );
                    return search_params;
                } )(),
            } )
            .then( response => {
                if ( ! response.ok ) {
                    throw new Error( 'Network response was not ok' );
                }
                return response.json();
            } )
            .catch( error => {
                log_error( 'get_loaned_info(): fetch() error:', error );
            } ) || {};
       
       return loaned_info;
    },
    
    get_loaned_book_info = ( asin, loaned_info ) => {
        return ( ( ( loaned_info || {} ).OwnershipData || {} ).items || [] ).filter( book_info => book_info.asin == asin )[ 0 ];
    },
    
    return_loaned_book = async ( asin, loaned_info ) => {
        if ( ! loaned_info ) {
            loaned_info = await get_loaned_info();
        }
        
        log_debug( 'loaned_info=', loaned_info );
        
        let loaned_book_info = get_loaned_book_info( asin, loaned_info );
        
        log_debug( 'loaned_book_info=', loaned_book_info );
        
        if ( ! loaned_book_info ) {
            return null;
        }
        
        let result = await fetch( 'https://www.amazon.co.jp/hz/mycd/ajax', {
                method : 'POST',
                mode : 'cors',
                credentials : 'include',
                headers : {
                    'Content-Type': 'application/x-www-form-urlencoded',
                },
                body : ( () => {
                    let search_params = new URLSearchParams();
                    
                    search_params.append( 'csrfToken', CURRENT_PAGE_INFO.csrf_token );
                    search_params.append( 'data', JSON.stringify( {
                        param : {
                            ReturnKULoan : {
                                returnLoanID : loaned_book_info.lendingId,
                            },
                        },
                    } ) );
                    return search_params;
                } )(),
            } )
            .then( response => {
                if ( ! response.ok ) {
                    throw new Error( 'Network response was not ok' );
                }
                return response.json();
            } )
            .catch( error => {
                log_error( 'get_loaned_info(): fetch() error:', error );
            } ) || {};
        
        log_debug( 'result=', result );
        
        return result;
    },
    
    update_my_contents_page = () => {
        log_debug( 'update_my_contents_page(): start' );
        
        if ( document.querySelector( '#contentAction_return_ku_myx' ) ) {
            return;
        }
        
        const
            content_ul = document.querySelector( '.contentTableList_myx > ul.nav' ),
            deliver_myx = document.querySelector( [
                '#contentAction_deliver_myx',
                '#contentAction_dummy_dlr_myx',
                '.myx-column.myx-span10 .myx-float-left:first-child > .inline_myx.button_myx:first-child > .inline_myx.button_myx .pointer_myx[bo-switch="action.type"][bo-id="action.id"]',
            ].join( ',' ) );
        
        if ( ( ! content_ul ) || ( ! deliver_myx ) ) {
            return;
        }
        
        const
            deliver_container = deliver_myx.closest( '.button_myx' ),
            return_container = deliver_container.cloneNode( true ),
            
            bulk_action = return_container.querySelector( '[type="bulkAction"]' ),
            button_action = bulk_action.querySelector( '[type="button"][action="action"]' ),
            pointer_myx = button_action.querySelector( '.pointer_myx' ),
            action_text = pointer_myx.querySelector( '[bo-text="action.text"]' ),
            counter_span = action_text.parentNode.querySelector( '.ng-binding[ng-hide]' ),
            button_link = pointer_myx.querySelector( 'a.myx-button.myx-button-primary' ),
            
            get_selected_content_list = () => {
                return Array.from( content_ul.querySelectorAll( 'i.myx-icon.icon-selected' ) ).filter( icon => icon.style.display != 'none' ).map( icon => icon.closest( 'li.myx-active' ) );
            },
            
            get_selected_ku_loan_list = () => get_selected_content_list().filter( content => content.querySelector( '[ng-switch-when="KULoan"]' ) ),
            
            update_return_container = () => {
                let selected_contents = get_selected_ku_loan_list();
                
                log_debug( 'selected_contents:', selected_contents.length, selected_contents );
                
                if ( selected_contents.length <= 0 ) {
                    button_link.classList.add( 'myx-button-disabled' );
                    counter_span.style.display = 'none';
                }
                else {
                    button_link.classList.remove( 'myx-button-disabled' );
                    counter_span.textContent = '(' + selected_contents.length + ')';
                    counter_span.style.display = 'inline';
                }
            },
            
            observer = new MutationObserver( ( records ) => {
                stop_observe();
                
                try {
                    update_return_container();
                }
                catch ( error ) {
                    log_error( error );
                }
                finally {
                    if ( content_ul.closest( '#a-page' ) && return_container.closest( '#a-page' ) ) {
                        start_observe();
                    }
                    else {
                        return_container.remove();
                    }
                }
            } ),
            start_observe = () => observer.observe( content_ul, { childList : true, subtree : true, attributes : true } ),
            stop_observe = () => observer.disconnect();
        
        bulk_action.setAttribute( 'add-directive-dmyx', 'return-ku-dmyx' );
        button_action.removeAttribute( 'deliver-dmyx' );
        button_action.removeAttribute( 'dummy-deliver-dmyx' );
        button_action.setAttribute( 'return-ku-dmyx', '' );
        pointer_myx.setAttribute( 'id', 'contentAction_return_ku_myx' );
        action_text.textContent = '返却';
        
        button_link.addEventListener( 'click', ( event ) => {
            event.preventDefault();
            event.stopPropagation();
            
            stop_observe();
            show_loading_mask();
            
            button_link.classList.add( 'myx-button-disabled' );
            
            ( async () => {
                let selected_asin_list = get_selected_ku_loan_list()
                        .map( content => ( ( content.querySelector( '[src="responsiveView"][name]' ) || document.createElement( 'b' ) ).getAttribute( 'name' ) || '' ).match( /contentTabList_(.+)/ ) && RegExp.$1 )
                        .filter( asin => asin ),
                    loaned_info = await get_loaned_info();
                
                log_debug( 'selected_asin_list=', selected_asin_list, 'loaned_info=', loaned_info );
                
                if ( ( selected_asin_list.length <= 0 ) || ( ! loaned_info ) ) {
                    update_return_container();
                    hide_loading_mask();
                    start_observe();
                    return;
                }
                
                let returned_asin_list = [];
                
                for ( let asin of selected_asin_list ) {
                    let result = await return_loaned_book( asin, loaned_info );
                    
                    if ( ( ! result ) || ( ! ( result.ReturnKULoan || {} ).success ) ) {
                        log_error( 'Failed to return book: asin=', asin );
                        continue;
                    }
                    returned_asin_list.push( asin );
                }
                
                log_debug( returned_asin_list.length, 'returned_asin_list=', returned_asin_list );
                
                if ( returned_asin_list.length <= 0 ) {
                    log_error( 'No book returned' );
                    update_return_container();
                    hide_loading_mask();
                    start_observe();
                    return;
                }
                
                // TODO: 返却をしたものが借用中リストから消えるまでタイムラグ有り
                // →暫定的に、最大( 1 + MAX_RETURN_CONFIRM_RETRY_NUMBER ) 回確認することで対処
                // TODO: 借用中リストからいったん消えたあと、再び現れることもある模様、対処困難なため保留
                await wait( TIME_INTERVAL_TO_CONFIRM_RETURN_FIRST );
                for ( let counter = 0; counter <= MAX_RETURN_CONFIRM_RETRY_NUMBER; counter ++ ) {
                    let loaned_info = await get_loaned_info(),
                        removed_counter = 0;
                    
                    for ( let asin of returned_asin_list ) {
                        let loaned_book_info = get_loaned_book_info( asin, loaned_info );
                        
                        if ( loaned_book_info ) {
                            continue;
                        }
                        
                        removed_counter ++;
                    }
                    log_debug( 'counter=', counter, 'removed_counter=', removed_counter, 'loaned_info=', loaned_info );
                    
                    if ( returned_asin_list.length <= removed_counter ) {
                        break;
                    }
                    await wait( TIME_INTERVAL_TO_CONFIRM_RETURN );
                }
                
                //update_return_container();
                //hide_loading_mask();
                //start_observe();
                location.reload( true ); // TODO: リロードせずに情報を更新したい
            } )();
        } );
        
        deliver_container.parentNode.appendChild( return_container );
        
        update_return_container();
        start_observe();
    },
    
    update_loaned_book_page = () => {
        log_debug( 'update_loaned_book_page(): start' );
        
        if ( document.querySelector( '.' + SCRIPT_NAME + '-return-button' ) ) {
            return;
        }
        
        let return_button = document.createElement( 'button' );
        
        return_button.textContent = '返却';
        return_button.className = SCRIPT_NAME + '-return-button a-text-center';
        
        return_button.addEventListener( 'click', ( event ) => {
            return_button.disabled = true;
            show_loading_mask();
            
            event.preventDefault();
            event.stopPropagation();
            
            ( async () => {
                let asin = CURRENT_PAGE_INFO.asin,
                    result = await return_loaned_book( asin );
                
                if ( ( ! result ) || ( ! ( result.ReturnKULoan || {} ).success ) ) {
                    return_button.disabled = false;
                    hide_loading_mask();
                    log_error( 'Failed to return book: asin=', asin );
                    alert( '返却できませんでした' );
                    return;
                }
                
                // TODO: 返却をしたものが借用中リストから消えるまでタイムラグ有り
                // →暫定的に、最大( 1 + MAX_RETURN_CONFIRM_RETRY_NUMBER ) 回確認することで対処
                await wait( TIME_INTERVAL_TO_CONFIRM_RETURN_FIRST );
                for ( let counter = 0; counter <= MAX_RETURN_CONFIRM_RETRY_NUMBER; counter ++ ) {
                    let loaned_info = await get_loaned_info(),
                        loaned_book_info = get_loaned_book_info( asin, loaned_info );
                    
                    log_debug( 'counter=', counter, 'loaned_info=', loaned_info, 'loaned_book_info=', loaned_book_info );
                    
                    if ( ! loaned_book_info ) {
                        break;
                    }
                    await wait( TIME_INTERVAL_TO_CONFIRM_RETURN );
                }
                
                //return_button.disabled = false;
                //hide_loading_mask();
                location.reload( true ); // TODO: リロードせずに情報を更新したい
            } )();
        } );
        
        document.querySelector( '#ebooksInstantOrderUpdate' ).after( return_button );
    },
    
    update_page = () => {
        switch ( CURRENT_PAGE_INFO.type ) {
            case PAGE_TYPE.my_contents :
                update_my_contents_page();
                break;
            case PAGE_TYPE.loaned_book :
                update_loaned_book_page();
                break;
        }
    },
    
    observer = new MutationObserver( ( records ) => {
        let initialized = false;
        
        stop_observe();
        
        try {
            update_page();
        }
        catch ( error ) {
            log_error( error );
        }
        finally {
            start_observe();
        }
    } ),
    start_observe = () => observer.observe( document.body, { childList : true, subtree : true } ),
    stop_observe = () => observer.disconnect();

insert_css_rule();
update_page();
start_observe();

} )();