Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save ahy4/47d6584247ed931ffd694e540f8f8a35 to your computer and use it in GitHub Desktop.
Save ahy4/47d6584247ed931ffd694e540f8f8a35 to your computer and use it in GitHub Desktop.
// @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