├── assets ├── labels.json ├── next_version.txt └── labels.previous.json ├── channel_specific ├── manifest_channel.txt ├── set_chrome_extension_ID.sh ├── set_firefox_extension_ID.sh ├── set_firefox_channel_option.sh ├── set_chrome_trusted_testers_option.sh ├── updates.xml └── updates.template.xml ├── sources ├── unused │ ├── flags.js │ ├── common.js │ ├── url_templates.js │ ├── save_usage_telemetry.js │ ├── Random_numbers.jq │ ├── background.js │ └── purify.min.js ├── background.html ├── common_impex.js ├── hot-reload.js ├── background.js └── metabot.js ├── releases └── ChromeOpera_self_distribution.crx ├── metadata ├── package.json ├── manifest.template.json └── manifest3.template.json ├── store_listing ├── screenshots │ └── for Firefox 1280x800 later screenshot.png └── Chrome_Privacy_Policy.txt ├── docs ├── channel_specifics.md ├── dev_setup.md └── browser_specifics.md ├── .gitignore └── README.md /assets/labels.json: -------------------------------------------------------------------------------- 1 | { 2 | } 3 | -------------------------------------------------------------------------------- /assets/next_version.txt: -------------------------------------------------------------------------------- 1 | 0.7.19 2 | -------------------------------------------------------------------------------- /assets/labels.previous.json: -------------------------------------------------------------------------------- 1 | { 2 | } 3 | -------------------------------------------------------------------------------- /channel_specific/manifest_channel.txt: -------------------------------------------------------------------------------- 1 | stable 2 | -------------------------------------------------------------------------------- /sources/unused/flags.js: -------------------------------------------------------------------------------- 1 | var flagPrefillGoogleForms=true; 2 | -------------------------------------------------------------------------------- /channel_specific/set_chrome_extension_ID.sh: -------------------------------------------------------------------------------- 1 | extension_ID="cooadmmiojjmmfifkcainbnmhghfcbfi" 2 | -------------------------------------------------------------------------------- /channel_specific/set_firefox_extension_ID.sh: -------------------------------------------------------------------------------- 1 | extension_ID="d20acea6-2744-4763-bb1c-f62924e40073" 2 | -------------------------------------------------------------------------------- /releases/ChromeOpera_self_distribution.crx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antibot4navalny/metabot/HEAD/releases/ChromeOpera_self_distribution.crx -------------------------------------------------------------------------------- /metadata/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "chrome-webstore-upload-cli": "^2.1.0", 4 | "web-ext": "^7.2.0" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /channel_specific/set_firefox_channel_option.sh: -------------------------------------------------------------------------------- 1 | # Stable: 2 | unset firefox_channel_option 3 | 4 | 5 | ### Early Access only: 6 | # firefox_channel_option="--channel=unlisted" 7 | -------------------------------------------------------------------------------- /channel_specific/set_chrome_trusted_testers_option.sh: -------------------------------------------------------------------------------- 1 | ### Stable channel 2 | unset chrome_testers_option 3 | 4 | ### Early Access only: 5 | # chrome_testers_option="--trusted-testers" 6 | -------------------------------------------------------------------------------- /store_listing/screenshots/for Firefox 1280x800 later screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antibot4navalny/metabot/HEAD/store_listing/screenshots/for Firefox 1280x800 later screenshot.png -------------------------------------------------------------------------------- /sources/unused/common.js: -------------------------------------------------------------------------------- 1 | function getInitData() 2 | { 3 | initData= 4 | document.querySelector('#init-data') 5 | if (initData) 6 | return JSON.parse(initData.value) 7 | else 8 | return null 9 | } 10 | -------------------------------------------------------------------------------- /sources/background.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /sources/common_impex.js: -------------------------------------------------------------------------------- 1 | export function retrieveItemFromStorage(key, defaultValue) 2 | { 3 | return new Promise(resolve => { 4 | chrome.storage.local.get( 5 | {[key]: defaultValue}, function(result) { 6 | const returnedValue = result[key]; 7 | resolve(returnedValue); 8 | }) 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /channel_specific/updates.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /channel_specific/updates.template.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /store_listing/Chrome_Privacy_Policy.txt: -------------------------------------------------------------------------------- 1 | No data *of extension users* whatsoever is collected, used or shared by the extension. 2 | 3 | In order to highlight tweets of previously identified "Russian orchestrated human trolls for hire", account names and IDs *of those trolls* are *received* by the extension from a public web-hosted file on GitHub (https://github.com/antibot4navalny/accounts_labelled/blob/main/labels.json) and stored in browser's local storage. 4 | 5 | No data of *other Twitter users* is scraped, collected or transmitted by the extension. 6 | 7 | The extension does *not* handle user-related data in any other way. 8 | -------------------------------------------------------------------------------- /docs/channel_specifics.md: -------------------------------------------------------------------------------- 1 | The following is currently only used in Stable, not in Early Access (maybe it will change in future): 2 | - `update_git_version.sh`: because currently there's no long-lasting periods of testers-only access with regular update of prepacked labels 3 | - `update.(template.)xml`: because Early-Access version is not public at the moment 4 | 5 | Only in Early Access: 6 | - `experiments/`: has (limited) value for development team only 7 | - `store_listings/screenshots/drafts`: only used to prepare screenshots for extension stores 8 | 9 | 10 | TODO: decide and document whether `next_version.txt` counters should (not) be related between Stable and Early Access. 11 | -------------------------------------------------------------------------------- /sources/unused/url_templates.js: -------------------------------------------------------------------------------- 1 | // шаблоны ссылок на "Сообщить о боте" 2 | 3 | var templateReportTweetUrl='https://docs.google.com/forms/d/e/'+ 4 | '1FAIpQLSfPoe1MnSm_YNroehp6mLVgLYyVWWl-kzoeD13hAkEcDhG0QQ'+ 5 | '/viewform?usp=pp_url'+ 6 | '&entry.44520066=${reportedAccount}'+ 7 | '&entry.2043639068=${reportingUser}'+ 8 | '&entry.63366018=${tweetInvokedFrom}'+ 9 | '&entry.1893779955=${tweetInvokedFrom}'; 10 | 11 | var templateReportUntypicalTweetUrl='https://docs.google.com/forms/d/e/'+ 12 | '1FAIpQLSc4HYezRxBA_b8Ywe_CKjwa2_QI-uMjwwtfjUMXze6Mw-ayOg'+ 13 | '/viewform?usp=pp_url'+ 14 | '&entry.1071230723=${reportedAccount}'+ 15 | '&entry.560365343=${tweetInvokedFrom}'+ 16 | '&entry.221506714=${reportingUser}' 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .git* 2 | !.gitignore 3 | 4 | 5 | 6 | ### No value to store, intermediary files 7 | releases/* 8 | assets/labels.previous.json 9 | 10 | 11 | **/.DS_Store 12 | bot_accounts*.csv 13 | twarc.log 14 | 15 | package-lock.json 16 | node_modules/* 17 | 18 | 19 | 20 | ### Ignore in public repository only 21 | 22 | ### Ignore in private repository only 23 | ###### Only exists in private repository 24 | store_listing/screenshots/drafts/* 25 | ######### No value to store, intermediary files 26 | experiments/**/*.crx 27 | experiments/**/*.zip 28 | experiments/**/*.xpi 29 | 30 | 31 | 32 | ### Keep in public repository only 33 | !releases/*.crx 34 | 35 | 36 | ### Private credentials, for no repository whatsoever 37 | credentials/* 38 | -------------------------------------------------------------------------------- /sources/unused/save_usage_telemetry.js: -------------------------------------------------------------------------------- 1 | function saveUsageTelemetry() 2 | { 3 | // likely new design 4 | if (prefillGoogleForms()) 5 | { 6 | extensionUser = extractExtensionUserFromBodyScript() 7 | 8 | if (! extensionUser) 9 | // likely old design desktop 10 | extensionUser=extractExtensionUserFromInitData() 11 | 12 | else if (! extensionUser) 13 | extensionUser="" 14 | } 15 | else 16 | extensionUser="" 17 | 18 | if(chrome.storage) if (chrome.storage.sync) 19 | { 20 | chrome.storage.sync.set( 21 | { 22 | extensionVersionUponUninstall: 23 | getExtensionVersion(), 24 | extensionUserScreenname: 25 | extensionUser, 26 | latestBrowserVersion: 27 | getBrowserVersionConcise() 28 | }, 29 | function() { 30 | }); 31 | 32 | chrome.runtime.sendMessage({updateTelemetry: "Request"}, function(response) { 33 | }); 34 | } 35 | } 36 | 37 | saveUsageTelemetry(); 38 | -------------------------------------------------------------------------------- /metadata/manifest.template.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | 4 | "name.channel_dependent": { 5 | "stable": "MetaBot for Twitter", 6 | "early": "MetaBot for Twitter (Early Access)" 7 | }, 8 | 9 | "version": "VVVVVV", 10 | "description": "Highlight known Kremlin bots on Twitter.", 11 | "author": "antibot4navalny", 12 | 13 | // Chrome, _public_ self-hosted releases 14 | "update_url.channel_dependent": { 15 | "stable": "https://raw.githubusercontent.com/antibot4navalny/metabot/master/updates.xml" 16 | }, 17 | 18 | "content_scripts": [ 19 | { 20 | "matches": [ 21 | "https://*.twitter.com/*", 22 | "https://*.x.com/*" 23 | ], 24 | "js": [ 25 | "metabot.js"], 26 | "run_at": "document_idle" 27 | } 28 | ], 29 | 30 | "background": { 31 | "page": "background.html", 32 | "persistent": false 33 | }, 34 | 35 | "web_accessible_resources": [ 36 | "assets/labels.json" 37 | ], 38 | 39 | "permissions": [ 40 | "storage", 41 | "alarms" 42 | ], 43 | 44 | // Firefox only 45 | "browser_specific_settings": { 46 | "gecko": { 47 | "id.channel_dependent": { 48 | "stable": "{d20acea6-2744-4763-bb1c-f62924e40073}", 49 | "early": "{b4ab8398-6a60-41a2-91e2-09eb79a5d153}" 50 | }, 51 | "strict_min_version": "66.0" 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /sources/hot-reload.js: -------------------------------------------------------------------------------- 1 | // source: https://github.com/xpl/crx-hotreload 2 | 3 | 4 | const filesInDirectory = dir => new Promise (resolve => 5 | 6 | dir.createReader ().readEntries (entries => 7 | 8 | Promise.all (entries.filter (e => e.name[0] !== '.').map (e => 9 | 10 | e.isDirectory 11 | ? filesInDirectory (e) 12 | : new Promise (resolve => e.file (resolve)) 13 | )) 14 | .then (files => [].concat (...files)) 15 | .then (resolve) 16 | ) 17 | ) 18 | 19 | const timestampForFilesInDirectory = dir => 20 | filesInDirectory (dir).then (files => 21 | files.map (f => f.name + f.lastModifiedDate).join ()) 22 | 23 | const reload = () => { 24 | 25 | chrome.tabs.query ({ active: true, currentWindow: true }, tabs => { // NB: see https://github.com/xpl/crx-hotreload/issues/5 26 | 27 | if (tabs[0]) { chrome.tabs.reload (tabs[0].id) } 28 | 29 | chrome.runtime.reload () 30 | }) 31 | } 32 | 33 | const watchChanges = (dir, lastTimestamp) => { 34 | 35 | timestampForFilesInDirectory (dir).then (timestamp => { 36 | 37 | if (!lastTimestamp || (lastTimestamp === timestamp)) { 38 | 39 | setTimeout (() => watchChanges (dir, timestamp), 1000) // retry after 1s 40 | 41 | } else { 42 | 43 | reload () 44 | } 45 | }) 46 | 47 | } 48 | 49 | chrome.management.getSelf (self => { 50 | 51 | if (self.installType === 'development') { 52 | 53 | if(chrome.runtime.getPackageDirectoryEntry) 54 | chrome.runtime.getPackageDirectoryEntry (dir => watchChanges (dir)) 55 | } 56 | }) 57 | -------------------------------------------------------------------------------- /metadata/manifest3.template.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | 4 | "name.channel_dependent": { 5 | "stable": "MetaBot for Twitter", 6 | "early": "MetaBot for Twitter (Early Access)" 7 | }, 8 | 9 | "version": "VVVVVV", 10 | "description": "Highlight known Kremlin bots on Twitter.", 11 | "author": "antibot4navalny", 12 | 13 | // Chrome, _public_ self-hosted releases 14 | "update_url.channel_dependent": { 15 | "stable": "https://raw.githubusercontent.com/antibot4navalny/metabot/master/updates.xml" 16 | }, 17 | 18 | "content_scripts": [ 19 | { 20 | "matches": [ 21 | "https://*.twitter.com/*", 22 | "https://*.x.com/*" 23 | ], 24 | "js": [ 25 | "metabot.js"], 26 | "run_at": "document_idle" 27 | } 28 | ], 29 | 30 | "host_permissions": [ 31 | "https://api.github.com/" 32 | ], 33 | 34 | "background": { 35 | // Firefox only: 36 | "page": "background.html", 37 | 38 | // Chrome only: 39 | "service_worker": "background.js", 40 | "type": "module" 41 | }, 42 | 43 | "permissions": [ 44 | "storage", 45 | "alarms" 46 | ], 47 | 48 | // Firefox only 49 | "browser_specific_settings": { 50 | "gecko": { 51 | "id.channel_dependent": { 52 | "stable": "{d20acea6-2744-4763-bb1c-f62924e40073}", 53 | "early": "{b4ab8398-6a60-41a2-91e2-09eb79a5d153}" 54 | }, 55 | "strict_min_version": "66.0" 56 | } 57 | }, 58 | 59 | "web_accessible_resources": [ 60 | { 61 | "resources": ["assets/labels.json", "common_impex.js"], 62 | "matches": [ 63 | "https://*.twitter.com/*", 64 | "https://*.x.com/*" 65 | ] 66 | } 67 | ] 68 | } 69 | -------------------------------------------------------------------------------- /docs/dev_setup.md: -------------------------------------------------------------------------------- 1 | # Installing tools 2 | 3 | ## Node.js 4 | 5 | `npm install --global ` 6 | 7 | -- allows to run `tool` from any location without specifying path to it; after installing in terminal, open a new tab to start using the `tool` without specifying path to it. 8 | 9 | Applies at least to: 10 | - [`web-ext`](https://extensionworkshop.com/documentation/develop/getting-started-with-web-ext/#installation-section) 11 | - [`json5`](https://json5.org/) 12 | - [`chrome-webstore-upload`](https://github.com/fregante/chrome-webstore-upload-cli) 13 | 14 | 15 | # Files hierarchy 16 | - `package.json`: list of `node.js` tools used, along with version numbers expected 17 | - `releases/`: ready-to-use binaries and pre-packaged browser-specific source file-sets; only files generated from sources belong here 18 | 19 | 20 | # Configuring browsers for development environment 21 | 22 | ## Firefox - Manifest V3 23 | 24 | 1. For installing unsigned extensions, use only `Developer Edition` 25 | 26 | 2. Ensure `about:flags`: 27 | - `extensions.manifestV3.enabled` --> `true` (default: `false`) 28 | - `extensions.eventPages.enabled` --> `true` (default: `false`) 29 | - `xpinstall.signatures.required` --> `false` (default: `true`) 30 | 31 | Not necessary to enable: 32 | 33 | - `extensions.backgroundServiceWorker.enabled` (default: `false`) 34 | 35 | 36 | 3. To properly install MV3 extension, manually grant permissions to access data on sites. If not, `Debug` > `Sources` won't display content scripts code: 37 | - `about:addons` 38 | - "Metabot" > [...] on right side in the list 39 | - `Manage` > `Permissions` > enable all switches: 40 | - `Access your data for (URL / mask)` 41 | -------------------------------------------------------------------------------- /sources/unused/Random_numbers.jq: -------------------------------------------------------------------------------- 1 | # 15-bit integers generated using the same formula as rand() from the Microsoft C Runtime. 2 | # The random numbers are in [0 -- 32767] inclusive. 3 | # Input: an array of length at least 2 interpreted as [count, state, ...] 4 | # Output: [count+1, newstate, r] where r is the next pseudo-random number. 5 | def next_rand_Microsoft: 6 | .[0] as $count | .[1] as $state 7 | | ( (214013 * $state) + 2531011) % 2147483648 # mod 2^31 8 | | [$count+1 , ., (. / 65536 | floor) ] ; 9 | 10 | # Generate a single number following the normal distribution with mean 0, variance 1, 11 | # using the Box-Muller method: X = sqrt(-2 ln U) * cos(2 pi V) where U and V are uniform on [0,1]. 12 | # Input: [n, state] 13 | # Output [n+1, nextstate, r] 14 | def next_rand_normal: 15 | def u: next_rand_Microsoft | .[2] /= 32767; 16 | u as $u1 17 | | ($u1 | u) as $u2 18 | | ((( (8*(1|atan)) * $u1[2]) | cos) 19 | * ((-2 * (($u2[2]) | log)) | sqrt)) as $r 20 | | [ (.[0]+1), $u2[1], $r] ; 21 | 22 | # Generate "count" arrays, each containing a random normal variate with the given mean and standard deviation. 23 | # Input: [count, state] 24 | # Output: [updatedcount, updatedstate, rnv] 25 | # where "state" is a seed and "updatedstate" can be used as a seed. 26 | def random_normal_variate(mean; sd; count): 27 | next_rand_normal 28 | | recurse( if .[0] < count then next_rand_normal else empty end) 29 | | .[2] = (.[2] * sd) + mean; 30 | 31 | def summary: 32 | length as $l | add as $sum | ($sum/$l) as $a 33 | | reduce .[] as $x (0; . + ( ($x - $a) | .*. )) 34 | | [ $a, (./$l | sqrt)] ; 35 | 36 | [ [0,1] | random_normal_variate(1; 0.5; 1000) | .[2] ] | summary 37 | -------------------------------------------------------------------------------- /sources/unused/background.js: -------------------------------------------------------------------------------- 1 | function fillUninstallURL(installationTimestamp, browserVersionUponInstall, extensionVersionUponInstall, extensionUserScreenname) { 2 | 3 | return `https://docs.google.com/forms/d/e/1FAIpQLScwRaVyTZOSNyCjt483BQC4TfH38ZfPVfgvYDhqiLYZmcNbPg/viewform?usp=pp_url&entry.1813334385=${extensionUserScreenname}&entry.1669520664=${installationTimestamp}&entry.1000353045=${extensionVersionUponInstall}&entry.1265234860=${browserVersionUponInstall}` 4 | 5 | // '&entry.600723940=${extensionVersionUponUninstall}'+ 6 | // '&entry.318989038=${latestBrowserVersion}' 7 | } 8 | 9 | 10 | 11 | function updateUninstallUrlFromStorage() 12 | { 13 | chrome.storage.sync.get([ 14 | 'installationTimestamp', 15 | 'extensionVersionUponInstall', 16 | 'browserVersionUponInstall', 17 | 'extensionUserScreenname', 18 | 'extensionVersionUponUninstall', 19 | 'latestBrowserVersion' 20 | ], 21 | function(result) { 22 | 23 | 24 | uninstallUrlLink = fillUninstallURL( 25 | result.installationTimestamp, 26 | result.browserVersionUponInstall, 27 | result.extensionVersionUponInstall, 28 | result.extensionUserScreenname ) 29 | 30 | 31 | /* If Chrome version supports it... */ 32 | if (chrome.runtime.setUninstallURL) 33 | { 34 | chrome.runtime.setUninstallURL(uninstallUrlLink) 35 | console.log("updateFromStorage: Set UninstallURL for Chrome" /* + ": "+uninstallUrlLink*/ ) 36 | } 37 | else if(browser.runtime.setUninstallURL) 38 | // otherwise try Firefox method 39 | { 40 | browser.runtime.setUninstallURL(uninstallUrlLink) 41 | console.log("updateFromStorage: Set UninstallURL for Firefox" /* + ": "+uninstallUrlLink*/ ) 42 | } else 43 | console.log("updateFromStorage: Failed to set UninstallURL") 44 | 45 | console.log("updateFromStorage: URL Length: ", uninstallUrlLink.length) 46 | }); 47 | } 48 | 49 | 50 | /* Check whether new version is installed */ 51 | chrome.runtime.onInstalled.addListener(function(details) { 52 | 53 | /* other 'reason's include 'update' */ 54 | 55 | if (details.reason == "install") { 56 | 57 | /* If first install, save initial telemetry data, uninstall URL */ 58 | 59 | chrome.storage.sync.set( 60 | { 61 | installationTimestamp: currentDateTime(), 62 | browserVersionUponInstall: getBrowserVersionConcise(), 63 | extensionVersionUponInstall: getExtensionVersion(), 64 | }, function() {}); 65 | 66 | updateUninstallUrlFromStorage() 67 | } 68 | }); 69 | 70 | chrome.runtime.onMessage.addListener( 71 | function(request, sender, sendResponse) { 72 | if (request.updateTelemetry) 73 | { 74 | updateUninstallUrlFromStorage() 75 | outcome="Updating" 76 | sendResponse({updateResult: outcome}) 77 | } 78 | }); 79 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Подсветка кремлеботов в Twitter (расширение для браузера) 2 | 3 | Подсвечивает твиты, предположительно написанные кремлеботами. 4 | 5 | См. [подробные доказательства](https://twitter.com/antibot4navalny/status/1658713744665374722), что подсвечиваются именно поддельные аккаунты, действующие в пользу Кремля. 6 | 7 | 8 | 9 | 10 | ## Установка 11 | 12 | ### Firefox (работает на компьютере и на смартфонах Android) 13 | 1. Только для смартфонов: 14 | - установите [Firefox Beta](https://play.google.com/store/apps/details?id=org.mozilla.firefox_beta), 15 | либо [Nightly for Developers](https://play.google.com/store/apps/details?id=org.mozilla.fenix) 16 | - выполните [эти инструкции](https://support.mozilla.org/en-US/kb/extended-add-support) 17 | 2. Установите из магазина расширений (Firefox Addons): 18 | https://addons.mozilla.org/en-US/firefox/addon/metabot-for-twitter/ 19 | 3. [Поддержите](#поддержите-нашу-работу) нашу каждодневную работу по пополнению списка кремлеботов. 20 | 21 | 22 | ### Google Chrome (работает только на компьютере) 23 | 1. Установите из магазина расширений (Chrome Store): 24 | https://chromewebstore.google.com/detail/metabot-for-twitter/cooadmmiojjmmfifkcainbnmhghfcbfi 25 | 2. [Поддержите](#поддержите-нашу-работу) нашу каждодневную работу по пополнению списка кремлеботов. 26 | 27 | 28 | ### [Kiwi](https://play.google.com/store/apps/details?id=com.kiwibrowser.browser) (браузер под Android) 29 | 1. Установите из магазина расширений (Chrome Store): 30 | https://chromewebstore.google.com/detail/metabot-for-twitter/cooadmmiojjmmfifkcainbnmhghfcbfi 31 | 2. [Поддержите](#поддержите-нашу-работу) нашу каждодневную работу по пополнению списка кремлеботов. 32 | 33 | 34 | ### Microsoft Edge (работает только на компьютере; в перспективе [возможен](https://9to5google.com/2024/01/31/microsoft-edge-android-extensions/) Android) 35 | 36 | 1. Откройте страницу Метабота для Google Chrome: 37 | https://chromewebstore.google.com/detail/metabot-for-twitter/cooadmmiojjmmfifkcainbnmhghfcbfi 38 | 3. Нажмите сверху "Получить расширение" (Get extension) 39 | 4. В появившемся окне нажмите "Добавить расширение" (Add extension) 40 | 5. [Поддержите](#поддержите-нашу-работу) нашу каждодневную работу по пополнению списка кремлеботов. 41 | 42 | 43 | ### Opera (работает только на компьютере) 44 | 1. Откройте страницу Метабота для Google Chrome: 45 | https://chromewebstore.google.com/detail/metabot-for-twitter/cooadmmiojjmmfifkcainbnmhghfcbfi 46 | 2. Нажмите "Добавить в Opera" (Add to Opera) 47 | 3. В появившемся окне нажмите "Добавить расширение" (Add extension) 48 | 4. [Поддержите](#поддержите-нашу-работу) нашу каждодневную работу по пополнению списка кремлеботов. 49 | 50 | 51 | ### TOR (работает только на компьютере) 52 | 1. Установите из магазина расширений Firefox Addons: 53 | https://addons.mozilla.org/en-US/firefox/addon/metabot-for-twitter/ 54 | 2. [Поддержите](#поддержите-нашу-работу) нашу каждодневную работу по пополнению списка кремлеботов. 55 | 56 | 57 | ## Поддержите нашу работу 58 | 59 | Если вы считаете нашу работу важной, пожалуйста поддержите нас донатами в крипто-валюте: 60 | - BTC: 12MoEGwmEaQUWPK1GHJvrypYGKwxiPREKP 61 | - ETH: 0x96e25dc68a0379300e1142a7351e546d170671f6 62 | 63 | ---- 64 | Создано на базе и благодаря расширению [Metabot для Youtube](https://github.com/CupIvan/metabot) (c) [CupIvan](https://github.com/CupIvan). 65 | -------------------------------------------------------------------------------- /docs/browser_specifics.md: -------------------------------------------------------------------------------- 1 | # Extension ID values 2 | 3 | ## Chrome, extension IDs per Chrome Web Store: 4 | 5 | ### Stable channel 6 | `cooadmmiojjmmfifkcainbnmhghfcbfi` 7 | 8 | ### Early Access channel 9 | `hpenphinnajgmlhlofjgnlhhlibkheng` 10 | 11 | 12 | ## Chrome / Opera, self-hosted (.CRX) 13 | Extension ID in `updates.xml` is currenly used only for stable, self-distributed binaries. 14 | 15 | 16 | ### Stable 17 | `jmdlhgbmheopjecmdfanbhfbghmhgemc` 18 | 19 | 20 | ### Early-Access 21 | `lafccnodoaiaieappkhanollndekfodd` 22 | 23 | 24 | ## Firefox, extension IDs per Mozilla Add-Ons store: 25 | 26 | ### Stable: 27 | `d20acea6-2744-4763-bb1c-f62924e40073` 28 | 29 | ### Early Access (self-distributed): 30 | `b4ab8398-6a60-41a2-91e2-09eb79a5d153` 31 | 32 | (previously: `bots_commenting_navalny@protonmail.com`) 33 | 34 | 35 | # ExtensionID assignment / persistence 36 | 37 | ## Chrome - self-distributed 38 | For `update.xml`, [extension ID is generated based on a hash of the public key .PEM](https://developer.chrome.com/docs/apps/autoupdate/#:~:text=The%20extension%20or%20app%20ID%2C%20generated%20based%20on%20a%20hash%20of%20the%20public%20key). 39 | 40 | 41 | `.PEM` is used to ensure updated `.CRX` is signed with the same key as earlier version of self-hosted extension. 42 | 43 | From my experiments, the same ID is generated when: 44 | - manually Packing Extension with Chrome 45 | - packing with `CRX` command line utility 46 | 47 | 48 | ## Firefox 49 | Generated and assigned: 50 | - upon manual upload to AMO => will be embedded in the signed packaged extension 51 | - [signing with `web-ext`/API](https://addons-server.readthedocs.io/en/latest/topics/api/signing.html#uploading-without-an-id) with no UUID specified 52 | 53 | 54 | 55 | Add-on ID is reserved forever and even if add-on is deleted, ID will forever be unusable for submission => assign wisely. 56 | 57 | 58 | 59 | # Self-hosted viability / installation 60 | ## Chrome 61 | MacOS: seems installed, but can not be enabled, with "This extension is not listed in the Chrome Web Store and may have been added without your knowledge" message. Support article [implies](https://support.google.com/chrome_webstore/answer/2811969#:~:text=if%20you%20already%20have%20it%20installed%2C%20to%20manually%20re%2Denable%20it%20from%20your%20list%20of%20extensions) that it can be enabled only if the extension in question is published at Chrome Web Store. 62 | 63 | With Chrome 104.0.5112.101, tried `.crx` files created with: 64 | 65 | - `crx` utility 66 | - exactly that Chrome browser that I tried installing to 67 | 68 | (to install, drag-n-dropped onto `chrome://extensions/` tab with pre-enabled `Developer mode`). 69 | 70 | 71 | Windows: 72 | > Since M33, user can not install self-hosted extensions. 73 | 74 | ## Opera 75 | Installs self-signed CRXs just fine, only need to confirm (as of Opera 90.0.4480.54). 76 | 77 | ## Firefox 78 | [Menu > Add-ons and themes](about:addons) > ️⚙ > Debug Addons > Load Temporary Add-On > 79 | - either open `manifest.json` inside unpacked build 80 | - or open `.zip` with Firefox-specific build 81 | 82 | # Browser-specific files / file sections 83 | 84 | ## `updates.xml` / `updates.json` 85 | 86 | 87 | Makes only sense for public releases; doesn't make for restricted-access early-adopter versions (therefore exists only for stable channel). 88 | 89 | Not part of extension package; should be hosted alongside self-distributed extension package. 90 | 91 | Currenly published via GitHub commits only. 92 | 93 | Updates to self-hosted extensions are provided via: 94 | 95 | - `updates.xml` -- [Chrome/Opera only](https://developer.chrome.com/docs/apps/autoupdate/#update_manifest) 96 | 97 | - `updates.json` -- [Firefox-only](https://extensionworkshop.com/documentation/manage/updating-your-extension/) (I'm not using it) 98 | 99 | 100 | ## `manifest.json` 101 | ### `update_url` 102 | Chrome-only URL to web-hosted [`update.xml`]((https://developer.chrome.com/docs/apps/autoupdate/#update_manifest) 103 | ) for self-hosted distribution. 104 | 105 | ### `browser_specific_settings.gecko.update_url` 106 | Firefox-only URL to web-hosted [`update.json`]((https://extensionworkshop.com/documentation/manage/updating-your-extension/)) for self-hosted distribution. 107 | 108 | 109 | ### `browser_specific_settings.id` 110 | 111 | [Mandatory](https://extensionworkshop.com/documentation/develop/extensions-and-the-add-on-id/#when-do-you-need-an-add-on-id): 112 | - for all Manifest V3 extensions when submitted to AMO 113 | - [if the extension is unsigned](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/browser_specific_settings#firefox_gecko_properties) (and _not_ loaded via `about:debugging`) 114 | 115 | Optional otherwise: the extension ID is derived from the extension's signature. 116 | 117 | 118 | ### `.web-extension-id` 119 | [Saved by `web-ext`](https://extensionworkshop.com/documentation/develop/getting-started-with-web-ext/#:~:text=Signing%20extensions%20without%20an%20explicit%20ID) the first time extension is signed without an explicit ID. 120 | 121 | In `Metabot`, I'll aim to avoid its creation / saving / packaging; if it re-appears, I'd better auto-delete it--than ignore, exclude, be distracted with. 122 | -------------------------------------------------------------------------------- /sources/background.js: -------------------------------------------------------------------------------- 1 | const legacyLabels = { 2 | storageSuffix: "Legacy", 3 | URL: "https://api.github.com/repos/antibot4navalny/accounts_labelled/contents/labels.json", 4 | period: 3 * 60 5 | }; 6 | 7 | const manualLabels = { 8 | storageSuffix: "Manual", 9 | URL: "https://api.github.com/repos/antibot4navalny/accounts_labelled/contents/labels_manual.json", 10 | period: 15 11 | }; 12 | 13 | 14 | import { retrieveItemFromStorage } from './common_impex.js'; 15 | 16 | chrome.alarms.create("updateLabels", {periodInMinutes: 1}) 17 | 18 | 19 | function getStorageDataPromise(sKey) { 20 | return new Promise(function(resolve, reject) { 21 | chrome.storage.local.get(sKey, function(items) { 22 | if (chrome.runtime.lastError) { 23 | console.error(chrome.runtime.lastError.message); 24 | reject(chrome.runtime.lastError.message); 25 | } else { 26 | resolve(items[sKey]); 27 | } 28 | }); 29 | }); 30 | } 31 | 32 | function storeLastTimeUpdateChecked(labelSuffix, timeChecked) 33 | { 34 | const timeCheckedJsoned = timeChecked.toJSON() 35 | chrome.storage.local.set( 36 | { 37 | ["labelsLastTimeUpdateChecked" + labelSuffix]: timeCheckedJsoned 38 | }, function() { 39 | }); 40 | } 41 | 42 | async function retrieveDateByKey(key) 43 | { 44 | const retrievedDate = await retrieveItemFromStorage(key, 0); 45 | const dateConvertedTime = new Date(retrievedDate); 46 | return(dateConvertedTime); 47 | } 48 | 49 | 50 | async function retrieveLastTimeUpdateChecked(labelSuffix) 51 | { 52 | const retrievedDate = retrieveDateByKey( 53 | 'labelsLastTimeUpdateChecked' + labelSuffix); 54 | return retrievedDate; 55 | } 56 | 57 | async function retrieveLabelsLastModified(labelSuffix) 58 | { 59 | const lastModified = retrieveDateByKey( 60 | 'labelsLastModified' + labelSuffix) 61 | return lastModified; 62 | } 63 | 64 | function storeJSON(labelSuffix, json, lastModified) 65 | { 66 | 67 | // Any other key/value pairs in storage will not be affected. 68 | chrome.storage.local.set( 69 | { 70 | ["webHostedLabels" + labelSuffix]: json, 71 | ["labelsLastModified" + labelSuffix]: lastModified.toJSON() 72 | }, 73 | function() { 74 | //// console.log("storeJSON: Done updating JSON") 75 | } 76 | ); 77 | } 78 | 79 | 80 | var GitHubApiRateLimitRemaining; 81 | var GitHubApiRateLimitReset; 82 | 83 | 84 | function reportJsonUpdateFailed(reason) 85 | { 86 | console.log("Labels update failed, reason: ", reason) 87 | } 88 | 89 | async function fetchLabelsUpdateIfAvailable(labelsType) 90 | { 91 | const suffix = labelsType.storageSuffix 92 | var JsonLastModifiedDate; 93 | var JsonLastModifiedRaw; 94 | var JsonLastModifiedEpoch; 95 | 96 | if ((typeof GitHubApiRateLimitReset !== 'undefined') && 97 | (typeof GitHubApiRateLimitRemaining !== 'undefined') && 98 | (Date().now < GitHubApiRateLimitReset) && 99 | (GitHubApiRateLimitRemaining == 0) 100 | ) 101 | { 102 | /// Just log it to console and keep trying nicely. 103 | reportJsonUpdateFailed('ApiRateLimitExceeded, waiting for limit reset') 104 | } else { 105 | const labelsLastModified=await retrieveLabelsLastModified(suffix); 106 | const lastTimeUpdateChecked=new Date(); 107 | 108 | const response = await fetch(labelsType.URL, { 109 | method: 'GET', 110 | headers: { 111 | 'If-Modified-Since': labelsLastModified.toUTCString() 112 | } 113 | }); 114 | storeLastTimeUpdateChecked( 115 | suffix, lastTimeUpdateChecked); 116 | 117 | const ok = response.ok; 118 | const status = response.status; 119 | 120 | GitHubApiRateLimitRemaining = response.headers.get("x-ratelimit-remaining"); 121 | GitHubApiRateLimitReset = new Date(1000*response.headers.get("x-ratelimit-reset")); 122 | console.log(suffix + " GitHub API rate limit remaining: ", GitHubApiRateLimitRemaining) 123 | console.log(suffix + " GitHub API rate limit reset:\n ", 124 | GitHubApiRateLimitReset); 125 | 126 | if (status == 304) 127 | { 128 | console.log( 129 | "fetchLabelsUpdateIfAvailable: " + suffix + 130 | ": No JSON update for If-Modified-Since:\n ", 131 | labelsLastModified); 132 | } else if (status==200) { 133 | JsonLastModifiedRaw=response.headers.get("Last-Modified") 134 | JsonLastModifiedDate = new Date(JsonLastModifiedRaw); 135 | 136 | const rawJSON = await response.json(); 137 | const payloadJson=JSON.parse(atob(rawJSON.content)); 138 | 139 | storeJSON(suffix, payloadJson, JsonLastModifiedDate); 140 | console.log( 141 | "fetchLabelsUpdateIfAvailable: " + suffix + 142 | ": Fetched JSON updated at:\n ", 143 | JsonLastModifiedDate); 144 | 145 | } else { 146 | reportJsonUpdateFailed("HTTP status "+status) 147 | } 148 | } 149 | } 150 | 151 | function isValidDate(d) { 152 | return d instanceof Date && !isNaN(d); 153 | } 154 | 155 | 156 | async function considerRefreshingJSON(labelsType) 157 | { 158 | const suffix = labelsType.storageSuffix; 159 | var lastTimeUpdateChecked; 160 | 161 | lastTimeUpdateChecked=await retrieveLastTimeUpdateChecked(suffix); 162 | 163 | var currentTime=new Date(); 164 | const timeSinceLastChecked = new Date(currentTime - lastTimeUpdateChecked); 165 | const timeSinceLastCheckedStr = timeSinceLastChecked.toUTCString().split(' ')[4]; 166 | 167 | console.log('considerRefreshingJSON: ', 168 | timeSinceLastCheckedStr, ' since last update of labels: ' + suffix); 169 | 170 | //// Get latest JSON every 15 minutes 171 | if ((typeof lastTimeUpdateChecked === undefined) || 172 | ! isValidDate(lastTimeUpdateChecked) || 173 | //// Production timeouts: 174 | (((currentTime - lastTimeUpdateChecked) / (1000*60)) > labelsType.period)) 175 | //// Debug timeouts: 176 | //(((currentTime - lastTimeUpdateChecked) / (1000)) > 15)) 177 | { 178 | console.log("considerRefreshingJSON: Time to check JSON for updates: " + suffix); 179 | 180 | fetchLabelsUpdateIfAvailable(labelsType) 181 | } else { 182 | console.log("considerRefreshingJSON: Too early to check for JSON updates: " + suffix) 183 | } 184 | } 185 | 186 | function considerRefreshingAllJSONs() 187 | { 188 | considerRefreshingJSON(legacyLabels) 189 | considerRefreshingJSON(manualLabels) 190 | } 191 | 192 | 193 | /* Check whether new version is installed / updated */ 194 | chrome.runtime.onInstalled.addListener(function(details) { 195 | considerRefreshingAllJSONs() 196 | }); 197 | 198 | 199 | chrome.alarms.onAlarm.addListener(function(alarm) { 200 | if (alarm.name === 'updateLabels') 201 | considerRefreshingAllJSONs(); 202 | }); 203 | 204 | 205 | considerRefreshingAllJSONs(); 206 | -------------------------------------------------------------------------------- /sources/metabot.js: -------------------------------------------------------------------------------- 1 | var screennameRegex="[A-Za-z0-9_]+" 2 | 3 | 4 | async function commonImporter() 5 | { 6 | return await import((chrome.runtime.getURL || chrome.extension.getURL)("common_impex.js")); 7 | } 8 | 9 | var prepackaged_labels={}; 10 | 11 | var webHostedLabelsCached = {}; 12 | 13 | var webHostedLabelsCachedComponents = { legacy: {}, manual: {} } 14 | 15 | async function retrieveLabelsFromStorage() 16 | { 17 | webHostedLabelsCachedComponents.legacy = Object.assign({}, 18 | await ( 19 | await commonImporter()).retrieveItemFromStorage( 20 | 'webHostedLabelsLegacy', {}) 21 | ); 22 | 23 | webHostedLabelsCachedComponents.manual = Object.assign({}, 24 | await ( 25 | await commonImporter()).retrieveItemFromStorage( 26 | 'webHostedLabelsManual', {}) 27 | ); 28 | 29 | 30 | let retrievedLabels = { 31 | ...webHostedLabelsCachedComponents.legacy, 32 | ...webHostedLabelsCachedComponents.manual 33 | }; 34 | 35 | return retrievedLabels; 36 | } 37 | 38 | 39 | async function initializeCachedLabelsFromStorage() 40 | { 41 | const labelsFromStorage = await retrieveLabelsFromStorage(); 42 | webHostedLabelsCached = Object.assign({}, labelsFromStorage); 43 | } 44 | 45 | 46 | function checkLabelsComponentChanges(suffix, property, changes) 47 | { 48 | const changedItems = Object.keys(changes); 49 | 50 | if (changedItems.includes('webHostedLabels' + suffix)) 51 | { 52 | console.log("Local changes in Labels " + suffix + ":"); 53 | 54 | // const newLabels = await retrieveWebHostedLabels(); 55 | const newLabels = changes['webHostedLabels' + suffix].newValue; 56 | 57 | webHostedLabelsCachedComponents[property] = Object.assign({}, newLabels); 58 | 59 | webHostedLabelsCached = Object.assign({}, 60 | webHostedLabelsCachedComponents.legacy, 61 | webHostedLabelsCachedComponents.manual 62 | ); 63 | } 64 | } 65 | 66 | 67 | async function updateCachedLabelsOnStorageChange(changes, area) { 68 | 69 | if (area == "local") 70 | { 71 | checkLabelsComponentChanges("Legacy", "legacy", changes) 72 | checkLabelsComponentChanges("Manual", "manual", changes) 73 | } 74 | 75 | //// Debug-only output: 76 | // for (const item of changedItems) { 77 | // console.log(`${item} has changed:`); 78 | // console.log("Old value: ", changes[item].oldValue); 79 | // console.log("New value: ", changes[item].newValue); 80 | // } 81 | } 82 | 83 | chrome.storage.onChanged.addListener(updateCachedLabelsOnStorageChange); 84 | 85 | 86 | 87 | async function markUponStorageReady() 88 | { 89 | await initializeCachedLabelsFromStorage(); 90 | ///// console.log("Storage ready, proceeding to marking tweets") 91 | 92 | markTweets(); 93 | } 94 | 95 | 96 | 97 | function loadPrepackagedLabels() 98 | { 99 | 100 | var rUrl = chrome.runtime.getURL('assets/labels.json'); 101 | 102 | fetch(rUrl).then((response) => { 103 | return response.json(); 104 | }) 105 | .then((fileContent) => { 106 | prepackaged_labels=fileContent; 107 | } 108 | ) 109 | .catch((cause) => console.log(cause)); 110 | } 111 | 112 | 113 | function setStyle(divElement, style) 114 | { 115 | divElement.innerHTML = '' 116 | } 117 | 118 | function addStyle(s) 119 | { 120 | var d = document.createElement('div') 121 | 122 | if(s) setStyle(d,s) 123 | 124 | return document.body.appendChild(d) 125 | } 126 | 127 | 128 | 129 | // добавляем стили для пометки ботов 130 | 131 | // reduced-contrast style for tweet bodies posted by bots 132 | s = '.bot_tweet_highlight .bot_text { color: #808080; }' 133 | addStyle(s) 134 | 135 | 136 | // стиль для пометки красноватым фоном твитов от ботов 137 | tweetBackgroundStyle=addStyle() 138 | 139 | function defineTweetBackgroundStyle() 140 | { 141 | // dark mode: 142 | // light mode: 143 | dark_mode = ! (document.querySelector( 144 | ":root > head > meta[name=theme-color]") 145 | .getAttribute("content").toUpperCase() == "#FFFFFF") 146 | 147 | if (dark_mode) 148 | var s = '.bot_tweet_highlight { background: #4b3333 !important; }' // dark 149 | else 150 | var s = '.bot_tweet_highlight { background: #FEE !important; }' // light 151 | 152 | setStyle(tweetBackgroundStyle, s) 153 | } 154 | 155 | 156 | 157 | function normalizedPathname() 158 | { 159 | // normalize '/username/[with_replies]' to 'username' 160 | loc=document.location 161 | return (loc.pathname+loc.search).substring(1).replace("/with_replies","") 162 | } 163 | 164 | function isStatusView() 165 | { 166 | path=document.location.pathname 167 | return path.includes("/status/") && 168 | ! path.startsWith("/search") 169 | } 170 | 171 | function isSearchResults() 172 | { 173 | path=document.location.pathname 174 | return path.startsWith("/search") && 175 | document.location.search != "" 176 | } 177 | 178 | function isProfileView() 179 | { 180 | pathWithRepliesRemoved=normalizedPathname() 181 | userNameMatch=pathWithRepliesRemoved 182 | .match(screennameRegex) 183 | 184 | return (document.location.search=="") && 185 | (! pathWithRepliesRemoved.includes("/")) && 186 | (userNameMatch) && 187 | (userNameMatch[0]==pathWithRepliesRemoved) 188 | } 189 | 190 | 191 | 192 | 193 | function markTweets() 194 | { 195 | if (isStatusView() || isSearchResults || isProfileView() ) 196 | { 197 | defineTweetBackgroundStyle() 198 | 199 | var a=document.querySelectorAll('article[role=article]'); 200 | // In conversation view, works both for focused tweet and 201 | // for parent / child replies of the focused tweet. 202 | 203 | highlight_tweets=isStatusView() || isSearchResults 204 | 205 | 206 | var i, x, t, linksInsideTweet 207 | 208 | for (i = 0; i < a.length; i++) 209 | 210 | // process only tweets not processed in earlier passes 211 | if (!a[i].dataset.mt_is_upd) 212 | { 213 | t = a[i] 214 | // But don't mark it as processed if it contains no link: 215 | // e.g. if tweet is under extra click: 216 | // "Show additional replies" or similar 217 | linksInsideTweet = t.querySelector('a[href]') 218 | 219 | if (!(linksInsideTweet === null)) 220 | { 221 | 222 | x = linksInsideTweet.getAttribute('href').substring(1) 223 | 224 | 225 | isRed = ((webHostedLabelsCached[x]=='red') || 226 | (prepackaged_labels[x]=='red')) 227 | 228 | isYellow = ((webHostedLabelsCached[x]=='yellow') || 229 | (prepackaged_labels[x]=='yellow')) 230 | 231 | if ((isRed || isYellow) && highlight_tweets) 232 | { 233 | label=isRed?"БОТ:" :isYellow?"⚠️":"" 234 | 235 | // highlight all tweets shown on the page 236 | botCaption = document.createElement("span") 237 | botCaption.innerHTML=label+" " 238 | botCaption.style.color = 'red' 239 | 240 | // дописываем "БОТ: " перед именем автора твита 241 | fullname=t.querySelector("span") 242 | 243 | fullname.prepend(botCaption) 244 | 245 | elementToHighlight = t.parentNode 246 | 247 | //// "Highlight tweets only if they are not retweeted-by, 248 | //// no matter who retweeted or who posted the original tweet." 249 | //// 250 | //// In case of retweet, only username of retweeting user 251 | //// is prepended, not username of original tweet's author. 252 | //// 253 | //// How it should ideally work: 254 | //// - instead of parent node, two child nodes should be highlighted: 255 | //// (1) username, 256 | //// (2) tweet body 257 | //// 258 | //// - bot/not bot should be determined based on node always containing 259 | //// username of original tweet's author 260 | //// 261 | //// - it is much more important to highlight when the original 262 | //// tweet's author is bot, not when the account retweeted it is 263 | //// 264 | //// - the above logic will also highlight bot-created pinned tweet 265 | 266 | if (isRed && (elementToHighlight. 267 | //// "Username retweeted" caption above original tweet is empty 268 | querySelector( 269 | ":scope > article > div > div > div > div") 270 | .innerText=="" )) 271 | { 272 | // подсвечиваем весь твит стилем bot_tweet_highlight 273 | elementToHighlight.className+=" bot_tweet_highlight" 274 | 275 | // reduce contrast for tweet text 276 | tweetTextselector = 277 | // - for focused tweet: 278 | ':scope > div > div > span' + ', ' + 279 | // - for parent / child replies of the focused tweet 280 | ':scope > div > div > div > div > span' 281 | 282 | tweetTxts = t.querySelectorAll(tweetTextselector) 283 | tweetTxts.forEach( 284 | function(element) { 285 | element.className='bot_text ' + element.className; 286 | }); 287 | } 288 | } 289 | 290 | // Mark tweet as processed to skip in subsequent passes 291 | t.dataset.mt_is_upd = 1 292 | } 293 | } 294 | } 295 | // repeat every 0.1 seconds 296 | setTimeout(markTweets, 100); 297 | } 298 | 299 | 300 | loadPrepackagedLabels(); 301 | 302 | markUponStorageReady(); 303 | -------------------------------------------------------------------------------- /sources/unused/purify.min.js: -------------------------------------------------------------------------------- 1 | !function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):e.DOMPurify=t()}(this,function(){"use strict";function e(e,t){for(var n=t.length;n--;)"string"==typeof t[n]&&(t[n]=t[n].toLowerCase()),e[t[n]]=!0;return e}function t(e){var t={},n=void 0;for(n in e)Object.prototype.hasOwnProperty.call(e,n)&&(t[n]=e[n]);return t}function n(e){if(Array.isArray(e)){for(var t=0,n=Array(e.length);t0&&void 0!==arguments[0]?arguments[0]:A(),S=function(e){return r(e)};if(S.version="1.0.8",S.removed=[],!x||!x.document||9!==x.document.nodeType)return S.isSupported=!1,S;var k=x.document,w=!1,L=!1,E=x.document,O=x.DocumentFragment,M=x.HTMLTemplateElement,N=x.Node,_=x.NodeFilter,D=x.NamedNodeMap,C=void 0===D?x.NamedNodeMap||x.MozNamedAttrMap:D,R=x.Text,F=x.Comment,z=x.DOMParser;if("function"==typeof M){var H=E.createElement("template");H.content&&H.content.ownerDocument&&(E=H.content.ownerDocument)}var I=E,j=I.implementation,P=I.createNodeIterator,U=I.getElementsByTagName,W=I.createDocumentFragment,B=k.importNode,G={};S.isSupported=j&&void 0!==j.createHTMLDocument&&9!==E.documentMode;var q=f,V=p,Y=h,K=g,X=v,$=b,J=y,Q=null,Z=e({},[].concat(n(o),n(i),n(a),n(l),n(s))),ee=null,te=e({},[].concat(n(c),n(d),n(u),n(m))),ne=null,re=null,oe=!0,ie=!0,ae=!1,le=!1,se=!1,ce=!1,de=!1,ue=!1,me=!1,fe=!1,pe=!1,he=!0,ge=!0,ye=!1,ve={},be=e({},["audio","head","math","script","style","template","svg","video"]),Te=e({},["audio","video","img","source","image"]),Ae=e({},["alt","class","for","id","label","name","pattern","placeholder","summary","title","value","style","xmlns"]),xe=null,Se=E.createElement("form"),ke=function(r){"object"!==(void 0===r?"undefined":T(r))&&(r={}),Q="ALLOWED_TAGS"in r?e({},r.ALLOWED_TAGS):Z,ee="ALLOWED_ATTR"in r?e({},r.ALLOWED_ATTR):te,ne="FORBID_TAGS"in r?e({},r.FORBID_TAGS):{},re="FORBID_ATTR"in r?e({},r.FORBID_ATTR):{},ve="USE_PROFILES"in r&&r.USE_PROFILES,oe=!1!==r.ALLOW_ARIA_ATTR,ie=!1!==r.ALLOW_DATA_ATTR,ae=r.ALLOW_UNKNOWN_PROTOCOLS||!1,le=r.SAFE_FOR_JQUERY||!1,se=r.SAFE_FOR_TEMPLATES||!1,ce=r.WHOLE_DOCUMENT||!1,me=r.RETURN_DOM||!1,fe=r.RETURN_DOM_FRAGMENT||!1,pe=r.RETURN_DOM_IMPORT||!1,ue=r.FORCE_BODY||!1,he=!1!==r.SANITIZE_DOM,ge=!1!==r.KEEP_CONTENT,ye=r.IN_PLACE||!1,J=r.ALLOWED_URI_REGEXP||J,se&&(ie=!1),fe&&(me=!0),ve&&(Q=e({},[].concat(n(s))),ee=[],!0===ve.html&&(e(Q,o),e(ee,c)),!0===ve.svg&&(e(Q,i),e(ee,d),e(ee,m)),!0===ve.svgFilters&&(e(Q,a),e(ee,d),e(ee,m)),!0===ve.mathMl&&(e(Q,l),e(ee,u),e(ee,m))),r.ADD_TAGS&&(Q===Z&&(Q=t(Q)),e(Q,r.ADD_TAGS)),r.ADD_ATTR&&(ee===te&&(ee=t(ee)),e(ee,r.ADD_ATTR)),r.ADD_URI_SAFE_ATTR&&e(Ae,r.ADD_URI_SAFE_ATTR),ge&&(Q["#text"]=!0),ce&&e(Q,["html","head","body"]),Q.table&&e(Q,["tbody"]),Object&&"freeze"in Object&&Object.freeze(r),xe=r},we=function(e){S.removed.push({element:e});try{e.parentNode.removeChild(e)}catch(t){e.outerHTML=""}},Le=function(e,t){try{S.removed.push({attribute:t.getAttributeNode(e),from:t})}catch(e){S.removed.push({attribute:null,from:t})}t.removeAttribute(e)},Ee=function(t){var n=void 0;if(ue&&(t=""+t),w)try{n=(new z).parseFromString(t,"text/html")}catch(e){}if(L&&e(ne,["title"]),!n||!n.documentElement){var r=(n=j.createHTMLDocument("")).body;r.parentNode.removeChild(r.parentNode.firstElementChild),r.outerHTML=t}return U.call(n,ce?"html":"body")[0]};S.isSupported&&(function(){try{Ee('').querySelector("svg img")&&(w=!0)}catch(e){}}(),function(){try{Ee("</title><img>").querySelector("title").textContent.match(/<\/title/)&&(L=!0)}catch(e){}}());var Oe=function(e){return P.call(e.ownerDocument||e,e,_.SHOW_ELEMENT|_.SHOW_COMMENT|_.SHOW_TEXT,function(){return _.FILTER_ACCEPT},!1)},Me=function(e){return!(e instanceof R||e instanceof F)&&!("string"==typeof e.nodeName&&"string"==typeof e.textContent&&"function"==typeof e.removeChild&&e.attributes instanceof C&&"function"==typeof e.removeAttribute&&"function"==typeof e.setAttribute)},Ne=function(e){return"object"===(void 0===N?"undefined":T(N))?e instanceof N:e&&"object"===(void 0===e?"undefined":T(e))&&"number"==typeof e.nodeType&&"string"==typeof e.nodeName},_e=function(e,t,n){G[e]&&G[e].forEach(function(e){e.call(S,t,n,xe)})},De=function(e){var t=void 0;if(_e("beforeSanitizeElements",e,null),Me(e))return we(e),!0;var n=e.nodeName.toLowerCase();if(_e("uponSanitizeElement",e,{tagName:n,allowedTags:Q}),!Q[n]||ne[n]){if(ge&&!be[n]&&"function"==typeof e.insertAdjacentHTML)try{e.insertAdjacentHTML("AfterEnd",e.innerHTML)}catch(e){}return we(e),!0}return!le||e.firstElementChild||e.content&&e.content.firstElementChild||!/i&&e.setAttribute("id",o.value);else{if("INPUT"===e.nodeName&&"type"===r&&"file"===n&&(ee[r]||!re[r]))continue;"id"===c&&e.setAttribute(c,""),Le(c,e)}if(l.keepAttr){var u=e.nodeName.toLowerCase();if(Ce(u,r,n))try{d?e.setAttributeNS(d,c,n):e.setAttribute(c,n),S.removed.pop()}catch(e){}}}_e("afterSanitizeAttributes",e,null)}},Fe=function e(t){var n=void 0,r=Oe(t);for(_e("beforeSanitizeShadowDOM",t,null);n=r.nextNode();)_e("uponSanitizeShadowNode",n,null),De(n)||(n.content instanceof O&&e(n.content),Re(n));_e("afterSanitizeShadowDOM",t,null)};return S.sanitize=function(e,t){var n=void 0,r=void 0,o=void 0,i=void 0,a=void 0;if(e||(e="\x3c!--\x3e"),"string"!=typeof e&&!Ne(e)){if("function"!=typeof e.toString)throw new TypeError("toString is not a function");if("string"!=typeof(e=e.toString()))throw new TypeError("dirty is not a string, aborting")}if(!S.isSupported){if("object"===T(x.toStaticHTML)||"function"==typeof x.toStaticHTML){if("string"==typeof e)return x.toStaticHTML(e);if(Ne(e))return x.toStaticHTML(e.outerHTML)}return e}if(de||ke(t),S.removed=[],ye);else if(e instanceof N)1===(r=(n=Ee("\x3c!--\x3e")).ownerDocument.importNode(e,!0)).nodeType&&"BODY"===r.nodeName?n=r:n.appendChild(r);else{if(!me&&!ce&&-1===e.indexOf("<"))return e;if(!(n=Ee(e)))return me?null:""}n&&ue&&we(n.firstChild);for(var l=Oe(ye?e:n);o=l.nextNode();)3===o.nodeType&&o===i||De(o)||(o.content instanceof O&&Fe(o.content),Re(o),i=o);if(ye)return e;if(me){if(fe)for(a=W.call(n.ownerDocument);n.firstChild;)a.appendChild(n.firstChild);else a=n;return pe&&(a=B.call(k,a,!0)),a}return ce?n.outerHTML:n.innerHTML},S.setConfig=function(e){ke(e),de=!0},S.clearConfig=function(){xe=null,de=!1},S.isValidAttribute=function(e,t,n){xe||ke({});var r=e.toLowerCase(),o=t.toLowerCase();return Ce(r,o,n)},S.addHook=function(e,t){"function"==typeof t&&(G[e]=G[e]||[],G[e].push(t))},S.removeHook=function(e){G[e]&&G[e].pop()},S.removeHooks=function(e){G[e]&&(G[e]=[])},S.removeAllHooks=function(){G={}},S}var o=["a","abbr","acronym","address","area","article","aside","audio","b","bdi","bdo","big","blink","blockquote","body","br","button","canvas","caption","center","cite","code","col","colgroup","content","data","datalist","dd","decorator","del","details","dfn","dir","div","dl","dt","element","em","fieldset","figcaption","figure","font","footer","form","h1","h2","h3","h4","h5","h6","head","header","hgroup","hr","html","i","img","input","ins","kbd","label","legend","li","main","map","mark","marquee","menu","menuitem","meter","nav","nobr","ol","optgroup","option","output","p","pre","progress","q","rp","rt","ruby","s","samp","section","select","shadow","small","source","spacer","span","strike","strong","style","sub","summary","sup","table","tbody","td","template","textarea","tfoot","th","thead","time","tr","track","tt","u","ul","var","video","wbr"],i=["svg","a","altglyph","altglyphdef","altglyphitem","animatecolor","animatemotion","animatetransform","audio","canvas","circle","clippath","defs","desc","ellipse","filter","font","g","glyph","glyphref","hkern","image","line","lineargradient","marker","mask","metadata","mpath","path","pattern","polygon","polyline","radialgradient","rect","stop","style","switch","symbol","text","textpath","title","tref","tspan","video","view","vkern"],a=["feBlend","feColorMatrix","feComponentTransfer","feComposite","feConvolveMatrix","feDiffuseLighting","feDisplacementMap","feDistantLight","feFlood","feFuncA","feFuncB","feFuncG","feFuncR","feGaussianBlur","feMerge","feMergeNode","feMorphology","feOffset","fePointLight","feSpecularLighting","feSpotLight","feTile","feTurbulence"],l=["math","menclose","merror","mfenced","mfrac","mglyph","mi","mlabeledtr","mmuliscripts","mn","mo","mover","mpadded","mphantom","mroot","mrow","ms","mpspace","msqrt","mystyle","msub","msup","msubsup","mtable","mtd","mtext","mtr","munder","munderover"],s=["#text"],c=["accept","action","align","alt","autocomplete","background","bgcolor","border","cellpadding","cellspacing","checked","cite","class","clear","color","cols","colspan","coords","crossorigin","datetime","default","dir","disabled","download","enctype","face","for","headers","height","hidden","high","href","hreflang","id","integrity","ismap","label","lang","list","loop","low","max","maxlength","media","method","min","multiple","name","noshade","novalidate","nowrap","open","optimum","pattern","placeholder","poster","preload","pubdate","radiogroup","readonly","rel","required","rev","reversed","role","rows","rowspan","spellcheck","scope","selected","shape","size","sizes","span","srclang","start","src","srcset","step","style","summary","tabindex","title","type","usemap","valign","value","width","xmlns"],d=["accent-height","accumulate","additivive","alignment-baseline","ascent","attributename","attributetype","azimuth","basefrequency","baseline-shift","begin","bias","by","class","clip","clip-path","clip-rule","color","color-interpolation","color-interpolation-filters","color-profile","color-rendering","cx","cy","d","dx","dy","diffuseconstant","direction","display","divisor","dur","edgemode","elevation","end","fill","fill-opacity","fill-rule","filter","flood-color","flood-opacity","font-family","font-size","font-size-adjust","font-stretch","font-style","font-variant","font-weight","fx","fy","g1","g2","glyph-name","glyphref","gradientunits","gradienttransform","height","href","id","image-rendering","in","in2","k","k1","k2","k3","k4","kerning","keypoints","keysplines","keytimes","lang","lengthadjust","letter-spacing","kernelmatrix","kernelunitlength","lighting-color","local","marker-end","marker-mid","marker-start","markerheight","markerunits","markerwidth","maskcontentunits","maskunits","max","mask","media","method","mode","min","name","numoctaves","offset","operator","opacity","order","orient","orientation","origin","overflow","paint-order","path","pathlength","patterncontentunits","patterntransform","patternunits","points","preservealpha","preserveaspectratio","r","rx","ry","radius","refx","refy","repeatcount","repeatdur","restart","result","rotate","scale","seed","shape-rendering","specularconstant","specularexponent","spreadmethod","stddeviation","stitchtiles","stop-color","stop-opacity","stroke-dasharray","stroke-dashoffset","stroke-linecap","stroke-linejoin","stroke-miterlimit","stroke-opacity","stroke","stroke-width","style","surfacescale","tabindex","targetx","targety","transform","text-anchor","text-decoration","text-rendering","textlength","type","u1","u2","unicode","values","viewbox","visibility","vert-adv-y","vert-origin-x","vert-origin-y","width","word-spacing","wrap","writing-mode","xchannelselector","ychannelselector","x","x1","x2","xmlns","y","y1","y2","z","zoomandpan"],u=["accent","accentunder","align","bevelled","close","columnsalign","columnlines","columnspan","denomalign","depth","dir","display","displaystyle","fence","frame","height","href","id","largeop","length","linethickness","lspace","lquote","mathbackground","mathcolor","mathsize","mathvariant","maxsize","minsize","movablelimits","notation","numalign","open","rowalign","rowlines","rowspacing","rowspan","rspace","rquote","scriptlevel","scriptminsize","scriptsizemultiplier","selection","separator","separators","stretchy","subscriptshift","supscriptshift","symmetric","voffset","width","xmlns"],m=["xlink:href","xml:id","xlink:title","xml:space","xmlns:xlink"],f=/\{\{[\s\S]*|[\s\S]*\}\}/gm,p=/<%[\s\S]*|[\s\S]*%>/gm,h=/^data-[\-\w.\u00B7-\uFFFF]/,g=/^aria-[\-\w]+$/,y=/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,v=/^(?:\w+script|data):/i,b=/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205f\u3000]/g,T="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},A=function(){return"undefined"==typeof window?null:window};return r()}); 2 | //# sourceMappingURL=purify.min.js.map 3 | --------------------------------------------------------------------------------
').querySelector("svg img")&&(w=!0)}catch(e){}}(),function(){try{Ee("