Kindle Unlimited 返却支援

Amazon.co.jp の Kindle Unlimited の返却を支援

作者のサイトでサポートを受ける。または、このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください。
// ==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();

} )();