FriendsKit

friends.nico の独自機能を再現するユーザスクリプト

目前為 2019-03-31 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name            FriendsKit
// @namespace       https://github.com/yuzulabo
// @version         1.2.2
// @description     friends.nico の独自機能を再現するユーザスクリプト
// @author          nzws
// @match           https://knzk.me/*
// @grant           GM_addStyle
// @grant           GM_setClipboard
// @grant           GM_xmlhttpRequest
// @grant           unsafeWindow
// @connect         friendskit.nzws.me
// @connect         media.knzk.me
// @require         https://unpkg.com/blob-util/dist/blob-util.min.js
// ==/UserScript==

const s = localStorage.friendskit;
const F = {
    conf: s ? JSON.parse(s) : {
        keyword: []
    },
    imgcache: {},
    iconcache: {}
};
const api = F.conf.api_server ? F.conf.api_server : 'https://friendskit.nzws.me/api/';
const user_emoji_regexp = new RegExp(':(_|@)([A-Za-z0-9_@.]+):', 'gm');
const shorten_regexp = new RegExp('(sm|nm|im|sg|mg|bk|lv|co|ch|ar|ap|jk|nw|l\/|dic\/|user\/|mylist\/)([0-9]+)', 'gm');

const keyword_escaped = [];
F.conf.keyword.forEach(value => {
    ['\\', '^', '$', '*', '+', '?', '.', '(', ')', '|', '{', '}', '[', ']'].forEach(meta => {
        value = value.replace(meta, '\\' + meta);
    });
    keyword_escaped.push(value);
});
const keyword_regexp = new RegExp(`(${keyword_escaped.join('|')})`, 'gim');

const observer = new MutationObserver((mutations) => {
    mutations.forEach((mutation) => {
        mutation.addedNodes.forEach(node => runner(node));
    });
});

function watcher() {
    const p = location.pathname;
    if (F.path !== p) {
        runner(document.querySelector('.column:last-child'));

        if (p === '/web/getting-started' && !document.querySelector('.friendskit-cp-btn')) {
            const settingLi = document.createElement('li');
            settingLi.innerHTML = ` · <a href="#" class="friendskit-cp-btn">FriendsKit CP</a>`;

            document.querySelector('.getting-started__footer ul').appendChild(settingLi);
            settingLi.addEventListener('click', openCP);
        }
    }
    F.path = p;
}

function runner(node) {
    if (!node.tagName) return;
    const statusAll = node.querySelectorAll('.status__content');
    if (!statusAll[0]) return;

    for (let status of statusAll) {
        const display_name_account = status.parentNode.querySelector('.display-name__account');
        const status_display_name = status.parentNode.querySelector('.status__display-name');
        const origin_acct = display_name_account ? display_name_account.textContent.slice(1) : status_display_name.title;
        const origin_domain = '@' + (origin_acct.indexOf('@') !== -1 ? origin_acct.split('@')[1] : location.hostname);

        replaceTool(status, origin_domain);
    }
}

function replaceTool(status, domain) {
    if (status.hasChildNodes()) {
        for (let node of status.childNodes) {
            replaceTool(node, domain);
        }
    } else {
        if (status.nodeName === '#text') {
            let is_replaced = false;
            const html = document.createElement('span');
            html.innerHTML = status.data;

            html.innerHTML = html.innerHTML.replace(keyword_regexp, `<span style='color: orange'>$1</span>`)
                .replace(shorten_regexp, `<a href="http://nico.ms/$1$2" target="_blank" rel=”nofollow”>$1$2</a>`);

            if (html.innerHTML !== status.data) {
                status.parentNode.replaceChild(html, status);
                is_replaced = true;
            }

            const ue_found = html.innerHTML.match(user_emoji_regexp);
            if (ue_found) {
                ue_found.forEach(async data => {
                    let acct = data.slice(2).slice(0, -1);
                    if (acct.indexOf('@') === -1) acct += domain;

                    const image = await getIconUrl(acct);
                    html.innerHTML = html.innerHTML.replace(new RegExp(data, 'gm'), `<img draggable="false" class="emojione" alt=":_${acct}:" title="${acct}" src="${image}"/>`);
                });

                if (!is_replaced) {
                    status.parentNode.replaceChild(html, status);
                }
            }
        }
    }
}

function openCP() {
    const div = document.createElement('div');
    div.className = 'friendskit-cp';
    div.innerHTML = `
<div class="close fcp-clickable" data-fcp="close"><i class="fa fa-times fa-2x" data-fcp="close"></i></div>
<div class="h1">FriendsKit CP</div>

<div class="h2">キーワードハイライト設定</div>
カンマ(,)区切りで指定
<textarea id="friendskit-keyword" rows="5">${F.conf.keyword.join(',')}</textarea>

<div class="h2">お気に入りアイコン設定</div>
<p>
* アイコン設定で複数の設定がされている時、この設定で一番上の物が優先されます。
</p>

<label for="fav_icon_default_force"><input class="check_boxes" type="checkbox" id="fav_icon_default_force" ${F.conf.fav_icon_default_force ? 'checked' : ''}>強制的に星に戻す</label>

文字にする (1~2文字程度):<br>
<input id="fav_icon_char" placeholder="空欄で解除" class="input-text" value="${F.conf.fav_icon_char ? F.conf.fav_icon_char : ''}">

画像にする (画像URLを指定):<br>
<input id="fav_icon" placeholder="アクティブ(明るい方)" class="input-text" value="${F.conf.fav_icon ? F.conf.fav_icon : ''}">
<input id="fav_icon_gray" placeholder="暗い方" class="input-text" value="${F.conf.fav_icon_gray ? F.conf.fav_icon_gray : ''}">

<div class="h2">お気に入りアイコン拡大</div>

<label for="no_fav_icon_big"><input class="check_boxes" type="checkbox" id="no_fav_icon_big" ${F.conf.no_fav_icon_big ? 'checked' : ''}>無効にする</label>

<div class="h2">細かなやつ</div>
<button class="button fcp-clickable" data-fcp="import-settings">設定のインポート</button>
<button class="button fcp-clickable" data-fcp="export-settings">設定のエクスポート</button>
<button class="button danger fcp-clickable" data-fcp="reset-settings">設定のリセット</button>

<button class="button button--block fcp-clickable" style="margin: 20px 0" data-fcp="save">設定を保存</button>

<div class="h3">FriendsKit v1.2.2</div>
<p>
GitHub: <a href="https://github.com/yuzulabo/FriendsKit" target="_blank">yuzulabo/FriendsKit</a><br>
Greasy Fork: <a href="https://greasyfork.org/ja/scripts/381132-friendskit" target="_blank">381132-friendskit</a>
</p>
`;
    document.body.appendChild(div);
    document.querySelector('.app-holder').classList.add('friendskit-disable');

    const btns = document.querySelectorAll(".fcp-clickable");
    for (let btn of btns) {
        btn.addEventListener('click', (e) => CPOpr(e));
    }
}

function CPOpr(e) {
    const mode = e.target.dataset.fcp;
    if (!mode) return;
    if (mode === 'save') {
        const keywords = Array.from(new Set(document.getElementById('friendskit-keyword').value.split(',')));

        const newConf = {
            keyword: keywords,
            fav_icon_default_force: document.getElementById('fav_icon_default_force').checked,
            fav_icon_char: document.getElementById('fav_icon_char').value,
            fav_icon: document.getElementById('fav_icon').value,
            fav_icon_gray: document.getElementById('fav_icon_gray').value,
            no_fav_icon_big: document.getElementById('no_fav_icon_big').checked,
        };
        Object.assign(F.conf, newConf);
        save();

        alert('保存しました。再読み込みします...');
        location.reload();
    } else if (mode === 'close') {
        if (!confirm('変更は破棄されますがよろしいですか?')) return;

        const element = document.querySelector('.friendskit-cp');
        element.parentNode.removeChild(element);
        document.querySelector('.app-holder').classList.remove('friendskit-disable');
    } else if (mode === 'import-settings') {
        const code = prompt('エクスポート時に出力したコードを入力してください:');
        if (!code) return;

        if (friendskit.importSettings(code)) {
            alert('インポートしました。再読み込みします...');
            location.reload();
        } else {
            alert('このコードは壊れています。インポートできません。');
        }
    } else if (mode === 'export-settings') {
        friendskit.exportSettings('CP');
        alert('設定のエクスポートをクリップボードにコピーしました。');
    } else if (mode === 'reset-settings') {
        if (!confirm('設定をリセットします!よろしいですか?')) return;
        if (!confirm('まじで?')) return;
        if (!confirm('まじか...')) return;

        if (friendskit.resetSettings()) {
            location.reload();
        }
    }
}

const friendskit = {
    keyword: {
        add: (word) => {
            const key = F.conf.keyword.indexOf(word);
            if (key !== -1) {
                console.warn('[FriendsKit]', 'このワードは追加済みです');
                return;
            }

            F.conf.keyword.push(word);
            save();
            console.log('[FriendsKit]', 'Done✨');
        },
        remove: (word) => {
            const key = F.conf.keyword.indexOf(word);
            if (key === -1) {
                console.warn('[FriendsKit]', 'このワードは存在しません');
                return;
            }

            delete F.conf.keyword[key];
            save();
            console.log('[FriendsKit]', 'Done✨');
        },
        list: () => {
            console.log('[FriendsKit]', F.conf.keyword);
        },
        reset: () => {
            F.conf.keyword = [];
            save();
            console.log('[FriendsKit]', 'Done✨');
        }
    },
    changeSettings: (name, value) => {
        F.conf[name] = value ? value : null;
        save();
        console.log('[FriendsKit]', 'Done✨');
    },
    exportSettings: (type) => {
        if (type === 'CP') {
            GM_setClipboard(localStorage.friendskit);
        } else {
            GM_setClipboard('friendskit.importSettings(`' + localStorage.friendskit + '`)');
            console.log('[FriendsKit]', 'Done✨\nクリップボードにコピーしたコードをインポートしたいページの Console にそのまま打ち込んでください。');
        }
    },
    resetSettings: () => {
        delete localStorage.friendskit;
        return !localStorage.friendskit;
    },
    importSettings: (data) => {
        try {
            JSON.parse(data);
        } catch(e) {
            console.warn('[FriendsKit]', 'このデータは壊れています', e);
            return false;
        }
        localStorage.friendskit = data;
        console.log('[FriendsKit]', 'Done✨');
        return true;
    }
};
exportFunction(friendskit, unsafeWindow, {defineAs: 'friendskit' });

async function getImage(url) {
    return new Promise(resolve => {
        if (F.imgcache[url]) {
            resolve(F.imgcache[url]);
            return;
        }
        blobUtil.imgSrcToDataURL(url, 'image/png', 'Anonymous').then(function (dataurl) {
            F.imgcache[url] = dataurl;
            resolve(F.imgcache[url]);
        }).catch(function (err) {
            console.warn('[FriendsKit]', '画像取得に失敗', url);
        });
    });
}

async function getIconUrl(acct) {
    return new Promise(resolve => {
        if (F.iconcache[acct]) {
            resolve(F.iconcache[acct]);
            return;
        }

        GM_xmlhttpRequest({
            method: 'POST',
            responseType: 'json',
            url: api + 'get_icon.php?acct=' + acct,
            onerror: () => {
                console.warn('[FriendsKit]', 'json取得に失敗', acct);
                return;
            },
            onload: (response) => {
                if (response.status !== 200) {
                    console.warn('[FriendsKit]', `json取得に失敗 ${response.status}`, acct);
                    return;
                }

                if (response.response.error) {
                    console.warn('[FriendsKit]', response.response.error, acct);
                    return;
                }

                F.iconcache[acct] = response.response.url;
                resolve(F.iconcache[acct]);
            }
        });
    });
}

function save() {
    const data = JSON.stringify(F.conf);
    localStorage.friendskit = data;
}

function at_pizza() {
    const textarea = document.querySelector('.autosuggest-textarea__textarea');
    if (!textarea) return;

    if (textarea.value.match(/[@|@]ピザ/)) {
        window.open('https://www.google.com/search?q=近くのピザ屋さん');
    }

    if (textarea.value.match(/[@|@]ハローワーク/)) {
        window.open('https://www.hellowork.go.jp/');
    }
}

const mainElem = document.getElementById('mastodon');
if (!mainElem) return;

observer.observe(mainElem, { childList: true, subtree: true });

let css = `
.friendskit-cp {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: #e6ebf0;
color: #000;
padding: 20px;
border-radius: 5px;
min-width: 55%;
max-width: 95%;
min-height: 50%;
max-height: 65%;
overflow-y: scroll;
}

.friendskit-disable {
filter: blur(4px);
pointer-events: none;
}

.friendskit-cp textarea, .friendskit-cp .input-text, .friendskit-cp label {
display: block;
width: 100%;
margin: 10px 0;
}

.fcp-clickable {
cursor: pointer;
}

.h1 {
font-size: 2.4rem;
}

.h2 {
font-size: 1.7rem;
}

.h3 {
font-size: 1.3rem;
}

.h1, .h2, .h3 {
margin: 1rem 0;
padding-bottom: 0.3rem;
font-weight: 500;
line-height: 1.2;
border-bottom: 1px solid #c0cdd9;
}

.close {
float: right;
color: #000;
text-shadow: 0 1px 0 #fff;
opacity: .5;
}

.button.danger {
background: #df405a;
}
`;

window.onload = async () => {
    setInterval(watcher, 1000);

    document.querySelector('.compose-form__publish-button-wrapper button').addEventListener('click', at_pizza, false);
    document.querySelector('.autosuggest-textarea__textarea').onkeydown = (e) => {
        if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) at_pizza();
    };

    if (!F.conf.fav_icon_default_force) {
        const i = F.conf.fav_icon ? await getImage(F.conf.fav_icon) : 'https://media.knzk.me/media_attachments/files/004/510/885/original/caf4ce0b3e7bd6d3.png';
        const ig = F.conf.fav_icon_gray ? await getImage(F.conf.fav_icon_gray) : 'https://media.knzk.me/media_attachments/files/004/510/887/original/bfb5e4222a3423fd.png';

        const char = F.conf.fav_icon_char ? F.conf.fav_icon_char : null;

        css += `
.fa-star {
background-image: ${char || !ig ? `none` : `url('${ig}')`};
width: 16px;
height: 16px;
background-size: cover;
background-repeat: no-repeat;
background-position: center center;
}

.fa-star:before {
content: '${char ? char : (ig ? '' : '\\f005')}';
}

.active .fa-star, .notification__message .fa-star {
background-image: ${char || !i ? `none` : `url('${i}')`};
}

.active .fa-star:before, .notification__message .fa-star:before {
content: '${char ? char : (i ? '' : '\\f005')}';
}
`;

        if (i && !char) {
            css += `
.active .fa-star, .notification__message .fa-star {
background-image: url('${i}');
}
`;
        }
    }

    if (!F.conf.no_fav_icon_big) {
        css += `
.status__info, .status__content {
margin-right: 40px;
}

.status button.star-icon {
position: absolute;
top: 20px;
right: 10px;
z-index: 999999;
}

.status .fa-star {
width: 40px;
height: 40px;
font-size: 2em;
}
`;
    }

    GM_addStyle(css);
};