├── dac_icon128.png ├── dac_icon16.png ├── dac_icon48.png ├── .drone.yml ├── README.md ├── src ├── scripts │ ├── modules │ │ ├── storage │ │ │ ├── README.md │ │ │ ├── comment_alpha_storage.js │ │ │ ├── comment_offset_storage.js │ │ │ ├── ignore_ids_storage.js │ │ │ └── selected_pairs_storage.js │ │ ├── global_vars.js │ │ ├── search_api.js │ │ ├── build_search_word.js │ │ ├── dialog.js │ │ ├── fetch_thread_arguments.js │ │ └── video_info.js │ ├── background.js │ ├── hack_comment_alpha.js │ ├── index.js │ ├── popup.js │ └── hack_fetch_thread.js ├── html │ └── popup.html └── styles │ └── popup.css ├── package.json ├── webpack.config.js ├── manifest.json ├── LICENSE.md ├── gulpfile.js └── .gitignore /dac_icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noradium/dac/HEAD/dac_icon128.png -------------------------------------------------------------------------------- /dac_icon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noradium/dac/HEAD/dac_icon16.png -------------------------------------------------------------------------------- /dac_icon48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noradium/dac/HEAD/dac_icon48.png -------------------------------------------------------------------------------- /.drone.yml: -------------------------------------------------------------------------------- 1 | pipeline: 2 | build: 3 | image: node:latest 4 | commands: 5 | - npm install 6 | - npm run test 7 | 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # danime-another-comment 2 | dアニメストア ニコニコ支店 の動画に流れるコメントが増えて見えるかもしれない拡張機能です。HTML5プレーヤーでのみ動作します。 3 | 4 | ## Install 5 | https://chrome.google.com/webstore/detail/danime-another-comment/mglbjmlapcmlbkhebomcmmnkfcnallkf?hl=ja 6 | 7 | ## Build 8 | ```bash 9 | $ npm run build 10 | ``` 11 | 12 | ## License 13 | このソフトウェアは MIT ライセンスのもと公開されています。 14 | -------------------------------------------------------------------------------- /src/scripts/modules/storage/README.md: -------------------------------------------------------------------------------- 1 | # storage 2 | ignore_pairs, selected_pairs の2つのリストを localStorage に保存しています。 3 | コメントの表示非表示を決定するときの優先度は、`selected_pairs > ignore_pairs` 4 | 5 | ## ignore_ids_storage 6 | この動画のコメントは出したくないと思った動画のidリスト 7 | `string[]` 8 | 9 | ## selected_pairs_storage 10 | このコメントを出したいと思って結びつけた動画のidのペアーのリスト 11 | `{[key: string]: string}[]` 12 | -------------------------------------------------------------------------------- /src/scripts/modules/global_vars.js: -------------------------------------------------------------------------------- 1 | export class GlobalVars { 2 | static set currentDefaultThreadId(value) { 3 | sessionStorage.danimeAnotherCommentCurrentDefaultThreadId = value; 4 | } 5 | static get currentDefaultThreadId() { 6 | return sessionStorage.danimeAnotherCommentCurrentDefaultThreadId; 7 | } 8 | static set selectedAnotherVideo(value) { 9 | sessionStorage.danimeAnotherCommentSelectedAnotherVideo = JSON.stringify(value); 10 | } 11 | static get selectedAnotherVideo() { 12 | try { 13 | return JSON.parse(sessionStorage.danimeAnotherCommentSelectedAnotherVideo); 14 | } catch (e) { 15 | return null; 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/scripts/modules/search_api.js: -------------------------------------------------------------------------------- 1 | /** 2 | * ニコニコ動画の検索API 3 | * @see https://site.nicovideo.jp/search-api-docs/snapshot 4 | */ 5 | export default class SearchAPI { 6 | static fetch(word, limit = 10) { 7 | return window.fetch(`https://api.search.nicovideo.jp/api/v2/snapshot/video/contents/search?q=${encodeURIComponent(word)}%20-${encodeURIComponent('dアニメストア')}&targets=title,tagsExact&_sort=-commentCounter&fields=title,contentId,lengthSeconds,thumbnailUrl,commentCounter&_limit=${limit}&_context=danime-another-comment`) 8 | .catch((error) => { 9 | console.error(error) 10 | return Promise.reject('search_error'); 11 | }) 12 | .then((response) => response.json()); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/scripts/modules/build_search_word.js: -------------------------------------------------------------------------------- 1 | export default function buildSearchWord(title) { 2 | return title 3 | .replace(' ', ' ') // 全角スペースは半角に直しておく 4 | .replace(/第(\d+)/g, '$1') // 第◯話 の第はない場合もあるので消しておく(けもフレ対応) 5 | .replace(/[「」『』]/g, ' ') // 括弧も表記揺れがあるので消しておく(バカテス対応) 6 | .replace(/\(.*\)/, ' ') // (.*) も消して良いと思う(シュタゲ9,10話対応) 7 | .replace(/【.*】/, ' ') // 日テレオンデマンド対応 8 | .replace(/0+([0-9]+)/, "$1" ) // ゼロサプレス(とある魔術の禁書目録対応) 9 | // TODO: ゼロサプレスするとファンタシースターオンラインが死ぬので何か考えないとだめそう... (複数回検索するなど) 10 | .replace(/[#.\-"'<>]/g, ' ') // 記号系はスペースに変換しちゃっていいんじゃないかなあ。ダメなケースもあるかも(君に届け対応) 11 | // 特殊系 12 | .replace('STEINS;GATE', 'シュタインズ ゲート ') // (シュタゲ対応) 13 | .replace(/ (\d+)駅/g, ' $1') // (輪るピングドラム対応 (第N駅 <-> Nth station ・第は除去済み)) 14 | ; 15 | } 16 | -------------------------------------------------------------------------------- /src/scripts/modules/storage/comment_alpha_storage.js: -------------------------------------------------------------------------------- 1 | export default class CommentAlphaStorage { 2 | static DANIME_ANOTHER_COMMENT_COMMENT_ALPHA_KEY = 'danime-another-comment-comment-alpha'; 3 | 4 | /** 5 | * alpha を localStorage から取得して返します 6 | * @return {number} 7 | */ 8 | static get() { 9 | const value = parseFloat(window.localStorage.getItem(this.DANIME_ANOTHER_COMMENT_COMMENT_ALPHA_KEY)); 10 | return value ? value : 0.5; 11 | } 12 | 13 | /** 14 | * alpha を localStorage に保存します 15 | * @param {number} alpha 16 | */ 17 | static set(alpha) { 18 | window.localStorage.setItem(this.DANIME_ANOTHER_COMMENT_COMMENT_ALPHA_KEY, alpha); 19 | } 20 | 21 | static remove() { 22 | window.localStorage.removeItem(this.DANIME_ANOTHER_COMMENT_COMMENT_ALPHA_KEY); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "danime-another-comment", 3 | "version": "1.6.2", 4 | "description": "dアニメストアニコニコ支店の動画にコメントが増えたように見えるchrome拡張", 5 | "scripts": { 6 | "build": "gulp build", 7 | "build-for-webstore": "gulp buildForChromeWebStore", 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "author": "noradium", 11 | "license": "MIT", 12 | "devDependencies": { 13 | "babel-core": "^6.21.0", 14 | "babel-loader": "^7.1.2", 15 | "babel-plugin-transform-class-properties": "^6.19.0", 16 | "babel-preset-env": "^1.6.0", 17 | "babel-register": "^6.22.0", 18 | "fs": "0.0.1-security", 19 | "gulp": "^4.0.0", 20 | "gulp-rework": "^1.2.0", 21 | "gulp-shell": "^0.6.3", 22 | "path": "^0.12.7", 23 | "query-string": "^5.0.0", 24 | "rework-npm": "^1.0.0", 25 | "webpack": "^2.7.0", 26 | "webpack-stream": "^4.0.0" 27 | }, 28 | "dependencies": { 29 | "dexie": "^2.0.4" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | context: __dirname + '/src/scripts', 3 | entry: { 4 | 'index': './index.js', 5 | 'hack_fetch_thread': './hack_fetch_thread.js', 6 | 'hack_comment_alpha': './hack_comment_alpha.js', 7 | 'background': './background.js', 8 | 'popup': './popup.js' 9 | }, 10 | output: { 11 | path: __dirname + '/dist/scripts', 12 | filename: "[name].js", 13 | chunkFilename: "[id].js" 14 | }, 15 | module: { 16 | loaders: [ 17 | { 18 | test: /\.js$/, 19 | exclude: /node_modules/, 20 | loader: "babel-loader", 21 | query:{ 22 | presets: [ 23 | ["env", { 24 | "targets": { 25 | "chrome": 59 26 | }, 27 | "loose": true 28 | }] 29 | ], 30 | plugins: [ 31 | "transform-class-properties" 32 | ] 33 | } 34 | } 35 | ] 36 | } 37 | }; 38 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "danime-another-comment", 4 | "version": "1.6.2", 5 | "description": "dアニメストアニコニコ支店の動画にコメントが増えたように見えるchrome拡張", 6 | "icons": { 7 | "16": "dac_icon16.png", 8 | "48": "dac_icon48.png", 9 | "128": "dac_icon128.png" 10 | }, 11 | "content_scripts": [ 12 | { 13 | "matches": ["*://www.nicovideo.jp/watch/*"], 14 | "js": ["scripts/index.js"] 15 | } 16 | ], 17 | "web_accessible_resources": [ 18 | "scripts/hack_fetch_thread.js", 19 | "scripts/hack_comment_alpha.js" 20 | ], 21 | "background": { 22 | "scripts": ["scripts/background.js"] 23 | }, 24 | "page_action": { 25 | "default_popup": "html/popup.html" 26 | }, 27 | "permissions": [ 28 | "webRequest", 29 | "webRequestBlocking", 30 | "*://nicovideo.cdn.nimg.jp/web/scripts/pages/watch/watch_app_*.js", 31 | "*://api.search.nicovideo.jp/api/v2/video/contents/search", 32 | "tabs", 33 | "*://www.nicovideo.jp/watch/*" 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 noradium 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | 9 | -------------------------------------------------------------------------------- /src/scripts/modules/storage/comment_offset_storage.js: -------------------------------------------------------------------------------- 1 | import Dexie from 'dexie'; 2 | 3 | export default class CommentOffsetStorage { 4 | /** 5 | * @private 6 | */ 7 | static _db; 8 | 9 | static get(threadId, anotherThreadId) { 10 | return this._getDB().offsets.get({threadId, anotherThreadId}); 11 | } 12 | 13 | static add(threadId, anotherThreadId, offset) { 14 | return this._getDB().offsets.get({threadId, anotherThreadId}) 15 | .then(item => { 16 | if (!item) { 17 | return this._getDB().offsets.add({threadId, anotherThreadId, offset}); 18 | } 19 | return this._getDB().offsets 20 | .where('[threadId+anotherThreadId]') 21 | .equals([threadId, anotherThreadId]) 22 | .modify({threadId, anotherThreadId, offset}); 23 | }); 24 | } 25 | 26 | static removeAll() { 27 | return this._getDB().offsets.clear(); 28 | } 29 | 30 | /** 31 | * @return {Dexie} 32 | * @private 33 | */ 34 | static _getDB() { 35 | if (!this._db) { 36 | this._db = new Dexie('DACCommentOffset'); 37 | this._db.version(1).stores({ offsets: "[threadId+anotherThreadId],offset" }); 38 | } 39 | return this._db; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/scripts/modules/storage/ignore_ids_storage.js: -------------------------------------------------------------------------------- 1 | export default class IgnoreIdsStorage { 2 | static DANIME_ANOTHER_COMMENT_IGNORE_THREAD_IDS_KEY = 'danime-another-comment-ignore-ids'; 3 | 4 | static includes(threadId) { 5 | return this._getThreadIds().includes(threadId); 6 | } 7 | 8 | static add(threadId) { 9 | const threadIds = this._getThreadIds(); 10 | threadIds.push(threadId); 11 | this._setThreadIds(threadIds); 12 | } 13 | 14 | static remove(threadId) { 15 | const threadIds = this._getThreadIds(); 16 | this._setThreadIds(threadIds.filter((id) => id !== threadId)); 17 | } 18 | 19 | static removeAll() { 20 | window.localStorage.removeItem(this.DANIME_ANOTHER_COMMENT_IGNORE_THREAD_IDS_KEY); 21 | } 22 | 23 | /** 24 | * ignorePair の配列を localStorage から取得して返します 25 | * @return {string[]} 26 | * @private 27 | */ 28 | static _getThreadIds() { 29 | const threadIds = window.localStorage.getItem(this.DANIME_ANOTHER_COMMENT_IGNORE_THREAD_IDS_KEY); 30 | try { 31 | return threadIds ? JSON.parse(threadIds) : []; 32 | } catch (e) { 33 | console.error('ignore-ids のパースに失敗', e, threadIds); 34 | } 35 | return []; 36 | } 37 | 38 | /** 39 | * ignoreIds を localStorage に保存します 40 | * @param threadIds 41 | * @private 42 | */ 43 | static _setThreadIds(threadIds) { 44 | window.localStorage.setItem(this.DANIME_ANOTHER_COMMENT_IGNORE_THREAD_IDS_KEY, JSON.stringify(threadIds)); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/scripts/modules/dialog.js: -------------------------------------------------------------------------------- 1 | export default class Dialog { 2 | _dialog; 3 | _currentOnClick; 4 | _timeoutId; 5 | 6 | constructor( 7 | rootNode 8 | ) { 9 | this._dialog = document.createElement('div'); 10 | this._dialog.style.position = 'fixed'; 11 | this._dialog.style.width = '200px'; 12 | this._dialog.style.right = '20px'; 13 | this._dialog.style.bottom = '20px'; 14 | this._dialog.style.color = '#fff'; 15 | this._dialog.style.padding = '12px'; 16 | this._dialog.style.borderRadius = '12px'; 17 | this._dialog.style.zIndex = 999999; 18 | this._dialog.style.cursor = 'pointer'; 19 | this._dialog.style.display = 'none'; 20 | rootNode.appendChild(this._dialog); 21 | } 22 | 23 | show(messageHTML, backgroundColor, onClick) { 24 | this._dialog.innerHTML = messageHTML; 25 | this._dialog.style.backgroundColor = backgroundColor; 26 | if (this._currentOnClick) { 27 | this._dialog.removeEventListener('click', this._currentOnClick); 28 | } 29 | this._currentOnClick = onClick; 30 | if (onClick) { 31 | this._dialog.addEventListener('click', this._currentOnClick); 32 | } 33 | this._dialog.style.cursor = onClick ? 'pointer' : 'none'; 34 | this._dialog.style.display = 'block'; 35 | if (this._timeoutId) { 36 | clearTimeout(this._timeoutId); 37 | } 38 | this._timeoutId = setTimeout(() => { 39 | this._dialog.style.display = 'none'; 40 | this._timeoutId = null; 41 | }, 5000); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/scripts/modules/fetch_thread_arguments.js: -------------------------------------------------------------------------------- 1 | export default class FetchThreadArguments { 2 | _arguments; 3 | 4 | constructor(args) { 5 | this._arguments = args; 6 | } 7 | 8 | get raw() { 9 | return this._arguments; 10 | } 11 | 12 | /** 13 | * コメント一覧で切り替え可能な「通常コメント」。チャンネル限定動画では使われないと思うので、ここに別動画のコメントを突っ込むことにする。 14 | * @return {string|null} 15 | */ 16 | get regularThreadId() { 17 | for (let i = 0; i < this._arguments.length; ++i) { 18 | if (!this._arguments[i].thread.isPrivate) { 19 | return this._arguments[i].thread.id; 20 | } 21 | } 22 | return null; 23 | } 24 | 25 | /** 26 | * dアニの動画が表示された時に見えるチャンネルコメントのスレッド。isPrivate が true。 27 | * @return {string|null} 28 | */ 29 | get defaultThreadId() { 30 | for (let i = 0; i < this._arguments.length; ++i) { 31 | if (this._arguments[i].thread.isPrivate) { 32 | return this._arguments[i].thread.id; 33 | } 34 | } 35 | return null; 36 | } 37 | 38 | get(index) { 39 | return this._arguments[index]; 40 | } 41 | 42 | append(thread) { 43 | this._arguments[this._arguments.length] = { 44 | thread: thread, 45 | scores: 1 46 | }; 47 | this._arguments.length += 1; 48 | } 49 | 50 | isOfficialAnotherThreadExist() { 51 | const ids = []; 52 | for (let i = 0; i < this._arguments.length; ++i) { 53 | const id = this._arguments[i].thread.id; 54 | if (!ids.includes(id)) { 55 | ids.push(id); 56 | } 57 | } 58 | // 公式の引用コメントが存在しない時は2種類のidがあり、存在する場合idは3種類 59 | return ids.length > 2; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/scripts/modules/video_info.js: -------------------------------------------------------------------------------- 1 | /** 2 | * インスタンス化した時点のページの情報を収集・保持するクラス 3 | */ 4 | export default class VideoInfo { 5 | /** 6 | * チャンネルの動画の場合はチャンネルID 7 | * @type {string|null} 8 | */ 9 | _channelId; 10 | /** 11 | * 動画のタイトル 12 | * @type {string} 13 | */ 14 | _title; 15 | /** 16 | * @type {number} 17 | */ 18 | _duration; 19 | 20 | constructor() { 21 | // この辺りのデータは`#js-initial-watch-data`から取れると良かったのですが、これだと説明文などから動画を移動したときに正しい値にならない。 22 | const channelLink = document.querySelector('.ChannelInfo-pageLink'); 23 | this._channelId = channelLink ? channelLink.getAttribute('href').match(/^https?:\/\/ch\.nicovideo\.jp\/(ch[0-9]+)/)[1] : null; 24 | this._title = document.querySelector('.VideoTitle').innerText; 25 | } 26 | 27 | get channelId() { 28 | return this._channelId; 29 | } 30 | 31 | get isChannel() { 32 | return !!this._channelId; 33 | } 34 | 35 | get title() { 36 | return this._title; 37 | } 38 | 39 | /** 40 | * duration は最初 0:00 の状態なので遅延させる。利用時はタイミングによっては0になるかも。注意 41 | */ 42 | get duration() { 43 | if (typeof this._duration === 'undefined') { 44 | this._duration = document.querySelector('.PlayerPlayTime-duration').innerText.split(':').reduce((prev, current, index, source) => { 45 | return prev + current * Math.pow(60, source.length - 1 - index); 46 | }, 0); 47 | } 48 | return this._duration; 49 | } 50 | 51 | toJSON() { 52 | const json = {}; 53 | Object.getOwnPropertyNames(this).forEach((key) => { 54 | if (key.indexOf('_') === 0) { 55 | json[key.slice(1)] = this[key]; 56 | } 57 | }); 58 | return json; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/scripts/background.js: -------------------------------------------------------------------------------- 1 | 2 | // watch_dll.js を改変したあとに watch_app.js をロードするためもとのリクエストは止める 3 | import SearchAPI from './modules/search_api'; 4 | 5 | chrome.webRequest.onBeforeRequest.addListener( 6 | (details) => { 7 | if (/watch_app_.*\.js/.test(details.url)) { 8 | return {cancel: details.url.indexOf('by-danime-another-comment') === -1}; 9 | } 10 | }, 11 | {urls: ['']}, 12 | ['blocking'] 13 | ); 14 | 15 | chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { 16 | switch (request.command) { 17 | case 'search': 18 | console.info('search start', request); 19 | SearchAPI.fetch(request.word, request.limit) 20 | .then(json => { 21 | console.info('search success', json); 22 | sendResponse({result: json}); 23 | }) 24 | .catch(error => { 25 | sendResponse({error}); 26 | }); 27 | return true; 28 | case 'watchdata': 29 | fetch(`https://www.nicovideo.jp/api/watch/v3_guest/${request.contentId}?additionals=pcWatchPage%2Cseries&prevIntegratedLoudness=0&actionTrackId=1g9hKPLpnU_1624006272&skips=&danime-another-comment`, { 30 | credentials: 'omit', 31 | headers: { 32 | 'x-client-os-type': 'android', 33 | 'x-frontend-id': '3', 34 | 'x-frontend-version': '0.1.0' 35 | }, 36 | }) 37 | .then(res => res.json()) 38 | .then(json => { 39 | sendResponse({result: json}); 40 | }).catch(error => { 41 | sendResponse({error}); 42 | }); 43 | return true 44 | } 45 | }); 46 | 47 | // pageAction の表示切り替え 48 | chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { 49 | if (tab.url.indexOf("www.nicovideo.jp/watch") !== -1) { 50 | chrome.pageAction.show(tabId); 51 | } 52 | }); 53 | -------------------------------------------------------------------------------- /src/scripts/modules/storage/selected_pairs_storage.js: -------------------------------------------------------------------------------- 1 | export default class SelectedPairsStorage { 2 | static DANIME_ANOTHER_COMMENT_SELECTED_PAIRS_KEY = 'danime-another-comment-selected-pairs'; 3 | static VERSION = '1'; 4 | 5 | static migration() { 6 | if (window.localStorage.getItem(this.DANIME_ANOTHER_COMMENT_SELECTED_PAIRS_KEY + '-version') === this.VERSION) { 7 | return; 8 | } 9 | window.localStorage.setItem(this.DANIME_ANOTHER_COMMENT_SELECTED_PAIRS_KEY + '-version', this.VERSION); 10 | 11 | // 今回チャンネルIDなどを増やすがそれらを取得する準備ができてないので消しちゃう 12 | this._setSelectedIdPairs({}); 13 | } 14 | 15 | static get(threadId) { 16 | return this._getSelectedIdPairs()[threadId]; 17 | } 18 | 19 | static add(threadId, video) { 20 | const pairs = this._getSelectedIdPairs(); 21 | pairs[threadId] = video; 22 | this._setSelectedIdPairs(pairs); 23 | } 24 | 25 | static remove(threadId) { 26 | const pairs = this._getSelectedIdPairs(); 27 | delete pairs[threadId]; 28 | this._setSelectedIdPairs(pairs); 29 | } 30 | 31 | static removeAll() { 32 | window.localStorage.removeItem(this.DANIME_ANOTHER_COMMENT_SELECTED_PAIRS_KEY); 33 | } 34 | 35 | /** 36 | * selectedIdPairs の配列を localStorage から取得して返します 37 | * @return {object} 38 | * @private 39 | */ 40 | static _getSelectedIdPairs() { 41 | const pairs = window.localStorage.getItem(this.DANIME_ANOTHER_COMMENT_SELECTED_PAIRS_KEY); 42 | try { 43 | return pairs ? JSON.parse(pairs) : {}; 44 | } catch (e) { 45 | console.error('selected-id-pairs のパースに失敗', e, pairs); 46 | } 47 | return {}; 48 | } 49 | 50 | /** 51 | * ignorePairs を localStorage に保存します 52 | * @param pairs 53 | * @private 54 | */ 55 | static _setSelectedIdPairs(pairs) { 56 | window.localStorage.setItem(this.DANIME_ANOTHER_COMMENT_SELECTED_PAIRS_KEY, JSON.stringify(pairs)); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/scripts/hack_comment_alpha.js: -------------------------------------------------------------------------------- 1 | import CommentAlphaStorage from "./modules/storage/comment_alpha_storage"; 2 | 3 | let addThreadProcessorsEventListenerHacked = false; 4 | 5 | let chunkwatchPush = window['webpackChunkwatch'].push; 6 | window['webpackChunkwatch'].push = function (item) { 7 | hackLibrary(item[1]); 8 | chunkwatchPush.call(this, ...arguments); 9 | } 10 | 11 | function hackLibrary(libraryFunctions) { 12 | if (!addThreadProcessorsEventListenerHacked) { 13 | addThreadProcessorsEventListenerHacked = hackAddThreadProcessorsEventListener(libraryFunctions); 14 | } 15 | } 16 | 17 | function hackAddThreadProcessorsEventListener(libraryFunctions) { 18 | /////////////////////// 19 | // _addThreadProcessorsEventListener を書き換える 20 | 21 | const addThreadProcessorsEventListenerFunctionIndex = Object.keys(libraryFunctions).find((index) => { 22 | const item = libraryFunctions[index]; 23 | 24 | return ( 25 | item && 26 | !!item.toString().match(/_addThreadProcessorsEventListener\(\w\)\s?\{/) 27 | ); 28 | }); 29 | if (typeof addThreadProcessorsEventListenerFunctionIndex === 'undefined') { 30 | return false; 31 | } 32 | 33 | const commentAlpha = CommentAlphaStorage.get(); 34 | 35 | const libraryFunction = libraryFunctions[addThreadProcessorsEventListenerFunctionIndex]; 36 | libraryFunctions[addThreadProcessorsEventListenerFunctionIndex] = function (t, e, n) { 37 | libraryFunction(t, e, n); 38 | const originalAddThreadProcessorsEventListener = t.exports.default.prototype._addThreadProcessorsEventListener; 39 | t.exports.default.prototype._addThreadProcessorsEventListener = function (threads) { 40 | // 元処理 41 | originalAddThreadProcessorsEventListener.call(this, ...arguments); 42 | // コメント描画が半透明に指定されている thread に対して、 43 | // commentAlpha をアルファ値(透明度)として利用するよう変更する 44 | for (const t of threads) { 45 | if (t.layer.isTranslucent) { 46 | const e = this.renderer.getLayerEffectControl(t.processor); 47 | e.alpha = commentAlpha; 48 | } 49 | } 50 | }; 51 | }; 52 | 53 | return true; 54 | } 55 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | const gulp = require('gulp'); 2 | const webpack = require('webpack-stream'); 3 | const webpackConfig = require('./webpack.config'); 4 | const path = require('path'); 5 | const shell = require('gulp-shell'); 6 | const rework = require('gulp-rework'); 7 | const reworkNPM = require('rework-npm'); 8 | 9 | /** 10 | * js をビルドします 11 | */ 12 | function buildWebpack() { 13 | const src = path.resolve(webpackConfig.context, webpackConfig.entry['index']); 14 | return gulp.src(src) 15 | .pipe(webpack(webpackConfig)) 16 | .pipe(gulp.dest(webpackConfig.output.path)); 17 | } 18 | 19 | /** 20 | * css をビルドします。 21 | * といっても今はコピーするだけ 22 | */ 23 | function buildCSS() { 24 | return gulp.src('src/styles/*.css') 25 | .pipe(rework(reworkNPM())) 26 | .pipe(gulp.dest('dist/styles')); 27 | } 28 | 29 | /** 30 | * html をビルドします。 31 | * といっても今はコピーするだけ 32 | */ 33 | function buildHTML() { 34 | return gulp.src('src/html/*.html') 35 | .pipe(gulp.dest('dist/html')); 36 | } 37 | 38 | /** 39 | * リリースに必要なファイルだけ release ディレクトリにコピーします 40 | */ 41 | const releaseCopy = gulp.parallel([ 42 | releaseCopyJSCSS, releaseCopyManifest 43 | ]); 44 | 45 | function releaseCopyJSCSS() { 46 | return gulp.src( 47 | ['dist/scripts/*.js', 'dist/styles/*.css', 'dist/html/*.html'], 48 | { base: './dist' } 49 | ) 50 | .pipe(gulp.dest('release')); 51 | } 52 | 53 | function releaseCopyManifest() { 54 | return gulp.src( 55 | ['manifest.json'], 56 | { base: './' } 57 | ) 58 | .pipe(gulp.dest('release')); 59 | } 60 | 61 | /** 62 | * release ディレクトリをzip圧縮します。 63 | * @param done 64 | */ 65 | function zip(done) { 66 | return shell.task([ 67 | 'zip release.zip -qr release -X' 68 | ])(done); 69 | } 70 | 71 | const build = gulp.series( 72 | gulp.parallel([buildWebpack, buildCSS, buildHTML]), 73 | releaseCopy 74 | ); 75 | 76 | const buildForChromeWebStore = gulp.series( 77 | build, 78 | zip 79 | ); 80 | 81 | gulp.task('buildWebpack', buildWebpack); 82 | gulp.task('releaseCopy', releaseCopy); 83 | gulp.task('zip', zip); 84 | 85 | gulp.task('build', build); 86 | gulp.task('buildForChromeWebStore', buildForChromeWebStore); 87 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # app 2 | dist 3 | release 4 | var/* 5 | selenium-debug.log 6 | test/e2e/reports 7 | test/mock 8 | release.zip 9 | 10 | 11 | # Created by https://www.gitignore.io/api/intellij,node,macos 12 | 13 | ### Intellij ### 14 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 15 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 16 | .idea 17 | 18 | ## File-based project format: 19 | *.iws 20 | 21 | ## Plugin-specific files: 22 | 23 | # IntelliJ 24 | /out/ 25 | 26 | # mpeltonen/sbt-idea plugin 27 | .idea_modules/ 28 | 29 | # JIRA plugin 30 | atlassian-ide-plugin.xml 31 | 32 | # Crashlytics plugin (for Android Studio and IntelliJ) 33 | com_crashlytics_export_strings.xml 34 | crashlytics.properties 35 | crashlytics-build.properties 36 | fabric.properties 37 | 38 | ### Intellij Patch ### 39 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 40 | 41 | # *.iml 42 | # modules.xml 43 | # .idea/misc.xml 44 | # *.ipr 45 | 46 | 47 | ### Node ### 48 | # Logs 49 | logs 50 | *.log 51 | npm-debug.log* 52 | 53 | # Runtime data 54 | pids 55 | *.pid 56 | *.seed 57 | *.pid.lock 58 | 59 | # Directory for instrumented libs generated by jscoverage/JSCover 60 | lib-cov 61 | 62 | # Coverage directory used by tools like istanbul 63 | coverage 64 | 65 | # nyc test coverage 66 | .nyc_output 67 | 68 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 69 | .grunt 70 | 71 | # node-waf configuration 72 | .lock-wscript 73 | 74 | # Compiled binary addons (http://nodejs.org/api/addons.html) 75 | build/Release 76 | 77 | # Dependency directories 78 | node_modules 79 | jspm_packages 80 | 81 | # Optional npm cache directory 82 | .npm 83 | 84 | # Optional eslint cache 85 | .eslintcache 86 | 87 | # Optional REPL history 88 | .node_repl_history 89 | 90 | # Output of 'npm pack' 91 | *.tgz 92 | 93 | # Yarn Integrity file 94 | .yarn-integrity 95 | 96 | 97 | 98 | ### macOS ### 99 | *.DS_Store 100 | .AppleDouble 101 | .LSOverride 102 | 103 | # Icon must end with two \r 104 | Icon 105 | # Thumbnails 106 | ._* 107 | # Files that might appear in the root of a volume 108 | .DocumentRevisions-V100 109 | .fseventsd 110 | .Spotlight-V100 111 | .TemporaryItems 112 | .Trashes 113 | .VolumeIcon.icns 114 | .com.apple.timemachine.donotpresent 115 | # Directories potentially created on remote AFP share 116 | .AppleDB 117 | .AppleDesktop 118 | Network Trash Folder 119 | Temporary Items 120 | .apdisk 121 | 122 | # End of https://www.gitignore.io/api/intellij,node,macos -------------------------------------------------------------------------------- /src/html/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 |
10 |
11 |
12 |
13 | 流すコメントの透明度(alpha) 14 |
15 |
16 | 17 | 18 | 19 |
20 |
21 |
22 |
23 | 流すコメントのオフセット 24 |
25 |
26 |
27 | 視聴中 28 | 29 | 30 |
31 |
32 | 選択中 33 | 34 | 35 |
36 |
37 |
38 |
39 | 40 | 41 | 42 | 43 | 44 |
45 | 46 |
47 |
48 |
49 |
50 | 流すコメントの動画を選択 51 |
52 | 56 | 58 | 71 |
72 | 動画が見つかりませんでした 73 |
74 |
75 |
76 |
77 | その他 78 |
79 |
80 | 81 |
82 |
83 | 「dアニメストア ニコニコ支店」の動画ではないため利用できません。 84 |
85 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /src/styles/popup.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | body { 6 | margin: 0; 7 | width: 400px; 8 | } 9 | 10 | .Message { 11 | padding: 4px; 12 | font-size: 16px; 13 | } 14 | 15 | .Message .ErrorMessage { 16 | color: red; 17 | } 18 | 19 | .Caption { 20 | padding: 4px; 21 | font-size: 18px; 22 | } 23 | 24 | .Search { 25 | display: flex; 26 | padding: 4px; 27 | height: 36px; 28 | } 29 | 30 | .Search .SearchInput { 31 | flex: 1; 32 | line-height: 36px; 33 | font-size: 14px; 34 | outline: none; 35 | } 36 | 37 | .Search .SearchButton { 38 | background-color: #007cff; 39 | color: #fff; 40 | border: none; 41 | margin-left: 4px; 42 | cursor: pointer; 43 | } 44 | 45 | .Alpha { 46 | display: flex; 47 | padding: 4px; 48 | height: 36px; 49 | } 50 | 51 | .Alpha .AlphaInput { 52 | flex: 1; 53 | } 54 | 55 | .Alpha .AlphaValue { 56 | text-align: center; 57 | width: 48px; 58 | line-height: 28px; 59 | font-size: 16px; 60 | } 61 | 62 | .Alpha .AlphaApplyButton { 63 | background-color: #cb6167; 64 | color: #fff; 65 | border: none; 66 | margin-left: 4px; 67 | cursor: pointer; 68 | } 69 | 70 | .OffsetSelector .Status { 71 | padding: 0 4px; 72 | } 73 | 74 | .OffsetSelector .Status .PlayingVideo, 75 | .OffsetSelector .Status .SelectedVideo { 76 | display: flex; 77 | align-items: center; 78 | } 79 | 80 | .OffsetSelector .Status .Label { 81 | margin-right: 4px; 82 | } 83 | 84 | .OffsetSelector .Status .Label:after { 85 | content: ':'; 86 | } 87 | 88 | .OffsetSelector .Status .Duration { 89 | margin-right: 4px; 90 | } 91 | 92 | .OffsetSelector .Status .Title { 93 | flex: 1; 94 | overflow: hidden; 95 | text-overflow: ellipsis; 96 | white-space: nowrap; 97 | } 98 | 99 | .Offset { 100 | margin-top: 4px; 101 | padding: 0 4px; 102 | display: flex; 103 | align-items: center; 104 | } 105 | 106 | .Offset .OffsetInputContainer { 107 | flex: 1; 108 | } 109 | 110 | .Offset .OffsetInput { 111 | flex: 1; 112 | height: 32px; 113 | line-height: 32px; 114 | font-size: 14px; 115 | outline: none; 116 | } 117 | 118 | .Offset .OffsetFitHeadButton, 119 | .Offset .OffsetDecrementButton, 120 | .Offset .OffsetIncrementButton, 121 | .Offset .OffsetFitTailButton { 122 | height: 32px; 123 | background-color: #eee; 124 | color: #000; 125 | border: none; 126 | cursor: pointer; 127 | } 128 | 129 | .Offset .OffsetFitHeadButton[disabled], 130 | .Offset .OffsetDecrementButton[disabled], 131 | .Offset .OffsetIncrementButton[disabled], 132 | .Offset .OffsetFitTailButton[disabled], 133 | .Offset .OffsetInput[disabled], 134 | .Offset .OffsetApplyButton[disabled] { 135 | opacity: 0.4; 136 | } 137 | 138 | .Offset .OffsetInput::-webkit-outer-spin-button, 139 | .Offset .OffsetInput::-webkit-inner-spin-button { 140 | -webkit-appearance: none; 141 | margin: 0; 142 | } 143 | 144 | .Offset .OffsetApplyButton { 145 | height: 28px; 146 | background-color: #cb6167; 147 | color: #fff; 148 | border: none; 149 | margin-left: 4px; 150 | cursor: pointer; 151 | } 152 | 153 | .List { 154 | list-style: none; 155 | padding: 0; 156 | } 157 | 158 | .ListItem { 159 | padding: 4px; 160 | display: flex; 161 | cursor: pointer; 162 | border-bottom: solid 1px #ccc; 163 | } 164 | 165 | .ListItem:first-child { 166 | border-top: solid 1px #ccc; 167 | } 168 | 169 | .NoItemMessage { 170 | display: none; 171 | text-align: center; 172 | font-size: 18px; 173 | padding: 8px; 174 | } 175 | 176 | .ListItem:hover { 177 | background-color: #eee; 178 | } 179 | 180 | .ListItem .Thumbnail { 181 | width: 112px; 182 | height: 64px; 183 | position: relative; 184 | overflow: hidden; 185 | } 186 | 187 | .ListItem .Thumbnail .Image { 188 | width: 100%; 189 | height: auto; 190 | position: absolute; 191 | top: 50%; 192 | transform: translateY(-50%); 193 | } 194 | 195 | .ListItem .Thumbnail .Duration { 196 | position: absolute; 197 | background-color: rgba(0,0,0,.6); 198 | z-index: 2; 199 | color: #fff; 200 | right: 0; 201 | bottom: 0; 202 | padding: 4px; 203 | } 204 | 205 | .ListItem .Thumbnail .ChannelLabel { 206 | position: absolute; 207 | padding: 1px 4px; 208 | color: #cc9900; 209 | background-color: #fff; 210 | border: solid 1px #cc9900; 211 | } 212 | 213 | .ListItem .Info { 214 | position: relative; 215 | margin-left: 4px; 216 | flex: 1; 217 | } 218 | 219 | .ListItem .Info .Title { 220 | font-size: 14px; 221 | } 222 | 223 | .ListItem .Info .CommentCounter { 224 | position: absolute; 225 | bottom: 0; 226 | } 227 | 228 | .ListItem .Info .CommentCounter:before { 229 | content: 'コメント:'; 230 | } 231 | 232 | .NoTargetMessage { 233 | display: none; 234 | font-size: 16px; 235 | padding: 8px; 236 | } 237 | -------------------------------------------------------------------------------- /src/scripts/index.js: -------------------------------------------------------------------------------- 1 | import VideoInfo from './modules/video_info'; 2 | import IgnoreIdsStorage from "./modules/storage/ignore_ids_storage"; 3 | import SelectedPairsStorage from "./modules/storage/selected_pairs_storage"; 4 | import CommentAlphaStorage from "./modules/storage/comment_alpha_storage"; 5 | import {GlobalVars} from './modules/global_vars'; 6 | import CommentOffsetStorage from './modules/storage/comment_offset_storage'; 7 | 8 | SelectedPairsStorage.migration(); 9 | 10 | // 動的追加したスクリプトは非同期に読み込まれるので、onload を用いて同期に読み込ませる。 11 | // 以下の順にスクリプトを読み込ませないと書き換えたライブラリが参照されなかった。 12 | // hack_fetch_thread.js, watch_app_*?by-danime-another-comment.js, hack_comment_alpha.js 13 | (async () => { 14 | await inject(chrome.extension.getURL('scripts/hack_fetch_thread.js')); 15 | // 他拡張機能による watchApp の遅延読み込み考慮し、inject 直前で URI を取得する 16 | const watchAppJsURI = getWatchAppJsURI(); 17 | await inject(`${watchAppJsURI}${watchAppJsURI.indexOf('?') === -1 ? '?' : '&'}by-danime-another-comment`); 18 | await inject(chrome.extension.getURL('scripts/hack_comment_alpha.js')); 19 | })(); 20 | 21 | // background.js と通信するためのもの 22 | chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { 23 | switch (message.type) { 24 | case 'getVideoInfo': 25 | sendResponse((new VideoInfo()).toJSON()); 26 | break; 27 | case 'anotherThreadIdSelected': 28 | try { 29 | if (IgnoreIdsStorage.includes(GlobalVars.currentDefaultThreadId)) { 30 | IgnoreIdsStorage.remove(GlobalVars.currentDefaultThreadId); 31 | } 32 | SelectedPairsStorage.add(GlobalVars.currentDefaultThreadId, message.data.video); 33 | sendResponse({status: 'success'}); 34 | } catch (error) { 35 | // error をそのまま渡すと中身が何故か空のオブジェクトになってしまうので、ここで展開してから渡す 36 | sendResponse({status: 'error', error: {name: error.name, message: error.message}}); 37 | } 38 | break; 39 | case 'getCurrentCommentAlpha': 40 | sendResponse(CommentAlphaStorage.get()); 41 | break; 42 | case 'commentAlphaSelected': 43 | try { 44 | CommentAlphaStorage.set(message.data.alpha); 45 | sendResponse({status: 'success'}); 46 | } catch (error) { 47 | sendResponse({status: 'error', error: {name: error.name, message: error.message}}); 48 | } 49 | break; 50 | case 'getSelectedAnotherVideo': 51 | sendResponse(GlobalVars.selectedAnotherVideo); 52 | break; 53 | case 'getCurrentCommentOffset': 54 | if (!GlobalVars.selectedAnotherVideo) { 55 | sendResponse(null); 56 | return; 57 | } 58 | CommentOffsetStorage.get(GlobalVars.currentDefaultThreadId, GlobalVars.selectedAnotherVideo.threadId) 59 | .then(result => sendResponse(result.offset)) 60 | .catch(error => sendResponse(null)); 61 | return true; 62 | case 'setCommentOffset': 63 | try { 64 | CommentOffsetStorage.add(GlobalVars.currentDefaultThreadId, GlobalVars.selectedAnotherVideo.threadId, message.data.offset); 65 | sendResponse({status: 'success'}); 66 | } catch (error) { 67 | sendResponse({status: 'error', error: {name: error.name, message: error.message}}); 68 | } 69 | break; 70 | case 'resetAllSettings': 71 | CommentAlphaStorage.remove(); 72 | SelectedPairsStorage.removeAll(); 73 | IgnoreIdsStorage.removeAll(); 74 | break; 75 | case 'reload': 76 | location.reload(); 77 | break; 78 | } 79 | }); 80 | 81 | // injected script (hack_fetch_thread.js) と通信するためのもの 82 | window.addEventListener('message', event => { 83 | if (event.origin !== location.origin || typeof event.data.type !== 'string') { 84 | return; 85 | } 86 | switch (event.data.type) { 87 | case 'danime-another-comment:background-search': 88 | // background へ検索リクエストを送る 89 | chrome.runtime.sendMessage( 90 | {command: 'search', word: event.data.word, limit: event.data.limit}, 91 | (response) => { 92 | window.postMessage({ 93 | type: 'danime-another-comment:background-search-result', 94 | response 95 | }, location.origin); 96 | } 97 | ); 98 | break; 99 | case 'danime-another-comment:background-watchdata': 100 | // background へ検索リクエストを送る 101 | chrome.runtime.sendMessage( 102 | {command: 'watchdata', contentId: event.data.contentId}, 103 | (response) => { 104 | window.postMessage({ 105 | type: 'danime-another-comment:background-watchdata-result', 106 | response 107 | }, location.origin); 108 | } 109 | ); 110 | break; 111 | } 112 | }); 113 | 114 | // --- utils --- 115 | async function inject(src) { 116 | return new Promise((resolve, reject) => { 117 | const s = document.createElement('script'); 118 | s.setAttribute('type', 'text/javascript'); 119 | s.setAttribute('src', src); 120 | s.onload = (() => { 121 | resolve(); 122 | }); 123 | 124 | document.body.appendChild(s); 125 | }); 126 | } 127 | 128 | function getWatchAppJsURI() { 129 | const scriptTags = Array.from(document.getElementsByTagName('script')); 130 | const watchAppJsRegExp = /watch_app_.*\.js/; 131 | const target = scriptTags.filter((script) => { 132 | return watchAppJsRegExp.test(script.src); 133 | }).pop(); 134 | return target.src; 135 | } 136 | -------------------------------------------------------------------------------- /src/scripts/popup.js: -------------------------------------------------------------------------------- 1 | import buildSearchWord from "./modules/build_search_word"; 2 | import SearchAPI from "./modules/search_api"; 3 | 4 | /** 5 | * コメント透明度の初期化 6 | */ 7 | sendMessageToCurrentTab({type: 'getCurrentCommentAlpha'}, (currentCommentAlpha) => { 8 | const alphaInput = document.querySelector('.AlphaInput'); 9 | const alphaValueSpan = document.querySelector('.AlphaValue'); 10 | const alphaApplyButton = document.querySelector('.AlphaApplyButton'); 11 | 12 | alphaInput.value = currentCommentAlpha; 13 | alphaValueSpan.innerText = currentCommentAlpha; 14 | 15 | alphaInput.addEventListener('input', (e) => { 16 | alphaValueSpan.innerText = e.target.value; 17 | }); 18 | alphaInput.addEventListener('change', (e) => { 19 | alphaValueSpan.innerText = e.target.value; 20 | }); 21 | alphaApplyButton.addEventListener('click', (e) => { 22 | sendMessageToCurrentTab({ 23 | type: 'commentAlphaSelected', 24 | data: { 25 | alpha: alphaInput.value 26 | } 27 | }, response => { 28 | if (response.status === 'error') { 29 | showErrorMessage(`${response.error.name}: ${response.error.message}`); 30 | return; 31 | } 32 | window.close(); 33 | sendMessageToCurrentTab({type: 'reload'}); 34 | }); 35 | }); 36 | }); 37 | 38 | /** 39 | * 動画リストの初期化やらなんやら 40 | */ 41 | sendMessageToCurrentTab({type: 'getVideoInfo'}, (videoInfoJSON) => { 42 | if (videoInfoJSON.channelId !== 'ch2632720') { 43 | document.querySelector('.Selector').style.display = 'none'; 44 | document.querySelector('.OffsetSelector').style.display = 'none'; 45 | document.querySelector('.NoTargetMessage').style.display = 'block'; 46 | return; 47 | } 48 | 49 | sendMessageToCurrentTab({type: 'getSelectedAnotherVideo'}, (anotherVideo) => { 50 | // オフセット設定UI初期化 51 | const playingVideo = document.querySelector('.OffsetSelector .PlayingVideo'); 52 | const selectedVideo = document.querySelector('.OffsetSelector .SelectedVideo'); 53 | playingVideo.querySelector('.Duration').innerText = formatDuration(videoInfoJSON.duration); 54 | playingVideo.querySelector('.Title').innerText = videoInfoJSON.title; 55 | 56 | const fitHeadButton = document.querySelector('.Offset .OffsetFitHeadButton'); 57 | const decrementButton = document.querySelector('.Offset .OffsetDecrementButton'); 58 | const offsetInput = document.querySelector('.Offset .OffsetInput'); 59 | const incrementButton = document.querySelector('.Offset .OffsetIncrementButton'); 60 | const fitTailButton = document.querySelector('.Offset .OffsetFitTailButton'); 61 | const offsetApplyButton = document.querySelector('.Offset .OffsetApplyButton'); 62 | 63 | if (!anotherVideo) { 64 | fitHeadButton.disabled = true; 65 | decrementButton.disabled = true; 66 | incrementButton.disabled = true; 67 | fitTailButton.disabled = true; 68 | offsetInput.disabled = true; 69 | offsetApplyButton.disabled = true; 70 | selectedVideo.querySelector('.Duration').innerText = '--:--'; 71 | selectedVideo.querySelector('.Title').innerText = '-'; 72 | } else { 73 | selectedVideo.querySelector('.Duration').innerText = formatDuration(anotherVideo.lengthSeconds); 74 | selectedVideo.querySelector('.Title').innerText = anotherVideo.title; 75 | fitHeadButton.addEventListener('click', () => { 76 | offsetInput.value = 0; 77 | }); 78 | decrementButton.addEventListener('click', () => { 79 | offsetInput.stepDown(); 80 | }); 81 | incrementButton.addEventListener('click', () => { 82 | offsetInput.stepUp(); 83 | }); 84 | fitTailButton.addEventListener('click', () => { 85 | offsetInput.value = videoInfoJSON.duration - anotherVideo.lengthSeconds; 86 | }); 87 | offsetApplyButton.addEventListener('click', () => { 88 | sendMessageToCurrentTab({ 89 | type: 'setCommentOffset', 90 | data: { 91 | offset: parseInt(offsetInput.value, 10) 92 | } 93 | }, response => { 94 | if (response.status === 'error') { 95 | showErrorMessage(`${response.error.name}: ${response.error.message}`); 96 | return; 97 | } 98 | window.close(); 99 | sendMessageToCurrentTab({type: 'reload'}); 100 | }); 101 | }); 102 | } 103 | 104 | sendMessageToCurrentTab({type: 'getCurrentCommentOffset'}, (offset) => { 105 | offsetInput.value = offset || 0; 106 | }); 107 | }); 108 | 109 | // 検索UI初期化 110 | const searchWord = buildSearchWord(videoInfoJSON.title).split(' ')[0]; 111 | updateSearchInput(searchWord); 112 | searchAndUpdateList(searchWord); 113 | }); 114 | 115 | document.querySelector('.SearchButton').addEventListener('click', () => { 116 | searchAndUpdateList(document.querySelector('.SearchInput').value); 117 | }); 118 | 119 | document.querySelector('.SearchInput').addEventListener('keypress', (e) => { 120 | // Enter 121 | if (e.keyCode === 13) { 122 | searchAndUpdateList(document.querySelector('.SearchInput').value); 123 | } 124 | }); 125 | 126 | document.querySelector('.ResetAllSettings').addEventListener('click', (e) => { 127 | sendMessageToCurrentTab({type: 'resetAllSettings'}, () => { 128 | document.querySelector('.ResetAllSettingsMessage').innerText = '削除しました'; 129 | setTimeout(() => { 130 | document.querySelector('.ResetAllSettingsMessage').innerText = ''; 131 | }, 3000); 132 | }); 133 | }); 134 | 135 | function searchAndUpdateList(word) { 136 | SearchAPI.fetch(word, 100) 137 | .then(json => { 138 | updateList(json); 139 | }); 140 | } 141 | 142 | function sendMessageToCurrentTab(message, onReceiveResponse) { 143 | chrome.tabs.query({active: true, currentWindow: true}, (tabs) => { 144 | const currentTab = tabs[0]; 145 | chrome.tabs.sendMessage(currentTab.id, message, onReceiveResponse); 146 | }); 147 | } 148 | 149 | function updateSearchInput(value) { 150 | document.querySelector('.SearchInput').value = value; 151 | } 152 | 153 | function updateList(searchAPIResponse) { 154 | document.querySelector('.List').innerText = ''; 155 | document.querySelector('.NoItemMessage').style.display = 'none'; 156 | if (!searchAPIResponse.data || searchAPIResponse.data.length === 0) { 157 | document.querySelector('.NoItemMessage').style.display = 'block'; 158 | return; 159 | } 160 | const documentFragment = document.createDocumentFragment(); 161 | 162 | const filteredVideos = searchAPIResponse.data 163 | .filter((video) => { 164 | return video.channelId !== 'ch2632720'; 165 | }) 166 | .sort((a, b) => { 167 | if (a.channelId && !b.channelId) return -1; 168 | if (!a.channelId && b.channelId) return 1; 169 | return b.commentCounter - a.commentCounter; 170 | }); 171 | 172 | filteredVideos.forEach((video) => { 173 | const template = document.getElementById('list-item-template'); 174 | const clone = document.importNode(template.content, true); 175 | clone.querySelector('.ListItem .Thumbnail .Image').src = video.thumbnailUrl; 176 | clone.querySelector('.ListItem .Thumbnail .Duration').innerText = formatDuration(video.lengthSeconds); 177 | if (!video.channelId) { 178 | clone.querySelector('.ListItem .Thumbnail .ChannelLabel').style.display = 'none'; 179 | } 180 | clone.querySelector('.ListItem .Info .Title').innerText = video.title; 181 | clone.querySelector('.ListItem .Info .CommentCounter').innerText = video.commentCounter; 182 | clone.querySelector('.ListItem').setAttribute('data-video', JSON.stringify(video)); 183 | documentFragment.appendChild(clone); 184 | }); 185 | document.querySelector('.List').appendChild(documentFragment); 186 | document.querySelectorAll('.List .ListItem').forEach(item => { 187 | item.addEventListener('click', (e) => { 188 | sendMessageToCurrentTab({ 189 | type: 'anotherThreadIdSelected', 190 | data: { 191 | video: JSON.parse(e.currentTarget.getAttribute('data-video')) 192 | } 193 | }, response => { 194 | if (response.status === 'error') { 195 | showErrorMessage(`${response.error.name}: ${response.error.message}`); 196 | return; 197 | } 198 | window.close(); 199 | sendMessageToCurrentTab({type: 'reload'}); 200 | }); 201 | }); 202 | }); 203 | } 204 | 205 | function formatDuration(lengthSeconds) { 206 | return `${Math.floor(lengthSeconds / 60)}:${`0${lengthSeconds % 60}`.slice(-2)}`; 207 | } 208 | 209 | function showErrorMessage(message) { 210 | document.querySelector('.Message .ErrorMessage').innerText = message; 211 | } 212 | -------------------------------------------------------------------------------- /src/scripts/hack_fetch_thread.js: -------------------------------------------------------------------------------- 1 | import VideoInfo from './modules/video_info'; 2 | import FetchThreadArguments from "./modules/fetch_thread_arguments"; 3 | import buildSearchWord from "./modules/build_search_word"; 4 | import IgnoreIdsStorage from "./modules/storage/ignore_ids_storage"; 5 | import Dialog from "./modules/dialog"; 6 | import SelectedPairsStorage from "./modules/storage/selected_pairs_storage"; 7 | import {GlobalVars} from './modules/global_vars'; 8 | import CommentOffsetStorage from './modules/storage/comment_offset_storage'; 9 | 10 | let fetchThreadHacked = false; 11 | 12 | try { 13 | init(); 14 | } catch (error) { 15 | console.error('Failed to initialize danime-another-comment', error); 16 | } 17 | 18 | function init() { 19 | for (let i = 0; i < window['webpackChunkwatch'].length; ++i) { 20 | const libraryFunctions = window['webpackChunkwatch'][i][1]; 21 | hackLibrary(libraryFunctions); 22 | } 23 | console.info('danime-another-comment successfully initialized.'); 24 | } 25 | 26 | let chunkwatchPush = window['webpackChunkwatch'].push; 27 | window['webpackChunkwatch'].push = function (item) { 28 | hackLibrary(item[1]); 29 | chunkwatchPush.call(this, ...arguments); 30 | } 31 | 32 | function hackLibrary(libraryFunctions) { 33 | if (!fetchThreadHacked) { 34 | fetchThreadHacked = hackFetchThread(libraryFunctions); 35 | } 36 | } 37 | 38 | function hackFetchThread(libraryFunctions) { 39 | ///////////////////////// 40 | // fetchThread を書き換える 41 | const commentClientFunctionIndex = Object.keys(libraryFunctions).find((index) => { 42 | const item = libraryFunctions[index]; 43 | // fetchThread の定義があったらきっとそれがコメント取得するライブラリ 44 | return item && !!item.toString().match(/\.fetchThread\s?=\s?function/); 45 | }); 46 | 47 | if (typeof commentClientFunctionIndex === 'undefined') { 48 | return false; 49 | } 50 | 51 | // 同じ動画のときは、別動画のコメントは2回以上取得しなくてもよいので記憶しておく 52 | // すでに別動画のコメントを取得した元動画のThreadId 53 | let alreadyFetchedOriginalThreadId = null; 54 | 55 | const originalCommentClientFunction = libraryFunctions[commentClientFunctionIndex]; 56 | libraryFunctions[commentClientFunctionIndex] = function (e, t, n) { 57 | originalCommentClientFunction(e, t, n); 58 | const fetchThreadBlockPropertyName = Object.getOwnPropertyNames(e.exports).find((propertyName) => { 59 | return e.exports[propertyName].prototype && typeof e.exports[propertyName].prototype.fetchThread === 'function'; 60 | }); 61 | 62 | const originalFetchThread = e.exports[fetchThreadBlockPropertyName].prototype.fetchThread; 63 | e.exports[fetchThreadBlockPropertyName].prototype.fetchThread = function (...args) { 64 | const fetchThreadArguments = new FetchThreadArguments(arguments); 65 | 66 | // 今見ている動画の so なしIDをdocument経由でとる良い方法がわからないのでsessionStorageにさすクソみたいな方法で用意する。 67 | GlobalVars.currentDefaultThreadId = fetchThreadArguments.defaultThreadId; 68 | 69 | const videoInfo = new VideoInfo(); 70 | 71 | console.log(fetchThreadArguments.raw); 72 | // dアニじゃないタイトルが似た動画のthreadId 73 | let anotherThreadId = null; 74 | let anotherTitle = null; 75 | 76 | if (document.querySelector('.EditorMenuContainer')) { 77 | console.info('投稿者編集が利用できる環境のため処理しません'); 78 | alreadyFetchedOriginalThreadId = null; 79 | return originalFetchThread.call(this, ...fetchThreadArguments.raw); 80 | } 81 | // dアニメストア ニコニコ支店 以外は処理しません 82 | if (!videoInfo.isChannel || videoInfo.channelId !== 'ch2632720') { 83 | console.info('対象外の動画なので処理しません'); 84 | alreadyFetchedOriginalThreadId = null; 85 | return originalFetchThread.call(this, ...fetchThreadArguments.raw); 86 | } 87 | if (fetchThreadArguments.isOfficialAnotherThreadExist()) { 88 | console.info('公式の引用コメントが存在するので処理しません'); 89 | alreadyFetchedOriginalThreadId = null; 90 | return originalFetchThread.call(this, ...fetchThreadArguments.raw); 91 | } 92 | // whenSec(過去ログのとき値が入っている)が指定されておらず、すでに取得済みだったら新しく取得しない 93 | if (!fetchThreadArguments.get(0).thread._whenSec && alreadyFetchedOriginalThreadId === fetchThreadArguments.defaultThreadId) { 94 | console.info('この動画ではすでに取得済みなので取得しません'); 95 | return originalFetchThread.call(this, ...fetchThreadArguments.raw); 96 | } 97 | 98 | console.info(`dアニメストア ニコニコ支店の動画なので処理を開始します(${fetchThreadArguments.defaultThreadId}:${videoInfo.title})`); 99 | alreadyFetchedOriginalThreadId = fetchThreadArguments.defaultThreadId; 100 | GlobalVars.selectedAnotherVideo = null; 101 | 102 | return fetchAnotherVideo(fetchThreadArguments.defaultThreadId, videoInfo) 103 | .then(async (data) => { 104 | const videojson = await fetchAnotherVideoWatchData(data.video.contentId) 105 | const anotherThread = videojson.data.comment.threads.find(thread => thread.label === 'community') 106 | anotherThreadId = anotherThread.id 107 | data.video.threadId = anotherThreadId 108 | data.video.channelId = videojson.data.channel.id 109 | data.video.threadkey = anotherThread.threadkey 110 | GlobalVars.selectedAnotherVideo = data.video; 111 | // anotherThreadId = data.video.threadId; 112 | anotherTitle = data.video.title; 113 | 114 | fetchThreadArguments.append(this.createThread({ 115 | id: anotherThreadId, 116 | threadkey: anotherThread.threadkey, 117 | isPrivate: !!data.video.channelId, // チャンネル動画のときは true, チャンネルじゃないときは false で取得されてる。 118 | leafExpression: fetchThreadArguments.get(0).thread.leafExpression, // わからんので他のと同じのを渡しておく 119 | language: 0, 120 | whenSec: fetchThreadArguments.get(0).thread.whenSec ? fetchThreadArguments.get(0).thread.whenSec : void 0 121 | })); 122 | return Promise.all([ 123 | originalFetchThread.call(this, ...fetchThreadArguments.raw) 124 | .catch(error => { 125 | console.log('他動画のコメントの取得に失敗したため、もとの動画のコメントだけ取得します'); 126 | if (error && error.length === 1 && error[0] && error[0].thread && error[0].thread.thread === '' + anotherThreadId) { 127 | // エラーが anotherThreadId のやつだけだったら、除去してもう一度 128 | return originalFetchThread.call(this, ...args); 129 | } 130 | return Promise.reject(error); 131 | }), 132 | CommentOffsetStorage.get(fetchThreadArguments.defaultThreadId, anotherThreadId) 133 | ]); 134 | }) 135 | .then(([threads, offset]) => { 136 | const regularThreadIndex = threads.findIndex((thread) => { 137 | return thread.id === fetchThreadArguments.regularThreadId && !thread.isPrivate; 138 | }); 139 | const anotherThreadIndex = threads.findIndex((thread) => { 140 | return thread.id === anotherThreadId; 141 | }); 142 | 143 | // anotherThread で取得した内容を元の動画の通常コメントを表す thread に詰め直す。ちょっと壊れそうだけど動いた 144 | let newIndex = 0; 145 | if (threads[anotherThreadIndex]) { 146 | threads[anotherThreadIndex]._chatMap.forEach((value, key) => { 147 | while (threads[regularThreadIndex]._chatMap.has(newIndex)) { 148 | ++newIndex; 149 | } 150 | // thread を偽装しないとコメント一覧の方に表示されなかった 151 | value.thread = fetchThreadArguments.regularThreadId; 152 | 153 | if (offset) { 154 | // vpos がコメント位置。単位はセンチ秒 155 | value.vpos += offset.offset * 100; 156 | } 157 | 158 | threads[regularThreadIndex]._chatMap.set(newIndex, value); 159 | ++newIndex; 160 | }); 161 | showIgnoreDialog(fetchThreadArguments.defaultThreadId, anotherTitle); 162 | } 163 | 164 | return threads; 165 | }).catch(error => { 166 | switch (error) { 167 | case 'notfound': 168 | console.error(`別の動画が見つかりませんでした。(${fetchThreadArguments.defaultThreadId}:${videoInfo.title})`); 169 | showNoCommentDialog('似たタイトルの動画が見つかりませんでした'); 170 | return originalFetchThread.call(this, ...fetchThreadArguments.raw); 171 | case 'search_error': 172 | console.error(`検索に失敗しました。(${fetchThreadArguments.defaultThreadId}:${videoInfo.title})`); 173 | return originalFetchThread.call(this, ...fetchThreadArguments.raw); 174 | case 'included_in_ignore_list': 175 | console.error(`非表示リストに含まれているため何もしませんでした。(${fetchThreadArguments.defaultThreadId}:${videoInfo.title})`); 176 | showNoCommentDialog('この動画はコメント非表示リストに含まれています'); 177 | return originalFetchThread.call(this, ...fetchThreadArguments.raw); 178 | default: 179 | console.error(error); 180 | return originalFetchThread.call(this, ...fetchThreadArguments.raw); 181 | } 182 | }); 183 | }; 184 | }; 185 | 186 | function fetchAnotherVideo(threadId, videoInfo) { 187 | const selectedAnotherVideo = SelectedPairsStorage.get(threadId); 188 | if (selectedAnotherVideo) { 189 | console.info(`指定された動画があったのでそれを採用しました(${selectedAnotherVideo.threadId}:${selectedAnotherVideo.title})`); 190 | return Promise.resolve({ 191 | video: selectedAnotherVideo 192 | }); 193 | } 194 | 195 | return fetchWithContentScript(buildSearchWord(videoInfo.title), 10) 196 | .then((json) => { 197 | if (!json.data) { 198 | return Promise.reject('search_error'); 199 | } 200 | 201 | // 動画の長さとして元動画の前後 20% を許容(25分の動画の場合20分から30分までOK) 202 | const allowedMinLength = videoInfo.duration * 0.8; 203 | const allowedMaxLength = videoInfo.duration * 1.2; 204 | const maybeAnotherVideo = json.data.find((video) => { 205 | return ( 206 | (allowedMinLength <= video.lengthSeconds && video.lengthSeconds <= allowedMaxLength) 207 | ); 208 | }); 209 | if (!maybeAnotherVideo) { 210 | return Promise.reject('notfound'); 211 | } 212 | if (IgnoreIdsStorage.includes(threadId)) { 213 | return Promise.reject('included_in_ignore_list'); 214 | } 215 | 216 | console.info(`別の動画が見つかりました(${maybeAnotherVideo.contentId}:${maybeAnotherVideo.title})`); 217 | return { 218 | video: maybeAnotherVideo 219 | }; 220 | }); 221 | } 222 | 223 | // content_script を経由して background で検索APIを叩き結果を返す 224 | // windows で CORB に引っかかるようになったのでそれを回避するため 225 | function fetchWithContentScript(word, limit) { 226 | return new Promise((resolve, reject) => { 227 | function onWindowMessage(event) { 228 | if (event.origin !== location.origin) { 229 | return; 230 | } 231 | if (typeof event.data.type !== 'string' || event.data.type !== 'danime-another-comment:background-search-result') { 232 | return; 233 | } 234 | window.removeEventListener('message', onWindowMessage); 235 | if (event.data.response.error) { 236 | reject(event.data.response.error); 237 | } 238 | resolve(event.data.response.result); 239 | } 240 | 241 | window.addEventListener('message', onWindowMessage); 242 | // content_script(index.js)に向けたメッセージ 243 | window.postMessage({type: 'danime-another-comment:background-search', word, limit}, location.origin); 244 | 245 | setTimeout(() => { 246 | window.removeEventListener('message', onWindowMessage); 247 | reject('background-search timeout'); 248 | }, 5000); 249 | }); 250 | } 251 | 252 | function fetchAnotherVideoWatchData(contentId) { 253 | return new Promise((resolve, reject) => { 254 | function onWindowMessage(event) { 255 | if (event.origin !== location.origin) { 256 | return; 257 | } 258 | if (typeof event.data.type !== 'string' || event.data.type !== 'danime-another-comment:background-watchdata-result') { 259 | return; 260 | } 261 | window.removeEventListener('message', onWindowMessage); 262 | if (event.data.response.error) { 263 | reject(event.data.response.error); 264 | } 265 | resolve(event.data.response.result); 266 | } 267 | 268 | window.addEventListener('message', onWindowMessage); 269 | // content_script(index.js)に向けたメッセージ 270 | window.postMessage({type: 'danime-another-comment:background-watchdata', contentId}, location.origin); 271 | 272 | setTimeout(() => { 273 | window.removeEventListener('message', onWindowMessage); 274 | reject('background-search timeout'); 275 | }, 5000); 276 | }); 277 | } 278 | 279 | const dialog = new Dialog(document.body); 280 | 281 | function showIgnoreDialog(threadId, title) { 282 | dialog.show( 283 | `「${title}」のコメントも表示しています
>この動画では別動画のコメントを表示しない`, 284 | '#ea5632', 285 | () => { 286 | if (!IgnoreIdsStorage.includes(threadId)) { 287 | IgnoreIdsStorage.add(threadId); 288 | } 289 | SelectedPairsStorage.remove(threadId); 290 | location.reload(); 291 | } 292 | ); 293 | } 294 | 295 | function showNoCommentDialog(message) { 296 | dialog.show( 297 | `${message}
ブラウザ右上のアイコンから流すコメントの動画を選択できます`, 298 | '#ea5632' 299 | ); 300 | } 301 | 302 | return true; 303 | } 304 | --------------------------------------------------------------------------------