├── icons ├── icon_design.txt ├── icon128.png ├── icon48.png ├── icon_design.svg └── icon_design_improved.svg ├── icon.png ├── docs ├── image.png ├── chrome_webstore_registration_guide.md └── chrome_webstore_cicd_guide.md ├── .gitignore ├── .github ├── dependabot.yml └── workflows │ ├── dependabot-auto-merge.yml │ ├── build.yml │ └── chrome-webstore-publish.yml ├── manifest.json ├── .eslintrc.json ├── SECURITY.md ├── background.js ├── LICENSE ├── package.json ├── webpack.config.js ├── README.md ├── popup.js ├── popup.html └── content.js /icons/icon_design.txt: -------------------------------------------------------------------------------- 1 | Creating icon design specifications 2 | -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GOROman/XKusoRepFilter/HEAD/icon.png -------------------------------------------------------------------------------- /docs/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GOROman/XKusoRepFilter/HEAD/docs/image.png -------------------------------------------------------------------------------- /icons/icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GOROman/XKusoRepFilter/HEAD/icons/icon128.png -------------------------------------------------------------------------------- /icons/icon48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GOROman/XKusoRepFilter/HEAD/icons/icon48.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # ビルド成果物 2 | dist/ 3 | releases/ 4 | *.zip 5 | 6 | # 依存関係 7 | node_modules/ 8 | npm-debug.log 9 | yarn-debug.log 10 | yarn-error.log 11 | 12 | # OS固有のファイル 13 | .DS_Store 14 | Thumbs.db 15 | 16 | # エディタ固有のファイル 17 | .idea/ 18 | .vscode/ 19 | *.swp 20 | *.swo 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | open-pull-requests-limit: 10 8 | labels: 9 | - "dependencies" 10 | - "npm" 11 | commit-message: 12 | prefix: "npm" 13 | include: "scope" 14 | assignees: 15 | - "GOROman" -------------------------------------------------------------------------------- /icons/icon_design.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "XKusoRepFilter", 4 | "version": "1.4.2", 5 | "description": "X(旧Twitter)で特定の文字列を含む投稿をブロックする拡張機能", 6 | "permissions": ["storage"], 7 | "action": { 8 | "default_popup": "popup.html", 9 | "default_icon": "icons/icon128.png" 10 | }, 11 | "content_scripts": [ 12 | { 13 | "matches": ["*://twitter.com/*", "*://x.com/*"], 14 | "js": ["content.js"] 15 | } 16 | ], 17 | "background": { 18 | "service_worker": "background.js" 19 | }, 20 | "icons": { 21 | "48": "icons/icon48.png", 22 | "128": "icons/icon128.png" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true, 5 | "webextensions": true, 6 | "node": true 7 | }, 8 | "extends": "eslint:recommended", 9 | "parserOptions": { 10 | "ecmaVersion": "latest", 11 | "sourceType": "module" 12 | }, 13 | "rules": { 14 | "indent": ["error", 2], 15 | "linebreak-style": ["error", "unix"], 16 | "quotes": ["error", "single"], 17 | "semi": ["error", "always"], 18 | "no-unused-vars": "warn", 19 | "no-console": ["warn", { "allow": ["warn", "error"] }], 20 | "camelcase": "warn" 21 | }, 22 | "globals": { 23 | "chrome": "readonly" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # セキュリティポリシー 2 | 3 | ## サポートバージョン 4 | 5 | 現在セキュリティアップデートがサポートされているバージョンは以下の通りです。 6 | 7 | | バージョン | サポート状況 | 8 | | ---------- | ------------------ | 9 | | 1.3.x | :white_check_mark: | 10 | | 1.2.x | :x: | 11 | | 1.1.x | :x: | 12 | | < 1.0 | :x: | 13 | 14 | ## 脆弱性の報告 15 | 16 | 脆弱性を発見した場合は、GitHubのIssueまたはPull Requestで報告してください。 17 | 18 | 報告された脆弱性は確認次第、できるだけ早く対応します。脆弱性が受理された場合は修正が行われ、新しいバージョンがリリースされます。 19 | 20 | ## セキュリティのベストプラクティス 21 | 22 | XKusoRepFilterは以下のセキュリティプラクティスに従っています: 23 | 24 | - ユーザーデータはローカルのChrome Storageにのみ保存され、外部サーバーには送信されません 25 | - 拡張機能は必要最小限の権限のみを要求します 26 | - コードは定期的に更新され、依存関係の脆弱性が修正されます 27 | -------------------------------------------------------------------------------- /background.js: -------------------------------------------------------------------------------- 1 | // 拡張機能がインストールされた時に実行される 2 | chrome.runtime.onInstalled.addListener(function() { 3 | // デフォルト設定を保存 4 | chrome.storage.sync.get(['blockWords', 'showConfirmDialog', 'filterMode'], function(result) { 5 | let updates = {}; 6 | 7 | if (!result.blockWords) { 8 | updates.blockWords = 'しばらく観察していると'; 9 | } 10 | 11 | if (result.showConfirmDialog === undefined) { 12 | updates.showConfirmDialog = true; 13 | } 14 | 15 | if (result.filterMode === undefined) { 16 | updates.filterMode = 'block'; // デフォルトは「ブロックモード」 17 | } 18 | 19 | if (Object.keys(updates).length > 0) { 20 | chrome.storage.sync.set(updates); 21 | } 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /icons/icon_design_improved.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 GOROman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "xkusorepfilter", 3 | "version": "1.3.0", 4 | "description": "X(旧Twitter)で特定の文字列を含む投稿をブロックする拡張機能", 5 | "main": "background.js", 6 | "scripts": { 7 | "build": "webpack", 8 | "package": "webpack && web-ext build -s dist/ -a releases/ --overwrite-dest", 9 | "test": "echo \"Error: no test specified\" && exit 1" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/GOROman/XKusoRepFilter.git" 14 | }, 15 | "keywords": [ 16 | "chrome-extension", 17 | "twitter", 18 | "filter" 19 | ], 20 | "author": "GOROman", 21 | "license": "MIT", 22 | "bugs": { 23 | "url": "https://github.com/GOROman/XKusoRepFilter/issues" 24 | }, 25 | "homepage": "https://github.com/GOROman/XKusoRepFilter#readme", 26 | "devDependencies": { 27 | "@babel/core": "^7.23.9", 28 | "@babel/preset-env": "^7.23.9", 29 | "babel-loader": "^9.1.3", 30 | "clean-webpack-plugin": "^4.0.0", 31 | "copy-webpack-plugin": "^13.0.1", 32 | "web-ext": "^8.4.0", 33 | "webpack": "^5.99.6", 34 | "webpack-cli": "^6.0.1", 35 | "zip-webpack-plugin": "^4.0.1" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-auto-merge.yml: -------------------------------------------------------------------------------- 1 | name: Dependabot Auto-Merge 2 | 3 | on: 4 | pull_request: 5 | branches: [ main ] 6 | 7 | permissions: 8 | contents: write 9 | pull-requests: write 10 | 11 | jobs: 12 | dependabot-auto-merge: 13 | runs-on: ubuntu-latest 14 | if: ${{ github.actor == 'dependabot[bot]' }} 15 | 16 | steps: 17 | - name: Dependabot metadata 18 | id: metadata 19 | uses: dependabot/fetch-metadata@v2 20 | with: 21 | github-token: "${{ secrets.GITHUB_TOKEN }}" 22 | 23 | - name: Wait for build to succeed 24 | uses: lewagon/wait-on-check-action@v1.3.1 25 | with: 26 | ref: ${{ github.event.pull_request.head.sha }} 27 | check-name: 'build' 28 | repo-token: ${{ secrets.GITHUB_TOKEN }} 29 | wait-interval: 10 30 | 31 | - name: Enable auto-merge for Dependabot PRs 32 | if: ${{ steps.metadata.outputs.update-type != 'version-update:semver-major' }} 33 | run: gh pr merge --auto --merge "$PR_URL" 34 | env: 35 | PR_URL: ${{ github.event.pull_request.html_url }} 36 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const ZipPlugin = require('zip-webpack-plugin'); 3 | const { CleanWebpackPlugin } = require('clean-webpack-plugin'); 4 | const CopyPlugin = require('copy-webpack-plugin'); 5 | const manifest = require('./manifest.json'); 6 | 7 | module.exports = { 8 | mode: 'production', 9 | entry: { 10 | background: './background.js', 11 | content: './content.js', 12 | popup: './popup.js' 13 | }, 14 | output: { 15 | path: path.resolve(__dirname, 'dist'), 16 | filename: '[name].js' 17 | }, 18 | module: { 19 | rules: [ 20 | { 21 | test: /\.js$/, 22 | exclude: /node_modules/, 23 | use: { 24 | loader: 'babel-loader', 25 | options: { 26 | presets: ['@babel/preset-env'] 27 | } 28 | } 29 | } 30 | ] 31 | }, 32 | plugins: [ 33 | new CleanWebpackPlugin(), 34 | new CopyPlugin({ 35 | patterns: [ 36 | { from: 'manifest.json', to: '.' }, 37 | { from: 'popup.html', to: '.' }, 38 | { from: 'icon.png', to: '.' }, 39 | { from: 'docs/image.png', to: 'docs/image.png' }, 40 | { from: 'README.md', to: '.' } 41 | ], 42 | }), 43 | new ZipPlugin({ 44 | filename: `XKusoRepFilter-v${manifest.version}.zip` 45 | }) 46 | ] 47 | }; 48 | -------------------------------------------------------------------------------- /docs/chrome_webstore_registration_guide.md: -------------------------------------------------------------------------------- 1 | # Chrome ウェブストアへの登録ガイド 2 | 3 | このドキュメントでは、XKusoRepFilter拡張機能をChrome ウェブストアに登録する手順を説明します。 4 | 5 | ## 前提条件 6 | 7 | 1. Google アカウント 8 | 2. Chrome ウェブストア開発者アカウント(一度だけ$5の登録料が必要) 9 | 3. 拡張機能のZIPパッケージ 10 | 11 | ## 登録手順 12 | 13 | ### 1. 開発者アカウントの登録 14 | 15 | 1. [Chrome ウェブストア開発者ダッシュボード](https://chrome.google.com/webstore/devconsole)にアクセス 16 | 2. Googleアカウントでログイン 17 | 3. 開発者登録を完了し、$5の登録料を支払う 18 | 19 | ### 2. 拡張機能のパッケージ化 20 | 21 | 1. リポジトリをクローンまたはダウンロード 22 | 2. 必要なファイルのみを含むZIPファイルを作成 23 | ``` 24 | zip -r xkusorepfilter.zip * -x "*.git*" -x "node_modules/*" -x "docs/*" -x "*.md" -x "package*" -x "webpack*" 25 | ``` 26 | 27 | ### 3. 拡張機能のアップロード 28 | 29 | 1. [Chrome ウェブストア開発者ダッシュボード](https://chrome.google.com/webstore/devconsole)にアクセス 30 | 2. 「新しいアイテムを追加」ボタンをクリック 31 | 3. 作成したZIPファイルを選択してアップロード 32 | 33 | ### 4. 拡張機能の情報を入力 34 | 35 | #### ストア掲載情報 36 | 37 | 1. **言語と地域**: 日本語を選択 38 | 2. **拡張機能名**: XKusoRepFilter 39 | 3. **簡単な説明**: X(旧Twitter)で特定の文字列を含む投稿をブロックする拡張機能 40 | 4. **詳細な説明**: 41 | ``` 42 | XKusoRepFilterは、X(旧Twitter)上で特定の文字列を含む投稿を自動的にブロックまたは表示するための拡張機能です。 43 | 44 | 主な機能: 45 | - 特定のフレーズを含む投稿を非表示にする 46 | - または特定のフレーズを含む投稿のみを表示する 47 | - カスタマイズ可能なブロックワードリスト 48 | - 確認ダイアログのオプション 49 | - 認証済みアカウントやフォロワーの投稿は保護 50 | 51 | デフォルトでは「しばらく観察していると」「紹介したこのブロガー」「彼の指導のもと」などの投資スパム関連のフレーズをブロックします。 52 | ``` 53 | 5. **スクリーンショット**: 拡張機能の使用例のスクリーンショットを追加(1280x800px推奨) 54 | 6. **プロモーション画像**: 必要に応じて追加(可選) 55 | 7. **アイコン**: すでに準備済み(128x128px) 56 | 57 | #### プライバシー 58 | 59 | 1. **単一目的**: 「X(旧Twitter)上で特定の文字列を含む投稿をフィルタリングする」 60 | 2. **権限の説明**: ストレージ権限はユーザー設定の保存に使用 61 | 3. **データ収集**: 「この拡張機能はユーザーデータを収集しません」を選択 62 | 63 | #### 配布 64 | 65 | 1. **可視性**: 公開(Chrome ウェブストア全体で公開) 66 | 2. **国/地域**: すべての国/地域で公開 67 | 68 | ### 5. 審査のために提出 69 | 70 | 1. すべての情報を入力後、「審査のために提出」ボタンをクリック 71 | 2. 審査には数日から数週間かかる場合があります 72 | 3. 審査が完了すると、拡張機能がChrome ウェブストアに公開されます 73 | 74 | ## 注意事項 75 | 76 | - 審査プロセスでは、拡張機能のセキュリティとプライバシーポリシーが厳しくチェックされます 77 | - 拡張機能のマニフェストがManifest V3に準拠していることを確認してください 78 | - 拡張機能のアイコンとスクリーンショットは高品質であることが重要です 79 | - 説明文は明確で、拡張機能の機能を正確に表現するようにしてください 80 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # XKusoRepFilter 2 | 3 | ![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/GOROman/XKusoRepFilter/build.yml?branch=main) 4 | ![GitHub release (latest by date)](https://img.shields.io/github/v/release/GOROman/XKusoRepFilter) 5 | ![GitHub](https://img.shields.io/github/license/GOROman/XKusoRepFilter) 6 | 7 | X(旧Twitter)で特定の文字列「しばらく観察していると」などを含む投稿をブロックするChrome拡張機能です。 8 | 9 | ![alt text](docs/image.png) 10 | 11 | ## 機能 12 | 13 | - X(旧Twitter)のタイムラインで、特定の文字列を含む投稿を非表示にします 14 | - ブロックする文字列は設定画面でカスタマイズ可能 15 | - デフォルトでは「しばらく観察していると」という文字列を含む投稿をブロック 16 | - ブロック前に確認ダイアログを表示するオプション(デフォルトでON) 17 | - 自分の投稿とフォロワーの投稿はブロックされません 18 | - 認証済みアカウント(青・黄色・グレーのチェックマーク)の投稿はブロックされません 19 | - 自分やフォロワーの投稿に特定の文字列が含まれる場合、背景黄色・文字赤でハイライト表示されます 20 | - ダークモード対応のモダンな設定画面 21 | 22 | ## インストール方法 23 | 24 | ### リリースからインストール 25 | 26 | 1. [Releasesページ](https://github.com/GOROman/XKusoRepFilter/releases)から最新の拡張機能パッケージ(.zip)をダウンロード 27 | 2. ファイルを解凍 28 | 3. Chromeで `chrome://extensions/` を開く 29 | 4. 右上の「デベロッパーモード」をオンにする 30 | 5. 「パッケージ化されていない拡張機能を読み込む」をクリック 31 | 6. 解凍したフォルダを選択 32 | 33 | ### ソースからインストール 34 | 35 | 1. このリポジトリをクローン 36 | ``` 37 | git clone https://github.com/GOROman/XKusoRepFilter.git 38 | ``` 39 | 2. 依存関係をインストール 40 | ``` 41 | npm install 42 | ``` 43 | 3. 拡張機能をビルド 44 | ``` 45 | npm run build 46 | ``` 47 | 4. Chromeで `chrome://extensions/` を開く 48 | 5. 右上の「デベロッパーモード」をオンにする 49 | 6. 「パッケージ化されていない拡張機能を読み込む」をクリック 50 | 7. `dist` フォルダを選択 51 | 52 | ## 使い方 53 | 54 | 1. インストール後、Chromeツールバーの拡張機能アイコンをクリック 55 | 2. 「XKusoRepFilter」アイコンをクリック 56 | 3. ブロックしたい文字列を入力(1行に1つ) 57 | 4. 「保存」ボタンをクリック 58 | 5. X(Twitter)のページを更新すると設定が反映されます 59 | 60 | ## 開発者向け情報 61 | 62 | ### CI/CDパイプライン 63 | 64 | このプロジェクトはGitHub Actionsを使用して自動ビルドとリリースを行っています。 65 | 66 | - `main`ブランチへのプッシュ時に自動ビルドが実行されます 67 | - タグ(`v*`形式)を付けてプッシュすると、自動的にリリースが作成されます 68 | 69 | リリースを作成するには、以下のコマンドを実行します(例:バージョン1.3.0の場合): 70 | 71 | ```bash 72 | git tag -a v1.3.0 -m "v1.3.0リリース" 73 | git push origin v1.3.0 74 | ``` 75 | 76 | ### ローカル開発 77 | 78 | ```bash 79 | # 依存関係のインストール 80 | npm install 81 | 82 | # 開発ビルド 83 | npm run build 84 | 85 | # パッケージ作成 86 | npm run package 87 | ``` 88 | 89 | ## 更新履歴 90 | 91 | ### v1.4.1 92 | - 認証済みアカウント(青・黄色・グレーのチェックマーク)の検出機能を修正 93 | - X(旧Twitter)のUI変更に対応 94 | 95 | ### v1.4.0 96 | - 初期リリース 97 | 98 | _❤️❤️❤️ナル先生 参上❤️❤️❤️_ 99 | 100 | -------------------------------------------------------------------------------- /docs/chrome_webstore_cicd_guide.md: -------------------------------------------------------------------------------- 1 | # Chrome ウェブストア公開用CI/CDガイド 2 | 3 | このドキュメントでは、GitHub Actionsを使用してXKusoRepFilter拡張機能をChrome ウェブストアに自動的に公開する方法について説明します。 4 | 5 | ## 概要 6 | 7 | このCI/CDワークフローは以下の機能を提供します: 8 | 9 | 1. 拡張機能のビルドと最適化 10 | 2. Chrome ウェブストア用のパッケージ作成 11 | 3. Chrome ウェブストアへの自動公開 12 | 4. リリースノートの管理 13 | 14 | ## 前提条件 15 | 16 | 1. Chrome ウェブストア開発者アカウント 17 | 2. Chrome ウェブストアAPIアクセス用の認証情報 18 | 3. GitHubリポジトリへの適切な権限 19 | 20 | ## 必要なシークレット 21 | 22 | 以下のシークレットをGitHubリポジトリに設定する必要があります: 23 | 24 | 1. `EXTENSION_ID` - Chrome ウェブストアの拡張機能ID 25 | 2. `CLIENT_ID` - Google OAuth クライアントID 26 | 3. `CLIENT_SECRET` - Google OAuth クライアントシークレット 27 | 4. `REFRESH_TOKEN` - Google OAuth リフレッシュトークン 28 | 29 | ### シークレットの設定方法 30 | 31 | 1. GitHubリポジトリの「Settings」タブを開く 32 | 2. 左側のメニューから「Secrets and variables」→「Actions」を選択 33 | 3. 「New repository secret」ボタンをクリックして各シークレットを追加 34 | 35 | ## 認証情報の取得方法 36 | 37 | ### 拡張機能IDの取得 38 | 39 | 1. 拡張機能をChrome ウェブストアに初回登録する 40 | 2. 登録後、ダッシュボードに表示される拡張機能IDをコピー 41 | 42 | ### Google OAuth認証情報の取得 43 | 44 | 1. [Google Cloud Console](https://console.cloud.google.com/)にアクセス 45 | 2. 新しいプロジェクトを作成 46 | 3. 「APIとサービス」→「認証情報」を選択 47 | 4. 「認証情報を作成」→「OAuthクライアントID」を選択 48 | 5. アプリケーションタイプとして「デスクトップアプリ」を選択 49 | 6. クライアントIDとクライアントシークレットを取得 50 | 51 | ### リフレッシュトークンの取得 52 | 53 | 以下のコマンドを使用してリフレッシュトークンを取得します: 54 | 55 | ```bash 56 | # 必要なパッケージのインストール 57 | npm install -g chrome-webstore-upload-cli 58 | 59 | # リフレッシュトークンの取得 60 | chrome-webstore-upload-cli refresh-token --client-id=YOUR_CLIENT_ID --client-secret=YOUR_CLIENT_SECRET 61 | ``` 62 | 63 | 表示される指示に従ってGoogleアカウントで認証し、リフレッシュトークンを取得します。 64 | 65 | ## ワークフローの使用方法 66 | 67 | ### 手動トリガー 68 | 69 | 1. GitHubリポジトリの「Actions」タブを開く 70 | 2. 「Chrome ウェブストア公開」ワークフローを選択 71 | 3. 「Run workflow」ボタンをクリック 72 | 4. 以下の情報を入力: 73 | - バージョン:拡張機能の新しいバージョン(例:1.4.3) 74 | - リリースノート:変更内容の説明 75 | 5. 「Run workflow」ボタンをクリックして実行 76 | 77 | ### 自動トリガー 78 | 79 | このワークフローは、GitHubでリリースが公開されたときにも自動的に実行されます: 80 | 81 | 1. GitHubリポジトリの「Releases」タブを開く 82 | 2. 「Draft a new release」ボタンをクリック 83 | 3. タグバージョンとリリースタイトルを入力 84 | 4. リリースノートを記入 85 | 5. 「Publish release」ボタンをクリック 86 | 87 | リリースが公開されると、ワークフローが自動的に実行され、Chrome ウェブストアに拡張機能が公開されます。 88 | 89 | ## ワークフローの詳細 90 | 91 | ### ビルドジョブ 92 | 93 | 1. リポジトリのチェックアウト 94 | 2. Node.jsのセットアップ 95 | 3. 依存関係のインストール 96 | 4. マニフェストのバージョン更新(手動トリガーの場合) 97 | 5. webpackの設定ファイル作成 98 | 6. 拡張機能のビルド 99 | 7. Chrome ウェブストア用ZIPファイルの作成 100 | 8. 成果物のアップロード 101 | 102 | ### 公開ジョブ 103 | 104 | 1. ビルド成果物のダウンロード 105 | 2. Chrome ウェブストアへの公開 106 | 3. リリースノートの作成 107 | 4. 公開完了通知 108 | 109 | ## トラブルシューティング 110 | 111 | ### 公開に失敗する場合 112 | 113 | 1. GitHubのActionsタブでワークフローの実行ログを確認 114 | 2. シークレットが正しく設定されているか確認 115 | 3. Chrome ウェブストアのダッシュボードで拡張機能のステータスを確認 116 | 117 | ### 認証エラーが発生する場合 118 | 119 | 1. リフレッシュトークンの有効期限が切れていないか確認 120 | 2. 必要に応じて新しいリフレッシュトークンを生成 121 | 3. GitHubのシークレットを更新 122 | 123 | ## 注意事項 124 | 125 | - Chrome ウェブストアの審査には時間がかかる場合があります 126 | - 公開後も審査が必要なため、すぐにユーザーに配信されるわけではありません 127 | - マニフェストのバージョンは必ず前回より大きな値にしてください 128 | - リリースノートは明確で詳細なものを記載してください 129 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: ビルドとリリース 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | tags: 7 | - 'v*' 8 | pull_request: 9 | branches: [ main ] 10 | 11 | jobs: 12 | lint: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - name: Node.jsのセットアップ 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version: '20' 22 | cache: 'npm' 23 | 24 | - name: 依存関係のインストール 25 | run: | 26 | npm init -y 27 | npm install --save-dev eslint@8.57.0 28 | 29 | - name: 静的解析の実行 30 | run: | 31 | echo '開始:JavaScriptファイルの静的解析' 32 | npx eslint . --ext .js 33 | 34 | build: 35 | needs: lint 36 | runs-on: ubuntu-latest 37 | 38 | steps: 39 | - uses: actions/checkout@v4 40 | 41 | - name: Node.jsのセットアップ 42 | uses: actions/setup-node@v4 43 | with: 44 | node-version: '20' 45 | cache: 'npm' 46 | 47 | - name: 依存関係のインストール 48 | run: | 49 | npm init -y 50 | npm install --save-dev @babel/core @babel/preset-env babel-loader webpack webpack-cli zip-webpack-plugin clean-webpack-plugin copy-webpack-plugin eslint@8.57.0 51 | 52 | - name: webpackの設定ファイル作成 53 | run: | 54 | cat > webpack.config.js << 'EOL' 55 | const path = require('path'); 56 | const ZipPlugin = require('zip-webpack-plugin'); 57 | const { CleanWebpackPlugin } = require('clean-webpack-plugin'); 58 | const CopyPlugin = require('copy-webpack-plugin'); 59 | const package = require('./package.json'); 60 | const manifest = require('./manifest.json'); 61 | 62 | module.exports = { 63 | mode: 'production', 64 | entry: { 65 | background: './background.js', 66 | content: './content.js', 67 | popup: './popup.js' 68 | }, 69 | output: { 70 | path: path.resolve(__dirname, 'dist'), 71 | filename: '[name].js' 72 | }, 73 | module: { 74 | rules: [ 75 | { 76 | test: /\.js$/, 77 | exclude: /node_modules/, 78 | use: { 79 | loader: 'babel-loader', 80 | options: { 81 | presets: ['@babel/preset-env'] 82 | } 83 | } 84 | } 85 | ] 86 | }, 87 | plugins: [ 88 | new CleanWebpackPlugin(), 89 | new CopyPlugin({ 90 | patterns: [ 91 | { from: 'manifest.json', to: '.' }, 92 | { from: 'popup.html', to: '.' }, 93 | { from: 'icon.png', to: '.' }, 94 | { from: 'docs/image.png', to: 'docs/image.png' } 95 | ], 96 | }), 97 | new ZipPlugin({ 98 | filename: `XKusoRepFilter-v${manifest.version}.zip` 99 | }) 100 | ] 101 | }; 102 | EOL 103 | 104 | - name: ビルド 105 | run: npx webpack 106 | 107 | - name: 成果物のアップロード 108 | uses: actions/upload-artifact@v4 109 | with: 110 | name: extension-package 111 | path: dist/*.zip 112 | 113 | release: 114 | needs: build 115 | if: startsWith(github.ref, 'refs/tags/') 116 | runs-on: ubuntu-latest 117 | permissions: 118 | contents: write 119 | 120 | steps: 121 | - name: 成果物のダウンロード 122 | uses: actions/download-artifact@v4 123 | with: 124 | name: extension-package 125 | path: ./ 126 | 127 | - name: リリースの作成 128 | uses: softprops/action-gh-release@v1 129 | with: 130 | files: ./*.zip 131 | draft: false 132 | prerelease: false 133 | env: 134 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 135 | -------------------------------------------------------------------------------- /.github/workflows/chrome-webstore-publish.yml: -------------------------------------------------------------------------------- 1 | name: Chrome ウェブストア公開 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version: 7 | description: '拡張機能のバージョン(例:1.4.3)' 8 | required: true 9 | release_notes: 10 | description: 'リリースノート' 11 | required: true 12 | release: 13 | types: [published] 14 | 15 | jobs: 16 | build: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | 21 | - name: Node.jsのセットアップ 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: '20' 25 | cache: 'npm' 26 | 27 | - name: 依存関係のインストール 28 | run: | 29 | npm init -y 30 | npm install --save-dev @babel/core @babel/preset-env babel-loader webpack webpack-cli zip-webpack-plugin clean-webpack-plugin copy-webpack-plugin eslint@8.57.0 31 | 32 | - name: マニフェストのバージョン更新 33 | if: github.event_name == 'workflow_dispatch' 34 | run: | 35 | # jqを使用してmanifest.jsonのバージョンを更新 36 | jq '.version = "${{ github.event.inputs.version }}"' manifest.json > manifest.json.tmp 37 | mv manifest.json.tmp manifest.json 38 | 39 | - name: webpackの設定ファイル作成 40 | run: | 41 | cat > webpack.config.js << 'EOL' 42 | const path = require('path'); 43 | const ZipPlugin = require('zip-webpack-plugin'); 44 | const { CleanWebpackPlugin } = require('clean-webpack-plugin'); 45 | const CopyPlugin = require('copy-webpack-plugin'); 46 | const manifest = require('./manifest.json'); 47 | 48 | module.exports = { 49 | mode: 'production', 50 | entry: { 51 | background: './background.js', 52 | content: './content.js', 53 | popup: './popup.js' 54 | }, 55 | output: { 56 | path: path.resolve(__dirname, 'dist'), 57 | filename: '[name].js' 58 | }, 59 | module: { 60 | rules: [ 61 | { 62 | test: /\.js$/, 63 | exclude: /node_modules/, 64 | use: { 65 | loader: 'babel-loader', 66 | options: { 67 | presets: ['@babel/preset-env'] 68 | } 69 | } 70 | } 71 | ] 72 | }, 73 | plugins: [ 74 | new CleanWebpackPlugin(), 75 | new CopyPlugin({ 76 | patterns: [ 77 | { from: 'manifest.json', to: '.' }, 78 | { from: 'popup.html', to: '.' }, 79 | { from: 'icons', to: 'icons' }, 80 | { from: 'icon.png', to: '.' } 81 | ], 82 | }), 83 | new ZipPlugin({ 84 | filename: `XKusoRepFilter-v${manifest.version}.zip` 85 | }) 86 | ] 87 | }; 88 | EOL 89 | 90 | - name: ビルド 91 | run: npx webpack 92 | 93 | - name: Chrome ウェブストア用ZIPファイル作成 94 | run: | 95 | cd dist 96 | zip -r chrome-store-package.zip * 97 | ls -la 98 | 99 | - name: 成果物のアップロード 100 | uses: actions/upload-artifact@v4 101 | with: 102 | name: chrome-webstore-package 103 | path: dist/chrome-store-package.zip 104 | 105 | publish: 106 | needs: build 107 | runs-on: ubuntu-latest 108 | steps: 109 | - name: 成果物のダウンロード 110 | uses: actions/download-artifact@v4 111 | with: 112 | name: chrome-webstore-package 113 | path: ./ 114 | 115 | - name: Chrome ウェブストアに公開 116 | uses: mnao305/chrome-extension-upload@v4.0.1 117 | with: 118 | file-path: chrome-store-package.zip 119 | extension-id: ${{ secrets.EXTENSION_ID }} 120 | client-id: ${{ secrets.CLIENT_ID }} 121 | client-secret: ${{ secrets.CLIENT_SECRET }} 122 | refresh-token: ${{ secrets.REFRESH_TOKEN }} 123 | publish: true 124 | publish-target: default 125 | 126 | - name: リリースノートの作成 127 | if: github.event_name == 'workflow_dispatch' 128 | run: | 129 | echo "${{ github.event.inputs.release_notes }}" > release_notes.txt 130 | 131 | - name: リリースノートの取得 132 | if: github.event_name == 'release' 133 | run: | 134 | echo "${{ github.event.release.body }}" > release_notes.txt 135 | 136 | - name: 公開完了通知 137 | run: | 138 | echo "Chrome ウェブストアへの公開が完了しました" 139 | echo "バージョン: ${{ github.event.inputs.version || github.event.release.tag_name }}" 140 | echo "リリースノート:" 141 | cat release_notes.txt 142 | -------------------------------------------------------------------------------- /popup.js: -------------------------------------------------------------------------------- 1 | document.addEventListener('DOMContentLoaded', function() { 2 | // デフォルト値を設定 3 | const defaultBlockWords = 'しばらく観察していると\n紹介したこのブロガー\n彼の指導のもと'; 4 | const defaultShowConfirmDialog = true; 5 | const defaultFilterMode = 'block'; 6 | 7 | 8 | // UI要素 9 | const blockWordsTextarea = document.getElementById('blockWords'); 10 | const showConfirmDialogCheckbox = document.getElementById('showConfirmDialog'); 11 | const blockModeRadio = document.getElementById('blockMode'); 12 | const showOnlyModeRadio = document.getElementById('showOnlyMode'); 13 | 14 | const saveButton = document.getElementById('saveButton'); 15 | const statusMessage = document.getElementById('status'); 16 | const formGroups = document.querySelectorAll('.form-group'); 17 | 18 | // フォームグループにアニメーション効果を追加 19 | formGroups.forEach((group, index) => { 20 | group.style.opacity = '0'; 21 | group.style.transform = 'translateY(10px)'; 22 | 23 | setTimeout(() => { 24 | group.style.transition = 'opacity 0.3s ease, transform 0.3s ease'; 25 | group.style.opacity = '1'; 26 | group.style.transform = 'translateY(0)'; 27 | }, 100 * (index + 1)); 28 | }); 29 | 30 | // 保存されている設定を読み込む 31 | chrome.storage.sync.get(['blockWords', 'showConfirmDialog', 'filterMode'], function(result) { 32 | const blockWords = result.blockWords || defaultBlockWords; 33 | blockWordsTextarea.value = blockWords; 34 | 35 | const showConfirmDialog = result.showConfirmDialog !== undefined ? result.showConfirmDialog : defaultShowConfirmDialog; 36 | showConfirmDialogCheckbox.checked = showConfirmDialog; 37 | 38 | const filterMode = result.filterMode || defaultFilterMode; 39 | if (filterMode === 'block') { 40 | blockModeRadio.checked = true; 41 | } else if (filterMode === 'showOnly') { 42 | showOnlyModeRadio.checked = true; 43 | } 44 | 45 | 46 | 47 | // テキストエリアにフォーカスアニメーション 48 | blockWordsTextarea.addEventListener('focus', function() { 49 | this.parentElement.style.transform = 'scale(1.01)'; 50 | }); 51 | 52 | blockWordsTextarea.addEventListener('blur', function() { 53 | this.parentElement.style.transform = 'scale(1)'; 54 | }); 55 | }); 56 | 57 | // 保存ボタンのクリックイベント 58 | saveButton.addEventListener('click', function() { 59 | // ボタンにクリックエフェクト 60 | this.classList.add('clicked'); 61 | 62 | // ボタンのテキストを変更 63 | const originalText = this.textContent; 64 | this.textContent = '保存中...'; 65 | 66 | const blockWords = blockWordsTextarea.value; 67 | const showConfirmDialog = showConfirmDialogCheckbox.checked; 68 | const filterMode = blockModeRadio.checked ? 'block' : 'showOnly'; 69 | 70 | 71 | // 設定を保存 72 | chrome.storage.sync.set({ 73 | blockWords: blockWords, 74 | showConfirmDialog: showConfirmDialog, 75 | filterMode: filterMode 76 | }, function() { 77 | // 保存完了メッセージを表示 78 | statusMessage.style.display = 'block'; 79 | statusMessage.style.opacity = '0'; 80 | statusMessage.style.transform = 'translateY(10px)'; 81 | 82 | setTimeout(() => { 83 | statusMessage.style.transition = 'opacity 0.3s ease, transform 0.3s ease'; 84 | statusMessage.style.opacity = '1'; 85 | statusMessage.style.transform = 'translateY(0)'; 86 | }, 10); 87 | 88 | // ボタンのテキストを元に戻す 89 | saveButton.textContent = '保存完了!'; 90 | 91 | setTimeout(function() { 92 | saveButton.textContent = originalText; 93 | saveButton.classList.remove('clicked'); 94 | statusMessage.style.opacity = '0'; 95 | statusMessage.style.transform = 'translateY(10px)'; 96 | 97 | setTimeout(() => { 98 | statusMessage.style.display = 'none'; 99 | }, 300); 100 | }, 2000); 101 | }); 102 | }); 103 | 104 | // バージョン情報を表示 105 | const manifestData = chrome.runtime.getManifest(); 106 | const versionElement = document.getElementById('version'); 107 | if (versionElement && manifestData.version) { 108 | versionElement.textContent = `XKusoRepFilter v${manifestData.version}`; 109 | 110 | // バージョン表示にアニメーション効果を追加 111 | versionElement.style.opacity = '0'; 112 | versionElement.style.transform = 'translateY(5px)'; 113 | 114 | setTimeout(() => { 115 | versionElement.style.transition = 'opacity 0.5s ease, transform 0.5s ease'; 116 | versionElement.style.opacity = '1'; 117 | versionElement.style.transform = 'translateY(0)'; 118 | }, 500); 119 | 120 | // クリックでバージョン情報のアニメーション 121 | versionElement.addEventListener('click', function() { 122 | this.style.transform = 'scale(1.1)'; 123 | setTimeout(() => { 124 | this.style.transform = 'scale(1)'; 125 | }, 300); 126 | }); 127 | } 128 | }); 129 | -------------------------------------------------------------------------------- /popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | XKusoRepFilter 設定 6 | 7 | 8 | 9 | 219 | 220 | 221 |
222 | 223 |

XKusoRepFilter 設定

224 |
225 | 226 |
227 | 228 | 229 |
230 | 231 |
232 |
233 | 234 | 238 |
239 |
240 | 241 |
242 | 243 |
244 | 248 | 252 |
253 |
254 | 255 | 256 |
257 | 258 |
259 | 260 |
設定を保存しました!
261 | 262 | 265 | 266 | 267 | 268 | 269 | -------------------------------------------------------------------------------- /content.js: -------------------------------------------------------------------------------- 1 | // ブロックする文字列のリスト 2 | let blockWordsList = [ 3 | 'しばらく観察していると', 4 | '紹介したこのブロガー', 5 | '彼の指導のもと' 6 | ]; 7 | // 確認ダイアログを表示するかどうか 8 | let showConfirmDialog = true; 9 | let filterMode = 'block'; 10 | // 確認済みのツイートIDを保存するセット 11 | let confirmedTweetIds = new Set(); 12 | // 自分のIDとフォロワーのIDを保存するセット 13 | let myAndFollowersIds = new Set(); 14 | 15 | // ディストーションの設定(バキッとした音にするため) 16 | function makeDistortionCurve(amount) { 17 | const k = typeof amount === 'number' ? amount : 50; 18 | const nSamples = 44100; 19 | const curve = new Float32Array(nSamples); 20 | const deg = Math.PI / 180; 21 | 22 | for (let i = 0; i < nSamples; ++i) { 23 | const x = (i * 2) / nSamples - 1; 24 | curve[i] = (3 + k) * x * 20 * deg / (Math.PI + k * Math.abs(x)); 25 | } 26 | return curve; 27 | } 28 | 29 | 30 | // 設定を読み込む 31 | function loadSettings() { 32 | chrome.storage.sync.get(['blockWords', 'showConfirmDialog', 'filterMode'], function(result) { 33 | if (result.blockWords) { 34 | // 改行で分割して配列に変換 35 | blockWordsList = result.blockWords.split('\n').filter(word => word.trim() !== ''); 36 | console.log('XKusoRepFilter: 設定を読み込みました', blockWordsList); 37 | } 38 | 39 | if (result.showConfirmDialog !== undefined) { 40 | showConfirmDialog = result.showConfirmDialog; 41 | console.log('XKusoRepFilter: 確認ダイアログ設定を読み込みました', showConfirmDialog); 42 | } 43 | 44 | if (result.filterMode !== undefined) { 45 | filterMode = result.filterMode; 46 | console.log('XKusoRepFilter: フィルターモード設定を読み込みました', filterMode); 47 | } 48 | }); 49 | } 50 | 51 | // 自分のIDとフォロワーのIDを取得する関数 52 | function fetchMyAndFollowersIds() { 53 | // 自分のプロフィールリンクを探す 54 | const profileLinks = document.querySelectorAll('a[href^="/"][role="link"][aria-label]'); 55 | 56 | profileLinks.forEach(link => { 57 | const href = link.getAttribute('href'); 58 | if (href && href.startsWith('/') && !href.includes('/status/')) { 59 | // 自分のIDを取得 60 | const myId = href.replace('/', ''); 61 | if (myId && !myAndFollowersIds.has(myId)) { 62 | myAndFollowersIds.add(myId); 63 | console.log('XKusoRepFilter: 自分のIDを登録しました', myId); 64 | } 65 | } 66 | }); 67 | 68 | // フォロワーリストはページから直接取得するのは難しいため、 69 | // タイムライン上でフォロー中のアカウントを検出します 70 | const followingIndicators = document.querySelectorAll('span[data-testid="userFollowing"]'); 71 | 72 | followingIndicators.forEach(indicator => { 73 | // フォロー中のアカウントのツイート要素を探す 74 | const tweet = indicator.closest('article[data-testid="tweet"]'); 75 | if (tweet) { 76 | // ツイートからユーザーIDを取得 77 | const userLinks = tweet.querySelectorAll('a[role="link"][href^="/"]'); 78 | userLinks.forEach(link => { 79 | const href = link.getAttribute('href'); 80 | if (href && href.startsWith('/') && !href.includes('/status/')) { 81 | const userId = href.replace('/', ''); 82 | if (userId && !myAndFollowersIds.has(userId)) { 83 | myAndFollowersIds.add(userId); 84 | console.log('XKusoRepFilter: フォロワーIDを登録しました', userId); 85 | } 86 | } 87 | }); 88 | } 89 | }); 90 | } 91 | 92 | // 初期設定の読み込み 93 | loadSettings(); 94 | 95 | // ストレージの変更を監視 96 | chrome.storage.onChanged.addListener(function(changes, namespace) { 97 | if (namespace === 'sync' && (changes.blockWords || changes.showConfirmDialog || changes.filterMode)) { 98 | loadSettings(); 99 | } 100 | }); 101 | 102 | // 確認ダイアログ用のCSSスタイルを追加 103 | function addConfirmDialogStyles() { 104 | const style = document.createElement('style'); 105 | style.textContent = ` 106 | .xkuso-confirm-dialog { 107 | position: absolute; 108 | bottom: 10px; 109 | right: 10px; 110 | background-color: white; 111 | border: 1px solid #ccc; 112 | border-radius: 8px; 113 | padding: 8px; 114 | box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); 115 | z-index: 9999; 116 | font-size: 14px; 117 | max-width: 300px; 118 | } 119 | 120 | .xkuso-confirm-dialog-buttons { 121 | display: flex; 122 | justify-content: flex-end; 123 | margin-top: 8px; 124 | } 125 | 126 | .xkuso-confirm-dialog-button { 127 | margin-left: 8px; 128 | padding: 4px 8px; 129 | border-radius: 4px; 130 | border: none; 131 | cursor: pointer; 132 | font-weight: bold; 133 | } 134 | 135 | .xkuso-confirm-dialog-block { 136 | background-color: #f44336; 137 | color: white; 138 | } 139 | 140 | .xkuso-confirm-dialog-cancel { 141 | background-color: #e0e0e0; 142 | } 143 | `; 144 | document.head.appendChild(style); 145 | console.log('XKusoRepFilter: 確認ダイアログスタイルを追加しました'); 146 | } 147 | 148 | // スタイルを追加 149 | addConfirmDialogStyles(); 150 | 151 | // 定期的に自分とフォロワーのIDを取得 152 | setInterval(fetchMyAndFollowersIds, 10000); 153 | // 初回実行 154 | fetchMyAndFollowersIds(); 155 | 156 | // ツイートIDを取得する関数 157 | function getTweetId(tweet) { 158 | // すでにIDが設定されている場合はそれを返す 159 | if (tweet.dataset.tweetId) { 160 | return tweet.dataset.tweetId; 161 | } 162 | 163 | // ツイート要素からリンクを探す 164 | const links = tweet.querySelectorAll('a[href*="/status/"]'); 165 | for (const link of links) { 166 | const href = link.getAttribute('href'); 167 | const match = href.match(/\/status\/(\d+)/); 168 | if (match && match[1]) { 169 | // IDをデータ属性に保存 170 | tweet.dataset.tweetId = match[1]; 171 | return match[1]; 172 | } 173 | } 174 | 175 | // IDが見つからない場合はユニークな文字列を生成して保存 176 | const uniqueId = 'tweet-' + Math.random().toString(36).substring(2, 15); 177 | tweet.dataset.tweetId = uniqueId; 178 | return uniqueId; 179 | } 180 | 181 | // ツイートを確認前に50%暗くする関数 182 | function dimTweetBeforeConfirmation(tweet) { 183 | try { 184 | // すでに暗くなっている場合は何もしない 185 | if (tweet.classList.contains('xkuso-dimmed-tweet') || tweet.dataset.dimmed === 'true') { 186 | return; 187 | } 188 | 189 | // クラスを追加して明るさを50%に設定 190 | tweet.classList.add('xkuso-dimmed-tweet'); 191 | 192 | // 処理済みとしてマーク 193 | tweet.dataset.dimmed = 'true'; 194 | 195 | console.log('XKusoRepFilter: ツイートを50%暗くしました'); 196 | } catch (error) { 197 | console.error('XKusoRepFilter: ツイートのスタイル変更中にエラーが発生しました', error); 198 | } 199 | } 200 | 201 | // 確認ダイアログを表示する関数 202 | function showBlockConfirmation(tweet, tweetText, matchedWord) { 203 | // すでに確認ダイアログが表示されている場合は何もしない 204 | if (tweet.querySelector('.xkuso-confirm-dialog')) return; 205 | 206 | // ツイートIDを取得 207 | const tweetId = getTweetId(tweet); 208 | 209 | // すでに確認済みの場合はブロックする 210 | if (confirmedTweetIds.has(tweetId)) { 211 | blockTweet(tweet, tweetText); 212 | return; 213 | } 214 | 215 | // 自分またはフォロワーのツイートの場合は何もしない 216 | if (isMyOrFollowersTweet(tweet)) return; 217 | 218 | // ツイートを50%暗くする 219 | dimTweetBeforeConfirmation(tweet); 220 | 221 | // 確認ダイアログを作成 222 | const dialog = document.createElement('div'); 223 | dialog.className = 'xkuso-confirm-dialog'; 224 | dialog.style.cssText = 'position: absolute; bottom: 5px; right: 5px; background-color: rgba(29, 161, 242, 0.9); color: white; padding: 5px; z-index: 9999; display: flex; border-radius: 4px;'; 225 | 226 | // ブロックボタン 227 | const blockButton = document.createElement('button'); 228 | blockButton.textContent = 'Block'; 229 | blockButton.style.cssText = 'margin-right: 5px; padding: 12px 24px; background-color: #e0245e; border: none; color: white; border-radius: 9999px; cursor: pointer; font-size: 14px; font-weight: bold;'; 230 | blockButton.onclick = function(e) { 231 | e.stopPropagation(); // イベントの伝播を停止 232 | confirmedTweetIds.add(tweetId); 233 | 234 | // 消滅アニメーションを表示 235 | playVanishAnimation(tweet, function() { 236 | // アニメーション完了後にブロック処理を実行 237 | blockTweet(tweet, tweetText); 238 | }); 239 | 240 | if (tweet.contains(dialog)) { 241 | tweet.removeChild(dialog); 242 | } 243 | }; 244 | dialog.appendChild(blockButton); 245 | 246 | // ツイートにダイアログを追加 247 | tweet.style.position = 'relative'; 248 | tweet.appendChild(dialog); 249 | } 250 | 251 | // アニメーション用のスタイルを追加 252 | function addAnimationStyles() { 253 | const styleElement = document.createElement('style'); 254 | styleElement.textContent = ` 255 | /* キーワード含有時のスタイル (50%明るさ) */ 256 | .xkuso-dimmed-tweet { 257 | filter: brightness(0.5) !important; 258 | transition: filter 0.3s ease; 259 | } 260 | 261 | /* ブロック後のアニメーション */ 262 | @keyframes xkuso-fade-out { 263 | 0% { opacity: 1; transform: scale(1); filter: brightness(0.5); } 264 | 40% { opacity: 0.8; transform: scale(0.97) translateY(2px); filter: brightness(0.4); } 265 | 100% { opacity: 0.4; transform: scale(0.94) translateY(5px); filter: brightness(0.2); } 266 | } 267 | 268 | @keyframes xkuso-shrink { 269 | 0% { max-height: 1000px; } 270 | 40% { max-height: 150px; } 271 | 100% { max-height: 20px; } 272 | } 273 | 274 | @keyframes xkuso-vanish { 275 | 0% { opacity: 0.4; transform: scale(0.94) translateY(5px); } 276 | 50% { opacity: 0.2; transform: scale(0.92) translateY(8px); } 277 | 100% { opacity: 0; transform: scale(0.9) translateY(10px); } 278 | } 279 | 280 | @keyframes xkuso-blur { 281 | 0% { filter: blur(0); } 282 | 100% { filter: blur(2px); } 283 | } 284 | 285 | @keyframes xkuso-label-appear { 286 | 0% { transform: translateY(-20px); opacity: 0; } 287 | 100% { transform: translateY(0); opacity: 1; } 288 | } 289 | 290 | @keyframes xkuso-pulse { 291 | 0% { transform: scale(1); } 292 | 50% { transform: scale(1.05); } 293 | 100% { transform: scale(1); } 294 | } 295 | 296 | /* 消滅アニメーション用スタイル */ 297 | @keyframes xkuso-vanish-animation { 298 | 0% { opacity: 1; transform: scale(1); } 299 | 50% { opacity: 0.5; transform: scale(0.98); } 300 | 100% { opacity: 0.2; transform: scale(0.95) translateY(5px); } 301 | } 302 | 303 | .xkuso-vanishing-tweet { 304 | animation: xkuso-vanish-animation 0.6s ease forwards; 305 | transition: all 0.6s ease-in-out; 306 | } 307 | 308 | .xkuso-blocked-tweet { 309 | animation: xkuso-fade-out 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) forwards, xkuso-shrink 0.8s cubic-bezier(0.34, 1.56, 0.64, 1) forwards, xkuso-vanish 1s cubic-bezier(0.34, 1.56, 0.64, 1) 0.8s forwards; 310 | overflow: hidden; 311 | position: relative; 312 | pointer-events: none; 313 | border: 1px solid rgba(0, 0, 0, 0.05); 314 | background-color: rgba(0, 0, 0, 0.02); 315 | transition: all 0.5s cubic-bezier(0.34, 1.56, 0.64, 1); 316 | margin-bottom: -20px; 317 | height: auto; 318 | max-height: 1000px; 319 | will-change: opacity, transform, max-height; 320 | } 321 | 322 | .xkuso-blocked-tweet * { 323 | animation: xkuso-blur 0.8s ease forwards; 324 | transition: all 0.5s ease; 325 | filter: grayscale(100%); 326 | } 327 | 328 | .xkuso-block-label { 329 | position: absolute; 330 | top: 0; 331 | left: 0; 332 | right: 0; 333 | background-color: rgba(0, 0, 0, 0.7); 334 | color: white; 335 | padding: 2px 5px; 336 | font-size: 10px; 337 | text-align: center; 338 | z-index: 999; 339 | animation: xkuso-label-appear 0.5s ease 0.3s both, xkuso-pulse 2s ease infinite 1s; 340 | } 341 | `; 342 | document.head.appendChild(styleElement); 343 | console.log('XKusoRepFilter: アニメーションスタイルを追加しました'); 344 | } 345 | 346 | // ページ読み込み時にアニメーションスタイルを追加 347 | addAnimationStyles(); 348 | 349 | // ツイートをブロックする関数 350 | function blockTweet(tweet, tweetText) { 351 | try { 352 | // すでにブロック済みの場合は何もしない 353 | if (tweet.classList.contains('xkuso-blocked-tweet') || tweet.dataset.filtered === 'true') { 354 | return; 355 | } 356 | 357 | // ツイートの内容を保存 358 | const tweetContent = tweet.innerHTML; 359 | 360 | // ツイートの元の高さを保存 361 | const originalHeight = tweet.offsetHeight; 362 | 363 | // 相対配置のための設定 364 | if (getComputedStyle(tweet).position === 'static') { 365 | tweet.style.position = 'relative'; 366 | } 367 | 368 | // 確認前に追加されたクラスを削除 369 | tweet.classList.remove('xkuso-dimmed-tweet'); 370 | 371 | // ブロック時のアニメーションクラスを追加 372 | tweet.classList.add('xkuso-blocked-tweet'); 373 | 374 | // ブロックラベルを追加 375 | const blockLabel = document.createElement('div'); 376 | blockLabel.innerHTML = 'ブロックされたツイート'; 377 | blockLabel.className = 'xkuso-block-label'; 378 | tweet.appendChild(blockLabel); 379 | 380 | // クリックイベントを無効化 381 | const disableClicks = (e) => { 382 | e.stopPropagation(); 383 | e.preventDefault(); 384 | return false; 385 | }; 386 | 387 | tweet.addEventListener('click', disableClicks, true); 388 | tweet.addEventListener('mousedown', disableClicks, true); 389 | tweet.addEventListener('mouseup', disableClicks, true); 390 | 391 | // エフェクト音を再生 392 | playBlockSound(); 393 | 394 | // 処理済みとしてマーク 395 | tweet.dataset.filtered = 'true'; 396 | tweet.dataset.originalContent = encodeURIComponent(tweetContent); 397 | 398 | // 1秒後に完全に非表示にする 399 | setTimeout(() => { 400 | tweet.style.display = 'none'; 401 | console.log('XKusoRepFilter: ツイートを完全に非表示にしました', tweetText); 402 | }, 1000); 403 | 404 | console.log('XKusoRepFilter: ツイートをブロックし、アニメーションを適用しました', tweetText); 405 | } catch (error) { 406 | console.error('XKusoRepFilter: ツイートのスタイル変更中にエラーが発生しました', error); 407 | 408 | // エラーが発生した場合は当初の方法で非表示にする 409 | tweet.style.display = 'none'; 410 | tweet.dataset.filtered = 'true'; 411 | } 412 | } 413 | 414 | // ブロック時のエフェクト音を再生する関数 415 | function playBlockSound() { 416 | try { 417 | // AudioContextを作成 418 | const audioContext = new (window.AudioContext || window.webkitAudioContext)(); 419 | 420 | // オシレーターとエフェクトノードを作成 421 | const oscillator1 = audioContext.createOscillator(); 422 | const oscillator2 = audioContext.createOscillator(); 423 | const gainNode = audioContext.createGain(); 424 | const distortion = audioContext.createWaveShaper(); 425 | 426 | // ディストーションの設定(バキッとした音にするため) 427 | 428 | distortion.curve = makeDistortionCurve(400); 429 | distortion.oversample = '4x'; 430 | 431 | // オシレーター1の設定(高い音) 432 | oscillator1.type = 'sawtooth'; // ノコギリ波でシャープな音に 433 | oscillator1.frequency.setValueAtTime(880, audioContext.currentTime); // A5 434 | oscillator1.frequency.exponentialRampToValueAtTime(220, audioContext.currentTime + 0.15); // A3 435 | 436 | // オシレーター2の設定(低い音を重ねる) 437 | oscillator2.type = 'square'; // 矩形波で厚みのある音に 438 | oscillator2.frequency.setValueAtTime(440, audioContext.currentTime); // A4 439 | oscillator2.frequency.exponentialRampToValueAtTime(110, audioContext.currentTime + 0.2); // A2 440 | 441 | // 音量の設定 442 | gainNode.gain.setValueAtTime(0.15, audioContext.currentTime); 443 | gainNode.gain.exponentialRampToValueAtTime(0.001, audioContext.currentTime + 0.3); 444 | 445 | // 接続(オシレーター→ディストーション→ゲイン→出力) 446 | oscillator1.connect(distortion); 447 | oscillator2.connect(distortion); 448 | distortion.connect(gainNode); 449 | gainNode.connect(audioContext.destination); 450 | 451 | // 再生 452 | oscillator1.start(); 453 | oscillator2.start(); 454 | oscillator1.stop(audioContext.currentTime + 0.3); 455 | oscillator2.stop(audioContext.currentTime + 0.3); 456 | 457 | console.log('XKusoRepFilter: バキーンエフェクト音を再生しました'); 458 | } catch (error) { 459 | console.error('XKusoRepFilter: エフェクト音の再生中にエラーが発生しました', error); 460 | } 461 | } 462 | 463 | // 消滅時のエフェクト音を再生する関数 464 | function playVanishSound() { 465 | try { 466 | // AudioContextを作成 467 | const audioContext = new (window.AudioContext || window.webkitAudioContext)(); 468 | 469 | // オシレーターを作成 470 | const oscillator = audioContext.createOscillator(); 471 | const gainNode = audioContext.createGain(); 472 | 473 | // オシレーターの設定(より低い音で消滅感を演出) 474 | oscillator.type = 'sine'; 475 | oscillator.frequency.setValueAtTime(220, audioContext.currentTime); // A3 476 | oscillator.frequency.exponentialRampToValueAtTime(110, audioContext.currentTime + 0.3); // A2 477 | 478 | // 音量の設定(より小さい音で消滅感を演出) 479 | gainNode.gain.setValueAtTime(0.05, audioContext.currentTime); 480 | gainNode.gain.exponentialRampToValueAtTime(0.001, audioContext.currentTime + 0.3); 481 | 482 | // 接続 483 | oscillator.connect(gainNode); 484 | gainNode.connect(audioContext.destination); 485 | 486 | // 再生 487 | oscillator.start(); 488 | oscillator.stop(audioContext.currentTime + 0.3); 489 | } catch (error) { 490 | console.error('XKusoRepFilter: 消滅音の再生中にエラーが発生しました', error); 491 | } 492 | } 493 | 494 | // 消滅アニメーションを表示する関数 495 | function playVanishAnimation(tweet, callback) { 496 | try { 497 | // すでにアニメーション中の場合は何もしない 498 | if (tweet.dataset.animating === 'true') { 499 | if (callback) callback(); 500 | return; 501 | } 502 | 503 | // アニメーション中としてマーク 504 | tweet.dataset.animating = 'true'; 505 | 506 | // 元のスタイルを保存 507 | const originalTransform = tweet.style.transform || ''; 508 | const originalTransition = tweet.style.transition || ''; 509 | const originalOpacity = tweet.style.opacity || '1'; 510 | 511 | // 消滅アニメーションのスタイルを適用 512 | tweet.style.transition = 'all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)'; 513 | tweet.style.transform = 'scale(0.94) translateY(8px)'; 514 | tweet.style.opacity = '0.1'; 515 | tweet.style.willChange = 'opacity, transform'; 516 | 517 | // 消滅音を再生 518 | playVanishSound(); 519 | 520 | // アニメーション完了後にコールバックを実行 521 | setTimeout(() => { 522 | // アニメーションフラグをリセット 523 | tweet.dataset.animating = 'false'; 524 | 525 | // コールバックがあれば実行 526 | if (callback) callback(); 527 | }, 400); // アニメーション時間と同じ 528 | } catch (error) { 529 | console.error('XKusoRepFilter: 消滅アニメーション中にエラーが発生しました', error); 530 | // エラーが発生した場合はコールバックを実行 531 | if (callback) callback(); 532 | } 533 | } 534 | 535 | // ツイートが自分またはフォロワーのものかチェックする関数 536 | function isMyOrFollowersTweet(tweet) { 537 | // ツイートからユーザーIDを取得 538 | const userLinks = tweet.querySelectorAll('a[role="link"][href^="/"]'); 539 | for (const link of userLinks) { 540 | const href = link.getAttribute('href'); 541 | if (href && href.startsWith('/') && !href.includes('/status/')) { 542 | const userId = href.replace('/', ''); 543 | // 自分またはフォロワーのIDかチェック 544 | if (myAndFollowersIds.has(userId)) { 545 | return true; 546 | } 547 | } 548 | } 549 | return false; 550 | } 551 | 552 | // 認証済みアカウントかチェックする関数 553 | function isVerifiedAccount(tweet) { 554 | try { 555 | // 認証済みアカウントのマークを探す 556 | // 認証済みアカウントは青いチェックマークアイコンが表示される 557 | const verifiedBadge = tweet.querySelector('svg[aria-label="認証済みアカウント"]'); 558 | if (verifiedBadge) { 559 | return true; 560 | } 561 | 562 | // 英語表記の場合もチェック 563 | const verifiedBadgeEn = tweet.querySelector('svg[aria-label="Verified Account"]'); 564 | if (verifiedBadgeEn) { 565 | return true; 566 | } 567 | 568 | // 新しいX/Twitterの認証マークの検出 569 | // 1. 青いチェックマーク (Twitter Blue/X Premium) 570 | const blueCheckmark = tweet.querySelector('svg[data-testid="icon-verified"]'); 571 | if (blueCheckmark) { 572 | return true; 573 | } 574 | 575 | // 2. 黄色いチェックマーク (組織) 576 | const yellowCheckmark = tweet.querySelector('svg[data-testid="verificationBadge"]'); 577 | if (yellowCheckmark) { 578 | return true; 579 | } 580 | 581 | // 3. グレーのチェックマーク (政府機関) 582 | const grayCheckmark = tweet.querySelector('svg[data-testid="verificationBadgeGray"]'); 583 | if (grayCheckmark) { 584 | return true; 585 | } 586 | 587 | // その他の言語の場合もチェックするため、チェックマークの色で判定 588 | const svgElements = tweet.querySelectorAll('svg'); 589 | for (const svg of svgElements) { 590 | // 青いチェックマークを探す 591 | const bluePaths = svg.querySelectorAll('path[fill="rgb(29, 155, 240)"]'); 592 | if (bluePaths.length > 0) { 593 | return true; 594 | } 595 | 596 | // 黄色いチェックマークを探す 597 | const yellowPaths = svg.querySelectorAll('path[fill="rgb(255, 207, 51)"]'); 598 | if (yellowPaths.length > 0) { 599 | return true; 600 | } 601 | 602 | // グレーのチェックマークを探す 603 | const grayPaths = svg.querySelectorAll('path[fill="rgb(128, 128, 128)"]'); 604 | if (grayPaths.length > 0) { 605 | return true; 606 | } 607 | } 608 | 609 | return false; 610 | } catch (error) { 611 | console.error('XKusoRepFilter: 認証済みアカウントチェック中にエラーが発生しました', error); 612 | return false; 613 | } 614 | } 615 | 616 | 617 | function showTweet(tweet) { 618 | // すでに処理済みの場合はスキップ 619 | if (tweet.dataset.filtered === 'shown') return; 620 | 621 | tweet.dataset.filtered = 'shown'; 622 | 623 | tweet.style.border = '2px solid #1d9bf0'; 624 | tweet.style.boxShadow = '0 0 10px rgba(29, 155, 240, 0.5)'; 625 | tweet.style.position = 'relative'; 626 | 627 | const spamLabel = document.createElement('div'); 628 | spamLabel.innerHTML = 'スパム投稿'; 629 | spamLabel.className = 'xkuso-spam-label'; 630 | spamLabel.style.position = 'absolute'; 631 | spamLabel.style.top = '0'; 632 | spamLabel.style.right = '0'; 633 | spamLabel.style.backgroundColor = '#1d9bf0'; 634 | spamLabel.style.color = 'white'; 635 | spamLabel.style.padding = '2px 5px'; 636 | spamLabel.style.fontSize = '10px'; 637 | spamLabel.style.borderRadius = '0 0 0 4px'; 638 | spamLabel.style.zIndex = '999'; 639 | tweet.appendChild(spamLabel); 640 | 641 | console.log('XKusoRepFilter: スパム投稿を表示しました'); 642 | } 643 | 644 | function hideTweet(tweet) { 645 | // すでに処理済みの場合はスキップ 646 | if (tweet.dataset.filtered === 'hidden') return; 647 | 648 | tweet.dataset.filtered = 'hidden'; 649 | 650 | tweet.style.display = 'none'; 651 | 652 | console.log('XKusoRepFilter: 非スパム投稿を非表示にしました'); 653 | } 654 | 655 | // ツイートをフィルタリングする関数 656 | function filterTweets() { 657 | // タイムラインの各ツイート要素を取得 658 | const tweets = document.querySelectorAll('article[data-testid="tweet"]'); 659 | 660 | tweets.forEach(tweet => { 661 | if (tweet.dataset.filtered && tweet.dataset.filterMode === filterMode) return; 662 | 663 | tweet.dataset.filterMode = filterMode; 664 | 665 | // ツイートのテキスト内容を取得 666 | const tweetText = tweet.textContent || ''; 667 | 668 | // ブロックワードが含まれているかチェック 669 | let matchedWord = null; 670 | for (const word of blockWordsList) { 671 | if (tweetText.includes(word)) { 672 | matchedWord = word; 673 | break; 674 | } 675 | } 676 | 677 | const isSpecialTweet = isMyOrFollowersTweet(tweet) || isVerifiedAccount(tweet); 678 | 679 | if (filterMode === 'block') { 680 | if (matchedWord && !isSpecialTweet) { 681 | if (showConfirmDialog) { 682 | // 確認ダイアログを表示 683 | showBlockConfirmation(tweet, tweetText, matchedWord); 684 | } else { 685 | // 確認なしでブロック 686 | blockTweet(tweet, tweetText); 687 | } 688 | } else if (isSpecialTweet && matchedWord) { 689 | tweet.dataset.filtered = 'skipped'; 690 | if (isVerifiedAccount(tweet)) { 691 | console.log('XKusoRepFilter: 認証済みアカウントのツイートをスキップしました'); 692 | } 693 | } 694 | } else if (filterMode === 'showOnly') { 695 | if (matchedWord) { 696 | showTweet(tweet); 697 | } else { 698 | hideTweet(tweet); 699 | } 700 | } 701 | }); 702 | } 703 | 704 | // MutationObserverを使用してDOMの変更を監視 705 | const observer = new MutationObserver(function(mutations) { 706 | filterTweets(); 707 | }); 708 | 709 | // 監視の開始 710 | observer.observe(document.body, { 711 | childList: true, 712 | subtree: true 713 | }); 714 | 715 | // 初回実行 716 | filterTweets(); 717 | 718 | // 定期的にフィルタリングを実行(スクロール時などの対策) 719 | setInterval(filterTweets, 2000); 720 | --------------------------------------------------------------------------------