Greasy Fork is available in English.

dc-fetch series

시리즈 게시글 목차 불러오기

// ==UserScript==
// @name         dc-fetch series
// @namespace    http://tampermonkey.net/
// @version      0.1
// @description  시리즈 게시글 목차 불러오기
// @author       You
// @match        https://gall.dcinside.com/board/view*
// @match        https://gall.dcinside.com/mgallery/board/view*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=dcinside.com
// @grant GM_setValue
// @grant GM_getValue
// @license MIT
// ==/UserScript==

(async function() {
    'use strict';

    const CACHE_VALID_TIME = 3 * 3600 * 1000

    async function getCached(key)
    {
        const value = await GM.getValue(key);
        if(!value) return null;
        const parsed = JSON.parse(value);
        if(Date.now() - parsed.time >= CACHE_VALID_TIME) return null;
        return parsed.value;
    }

    async function setCached(key, value, forced = false)
    {
        const cached = getCached(key);
        if(!forced && JSON.stringify(value) === JSON.stringify(cached)) return cached;
        await GM.setValue(key, JSON.stringify({ value, time: Date.now() }));
        return value;
    }

    async function invalidateCached(key)
    {
        await GM.setValue(key, JSON.stringify({ value: null, time: -Infinity }));
    }

    function extractQueryString(href) // without ?
    {
        if(href.includes('?')) href = href.slice(href.indexOf('?') + 1)
        if(href.includes('#')) href = href.slice(0, href.indexOf('#'))
        return href;
    }

    function parseQueryString(str)
    {
        const pairs = extractQueryString(str).split('&').map(v => v.split('='))
        const map = new Map();

        for(let [key, value] of pairs){
            if(key.endsWith('[]')){
                key = key.slice(0, -2);
                value = value.split(',');
            }

            if(map.has(key)){
                const old = map.get(key)
                if(Array.isArray(old))
                    map.set(key, old.concat(value))
                else
                    map.set(key, [].concat(old, value))
            }else{
                map.set(key, value)
            }
        }

        return map;
    }

    async function fetchDom(uri)
    {
        const key = 'fetch|' + uri;
        let text = await getCached(key);
        if(text === null){
            console.log('fetch ' + uri);
            const res = await fetch(uri);
            text = await res.text();
            await setCached(key, text);
        }else{
            console.log('fetch ' + uri + ' from cache');
        }
        return new DOMParser().parseFromString(text, 'text/html');
    }

    async function search(id, keyword, is_mgallery = false)
    {
        const uri = `https://gall.dcinside.com/${is_mgallery ? 'mgallery/' : '' }board/lists`;
        const qs = `?id=${id}&s_type=search_subject_memo&s_keyword=${encodeURIComponent(keyword).replace(/%/g, '.')}`;
        const dom = await fetchDom(`${uri}${qs}`);
        console.log(dom);
        const search_list = dom.getElementById('kakao_seach_list');
        const trs = Array.from(search_list.getElementsByTagName('tr'));
        return trs.map(tr => {
            try{
                const $ = selector => tr.querySelector(selector)
                const text = element => element.innerText.trim()
                return {
                    no: +text($('.gall_num')),
                    uri: $('a').href,
                    title: text($('.gall_tit')),
                    gall_title: text($('.gall_name')),
                    date: text($('.gall_date'))
                }
            }catch(e){
                // console.error(dom, e)
                return null;
            }
        }).filter(article => article);
    }

    function parseTitle(title)
    {
        title = title.trim()
        if(title.match(/^.{0,4}\)/))
           title = title.split(')').slice(1).join(')');
        const matched = title.match(/(\d+)화/)
        if(matched !== null) {
            let comment = title.slice(matched.index + matched[0].length).trim()
            while(comment.startsWith('(') && comment.endsWith(')')){
                comment = comment.slice(1, -1).trim()
            }
            return {
                keyword: title.slice(0, matched.index).trim(),
                series_no: +matched[1].trim(),
                comment
            }
        } else {
            return {
                keyword: title,
                series_no: 1,
                comment: ''
            }
        }
    }

    function normalize(title)
    {
        return title.replace(/[[\]{}()~?!*&^%$#@+_":><';|\\ ,]/g, '')
    }

    function str_distance(a, b)
    {
        if(a === b) return 0;
        function make_pairs(str)
        {
            return Array(str.length-1).fill(null).map((_, i) => str.slice(i, i+2))
        }
        const a_pairs = make_pairs(a);
        const a_set = new Set(a_pairs);
        const b_pairs = make_pairs(b)
        const b_set = new Set(b_pairs);
        let distance = 1;

        b_pairs.forEach(pair => {
            if(!a_set.has(pair)) {
                ++distance
            }
        });

        a_pairs.forEach(pair => {
            if(!b_set.has(pair)) {
                ++distance
            }
        });

        return distance;
    }

    const query = parseQueryString(location.search)
    if(!query.has('id') || !query.has('no')) return;
    const id = query.get('id');
    const no = query.get('no');
    const title = document.getElementsByClassName('title_subject')[0].innerText;
    const {keyword, series_no, comment} = parseTitle(title);

    const search_result = await search(id, keyword, location.pathname.startsWith('/mgallery'));
    const normalized_keyword = normalize(keyword);
    const related = search_result
        .map(result => {
            return {
                ...result,
                ...parseTitle(result.title)
            }
        })
        .filter(article => {
            const article_qs = parseQueryString(article.uri)
            // if(article_qs.get('id') !== id) return false;
            return str_distance(normalize(article.keyword), normalized_keyword) <= 4
        })

    console.log('keyword', keyword);
    console.log('related', related);

    const series_article_sorted = related
        .concat({series_no, title, uri: location.href})
        .sort((a, b) => b.series_no - a.series_no);

    function is_same_article_uri(a, b) {
        if(typeof a !== 'string' || typeof b !== 'string') return false;
        const a_content_qs = parseQueryString(a);
        const b_content_qs = parseQueryString(b);
        return a_content_qs.get('id') === b_content_qs.get('id') && a_content_qs.get('no') === b_content_qs.get('no')
    }

    function series_content_assertion(dom) {
        const content = dom.getElementsByClassName('write_div')[0];
        if(content.innerHTML.length < 30) throw new Error('낚시(너무 짧음)');
        const series = dom.getElementsByClassName('dc_series')[0];
        if(!series) throw new Error('시리즈 없음');
    }

    async function getFail(uri)
    {
        const key = 'fail|' + uri;
        return await getCached(key);
    }


    async function setFail(uri, message)
    {
        const key = 'fail|' + uri;
        return await setCached(key, message);
    }

    async function getSeries(series_last_article)
    {
        const key = 'series|' + series_last_article.uri;
        const value = await getCached(key);

        if(value) return new DOMParser().parseFromString(value, 'text/html').body.children[0];

        const dom = await fetchDom(series_last_article.uri);
        series_content_assertion(dom);

        const series = dom.getElementsByClassName('dc_series')[0];
        const series_content = Array.from(series.children)

        const last_article_series_element = series_content.at(-2).cloneNode(true);
        last_article_series_element.href = series_last_article.uri;
        last_article_series_element.innerText = '· ' + series_last_article.title

        series.append(last_article_series_element)
        series.append(series_content.at(-1).cloneNode(true));

        await setCached(key, series.outerHTML)

        return series;
    }

    async function invalidate(uri)
    {
        await invalidateCached('fail|' + uri);
        await invalidateCached('series|' + uri);
    }

    // series_article_sorted.forEach(article => invalidate(article.uri));

    for(const series_last_article of series_article_sorted) {
        try{
            const lastFail = await getFail(series_last_article.uri);
            if(lastFail) throw { message: lastFail };

            const series = await getSeries(series_last_article);
            const series_content = Array.from(series.children)

            const content_self = series_content.filter(content => {
                if(typeof content.href !== 'string') return false;
                const content_qs = parseQueryString(content.href);
                return is_same_article_uri(content.href, location.href);
            });

            /*
            if(!is_same_article_uri(series_last_article.uri, location.href) && content_self.length === 0)
                throw new Error('자기 자신이 없음');
            */
            content_self.forEach(content => {
                content.style.fontWeight = 'bold';
            });

            const local_series = document.getElementsByClassName('dc_series')[0];
            const content = document.getElementsByClassName('write_div')[0];
            if(!local_series){
                content.prepend(series);
            }else{
                local_series.style.display = 'none';
                local_series.parentNode.insertBefore(series, local_series);
            }
            content.append(series.cloneNode(true))
            console.log('성공: ', series_last_article.uri);
            break;
        }catch(e){
            console.log('실패: ', series_last_article.uri, e);
            await setFail(series_last_article.uri, e.message);
        }
    }
})();