FriendsKit

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

const version = '1.3.2';
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 nico_ms_shorten_regexp = new RegExp('(sm|nm|im|sg|mg|bk|lv|co|ch|ar|ap|jk|nw|so|l\/|dic\/|user\/|mylist\/)([0-9]+)', 'gmi');
const nico_ms_watch_regexp = new RegExp('(watch\/)([0-9]+)', 'gmi');

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 (v${version})</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') return;

        let is_replaced = false;
        const html = document.createElement('span');
        html.innerHTML = status.data;

        if (F.conf.keyword.length > 0 && F.conf.keyword[0]) {
            html.innerHTML = html.innerHTML.replace(keyword_regexp, `<span style='color: orange'>$1</span>`);
        }

        if (!findParentByTagName(status, 'A')) {
            html.innerHTML = html.innerHTML.replace(nico_ms_shorten_regexp, `<a href="http://nico.ms/$1$2" target="_blank" rel=”nofollow”>$1$2</a>`)
            .replace(nico_ms_watch_regexp, `<a href="http://nico.ms/$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 findParentByTagName(element, tagName, max = 3) {
    if (max < 1 || element.tagName === tagName) {
        return element.tagName === tagName;
    } else if (element.parentNode) {
        return findParentByTagName(element.parentNode, tagName, max - 1);
    } else {
        return false;
    }
}

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 v${version}</div>
<p>
<a href="https://github.com/yuzulabo/FriendsKit/releases" target="_blank">リリースノート(更新履歴) を見る</a>
</p>

<p style="margin-top: 5px">
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 keyword = document.getElementById('friendskit-keyword').value;
        const keywords = keyword ? Array.from(new Set(keyword.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 + '&domain=' + location.hostname,
            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 = await getImage(F.conf.fav_icon ? F.conf.fav_icon : 'https://i.imgur.com/iZQJgSW.png');
        const ig = await getImage(F.conf.fav_icon_gray ? F.conf.fav_icon_gray : 'https://i.imgur.com/ninYNIi.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);

    console.log(`%c..: FriendsKit v${version} :..`, '    background: black;font-size: large;color: orange');
    if (localStorage.friendskit_version !== version) {
        GM_notification({
            title: `FriendsKit v${version}にアップデートしました。`,
            text: `クリックしてリリースノート(更新履歴)を表示`,
            highlight: true,
            onclick: () => {
                window.open('https://github.com/yuzulabo/FriendsKit/releases');
            }
        });
    }
    localStorage.friendskit_version = version;
};