記録をFediverse(Misskey, Mastodon)へ投稿
// ==UserScript== // @name Annict 記録をFediverseへ投稿するやつ // @namespace https://midra.me // @version 1.1.1 // @description 記録をFediverse(Misskey, Mastodon)へ投稿 // @author Midra // @license MIT // @match https://annict.com/* // @icon https://www.google.com/s2/favicons?sz=64&domain=annict.com // @run-at document-end // @noframes // @grant unsafeWindow // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // @grant GM_xmlhttpRequest // @connect annict.com // @require https://greasyfork.org/scripts/7212-gm-config-eight-s-version/code/GM_config%20(eight's%20version).js?version=156587 // ==/UserScript== (() => { 'use strict' //---------------------------------------- // 設定初期化 //---------------------------------------- const configInitData = { postTo: { label: '投稿先', type: 'select', default: 'misskey', options: { all: 'すべて', misskey: 'Misskey', mastodon: 'Mastodon', }, }, annictUserName: { label: 'Annict ユーザ名', type: 'text', default: '', }, annictToken: { label: 'Annict アクセストークン', type: 'text', default: '', }, misskeyInstance: { label: 'Misskey インスタンス (httpsは省略)', type: 'text', default: 'misskey.io', }, misskeyVisibility: { label: 'Misskey 公開範囲', type: 'select', default: 'public', options: { public: 'パブリック', home: 'ホーム', followers: 'フォロワー', }, }, misskeyToken: { label: 'Misskey アクセストークン', type: 'text', default: '', }, mastodonInstance: { label: 'Mastodon インスタンス (httpsは省略)', type: 'text', default: 'mstdn.jp', }, mastodonVisibility: { label: 'Mastodon 公開範囲', type: 'select', default: 'public', options: { public: '公開', unlisted: '未収載', private: 'フォロワー限定', }, }, mastodonToken: { label: 'Mastodon アクセストークン', type: 'text', default: '', }, } GM_config.init('Annict 記録をFediverseへ投稿するやつ 設定', configInitData) GM_config.onload = () => { setTimeout(() => { alert('設定を反映させるにはページを再読み込みしてください。') }, 200) } GM_registerMenuCommand('設定', GM_config.open) // 設定取得 const config = {} Object.keys(configInitData).forEach(v => { config[v] = GM_config.get(v) }) const getWork = async (workId) => { try { const res = await fetch(`https://api.annict.com/v1/works?${new URLSearchParams({ filter_ids: workId, fields: 'title', access_token: config['annictToken'], })}`) const json = await res.json() return json['works'][0] } catch (e) { console.error(e) } } const getEpisode = async (episodeId) => { try { const res = await fetch(`https://api.annict.com/v1/episodes?${new URLSearchParams({ filter_ids: episodeId, fields: 'number_text,work.title,work.twitter_hashtag', access_token: config['annictToken'], })}`) const json = await res.json() return json['episodes'][0] } catch (e) { console.error(e) } } const postToFediverse = async (text) => { if (typeof text === 'string' && text !== '') { try { // Misskeyへ投稿 if ( ( config['postTo'] === 'all' || config['postTo'] === 'misskey' ) && config['misskeyInstance'] && config['misskeyToken'] ) { await fetch(`https://${config['misskeyInstance']}/api/notes/create`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ i: config['misskeyToken'], text: text, visibility: config['misskeyVisibility'], }), }) } // Mastodonへ投稿 if ( ( config['postTo'] === 'all' || config['postTo'] === 'mastodon' ) && config['mastodonInstance'] && config['mastodonToken'] ) { await fetch(`https://${config['mastodonInstance']}/api/v1/statuses`, { method: 'POST', headers: { 'Authorization': `Bearer ${config['mastodonToken']}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ status: text, visibility: config['mastodonVisibility'], }), }) } } catch (e) { console.error(e) } } } unsafeWindow.fetch = new Proxy(unsafeWindow.fetch, { apply: async function(target, thisArg, argumentsList) { const promise = Reflect.apply(target, thisArg, argumentsList) if ( argumentsList[0].startsWith('/api/internal/works/') && argumentsList[0].endsWith('/status_select') ) { let postText /** @type {Response} */ const response = await promise const body = JSON.parse(argumentsList[1]?.body || '{}') if (response.ok) { const status = { 'no_status': ['未選択'], 'plan_to_watch': ['見たい', 'wanna_watch'], 'watching': ['見てる', 'watching'], 'completed': ['見た', 'watched'], 'on_hold': ['一時中断', 'on_hold'], 'dropped': ['視聴中止', 'stop_watching'], }[body['status_kind']] const workId = argumentsList[0].split('/')[4] if (status[1] && workId) { const work = await getWork(workId) const title = work['title'] if (title) { postText = `アニメ「${title}」の視聴ステータスを「${status[0]}」にしました https://annict.com/@${config['annictUserName']}/${status[1]}` } } } await postToFediverse(postText) return response } return promise } }) unsafeWindow.XMLHttpRequest.prototype.send = new Proxy(unsafeWindow.XMLHttpRequest.prototype.send, { apply: async function(target, thisArg, argumentsList) { Reflect.apply(target, thisArg, argumentsList) /** @type {XMLHttpRequest} */ const req = thisArg req.addEventListener('load', async () => { try { if ([200, 201].includes(req.status)) { let postText const response = JSON.parse(req.response || '{}') const body = JSON.parse(argumentsList[0] || '{}') if (req.responseURL.endsWith('/api/internal/episode_records')) { const record_id = response['record_id'] const episode_id = body['episode_id'] if (record_id && episode_id) { const episode = await getEpisode(episode_id) const number_text = episode['number_text'] const title = episode['work']['title'] const twitter_hashtag = episode['work']['twitter_hashtag'] if (number_text && title) { postText = `${title} ${number_text} を見ました https://annict.com/@${config['annictUserName']}/records/${record_id} ${twitter_hashtag ? `#${twitter_hashtag}` : ''}` } } } await postToFediverse(postText) } } catch (e) { console.error(e) } }) } }) })()