Last active
May 15, 2024 06:12
-
-
Save ahy4/47d6584247ed931ffd694e540f8f8a35 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// @ts-check | |
/** | |
* Sleep for the given amount of milliseconds. | |
* @param {number} msec | |
* @returns {Promise<void>} | |
*/ | |
const sleep = (msec) => new Promise((resolve) => setTimeout(resolve, msec)); | |
/** | |
* Returns true if current page is a Twitter status page. | |
* @param {string} url | |
* @returns {boolean} | |
*/ | |
const isTwitterStatusPage = (url) => | |
/twitter\.com\/[^/]+\/status\/\d+/.test(url); | |
/** | |
* Parse a string number which may contain '万' (10,000s). | |
* @param {string} str | |
* @returns {number} | |
*/ | |
const parseNumberWithUnit = (str) => { | |
if (!str) return 0; | |
return str.endsWith("万") ? parseFloat(str) * 10000 : parseInt(str, 10); | |
}; | |
/** | |
* Returns a key for a tweet. | |
* @param {string} screenName | |
* @param {string} text | |
* @returns {string} | |
*/ | |
const tweetKey = (screenName, text) => `${screenName} ${text}`; | |
/** @typedef {{ rt?: number, fav?: number, reply?: number, bookmark?: number, imp?: number }} TweetStat */ | |
/** | |
* Extracts stats from a tweet's aria-label attribute. | |
* @param {Element} tweet | |
* @returns {TweetStat | null}} | |
*/ | |
const getTweetStat = (tweet) => { | |
const dataLabel = tweet | |
.querySelector('div[aria-label*="件の表示"]') | |
?.getAttribute("aria-label"); | |
if (!dataLabel) return null; | |
return { | |
rt: parseNumberWithUnit(dataLabel.match(/(\d+) 件のリポスト/)?.[1]), | |
fav: parseNumberWithUnit(dataLabel.match(/(\d+) 件のいいね/)?.[1]), | |
reply: parseNumberWithUnit(dataLabel.match(/(\d+) 件の返信/)?.[1]), | |
bookmark: parseNumberWithUnit( | |
dataLabel.match(/(\d+) 件のブックマーク/)?.[1] | |
), | |
imp: parseNumberWithUnit(dataLabel.match(/(\d+) 件の表示/)?.[1]), | |
}; | |
}; | |
/** | |
* Clicks an element if it exists and returns true, otherwise false. | |
* @param {Element | null} element | |
* @returns {Promise<boolean>} | |
*/ | |
const clickIfExist = async (element) => { | |
if (element instanceof HTMLElement) { | |
element.click(); | |
await sleep(50); | |
return true; | |
} | |
return false; | |
}; | |
/** | |
* 現在の要判定ツイートURLを暫定保存 (スクロールすると元ツイートの要素が消えるため) | |
* 判定不要と判断している場合 "" | |
* @type {string} | |
*/ | |
let currentURL = ""; | |
/** | |
* Main function that executes the spam-reply blocking logic. | |
*/ | |
const blockSpamReplies = async () => { | |
while (true) { | |
await sleep(3000); | |
if (!isTwitterStatusPage(location.href)) { | |
continue; | |
} | |
// ブロック処理するかどうか判定 (過去に判定済なら、判定を省略しブロック処理) | |
if (currentURL !== location.href) { | |
const spans = Array.from(document.querySelectorAll("span")); | |
const targetSpan = spans.find((span) => | |
span.textContent.includes("件の表示") | |
); | |
const ancestor = targetSpan?.closest('div[data-testid="cellInnerDiv"]'); | |
if (!ancestor) continue; | |
const rootTweetStat = getTweetStat(ancestor); | |
if (rootTweetStat && rootTweetStat.rt < 400 && rootTweetStat.fav < 2000) { | |
continue; | |
} | |
currentURL = location.href; | |
} | |
console.log("リプライブロック開始"); | |
let prevTweet = ""; | |
for (const tweet of document.querySelectorAll( | |
'div[data-testid="cellInnerDiv"]' | |
)) { | |
const tweetStat = getTweetStat(tweet); | |
if (!tweetStat) continue; | |
const isVerified = !!tweet.querySelector( | |
'svg[aria-label="認証済みアカウント"]' | |
); | |
const nameSpans = Array.from( | |
tweet.querySelectorAll('div[data-testid="User-Name"] span') | |
); | |
const screenName = nameSpans.find((span) => | |
span.textContent.startsWith("@") | |
)?.textContent; | |
const name = nameSpans.find( | |
(span) => !span.textContent.startsWith("@") | |
)?.textContent; | |
const tweetText = tweet.querySelector( | |
'div[data-testid="tweetText"]' | |
)?.textContent; | |
// ツイート本人 | |
const isOwner = currentURL.includes(screenName.replace("@", "")); | |
// 反応すくなすぎる | |
const tooFewReactions = | |
!isOwner && | |
isVerified && | |
tweetStat.rt === 0 && | |
(tweetStat.fav === 1 && | |
tweetStat.imp >= 1000 || tweetStat.fav === 0 && tweetStat.imp >= 750); | |
// 同じ人間が連続で登場する | |
const samePerson = | |
!isOwner && isVerified && prevTweet === tweetKey(screenName, tweetText); | |
// TODO: 特定ドメインのリンクを含むツイート、自分の引用RTを吊るしているものもブロックする | |
prevTweet = tweetKey(screenName, tweetText); | |
if (!(tooFewReactions || samePerson)) { | |
continue; | |
} | |
// 別ページに移動済ならスキップ | |
if (currentURL !== location.href) { | |
continue; | |
} | |
console.log( | |
`ブロック: twitter.com/${screenName} "${name}"「${tweetText}」RT:${tweetStat.rt} Fav:${tweetStat.fav} Imp:${tweetStat.imp}` | |
); | |
localStorage.setItem( | |
`reply-block`, | |
`${localStorage.getItem(`reply-block`)}, @${screenName}` | |
); | |
const dots = tweet.querySelector( | |
'[data-testid="caret"][aria-label="もっと見る"]' | |
); | |
if (!(await clickIfExist(dots))) continue; | |
const dropdown = document.querySelector('[data-testid="Dropdown"]'); | |
const blockButton = dropdown?.querySelector('[data-testid="block"]'); | |
if (!(await clickIfExist(blockButton))) continue; | |
const blockDialog = document.querySelector( | |
'[data-testid="confirmationSheetConfirm"]' | |
); | |
await clickIfExist(blockDialog); | |
} | |
} | |
}; | |
blockSpamReplies(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment