mirror of
https://github.com/Luzifer/anti-followbot.git
synced 2024-12-22 22:01:16 +00:00
Initial version
This commit is contained in:
commit
fe259cf7c7
2 changed files with 576 additions and 0 deletions
320
app.js
Normal file
320
app.js
Normal file
|
@ -0,0 +1,320 @@
|
|||
/* global axios, moment, Vue */
|
||||
|
||||
const twitchClientID = 'kf1i6vf07ihojqei5autfb52vwl586'
|
||||
|
||||
Vue.config.devtools = true
|
||||
new Vue({
|
||||
|
||||
computed: {
|
||||
actionsDisabled() {
|
||||
return this.twitchUserID !== this.twitchAuthorizedUserID || this.blockRunStarted
|
||||
},
|
||||
|
||||
affectedUsers() {
|
||||
return this.filteredFollowers.filter(f => this.protectedIDs.indexOf(f.from_id) === -1).length
|
||||
},
|
||||
|
||||
authURL() {
|
||||
const scopes = [
|
||||
'user:edit:follows',
|
||||
'user:manage:blocked_users',
|
||||
]
|
||||
|
||||
const params = new URLSearchParams()
|
||||
params.set('client_id', twitchClientID)
|
||||
params.set('redirect_uri', window.location.href)
|
||||
params.set('response_type', 'token')
|
||||
params.set('scope', scopes.join(' '))
|
||||
|
||||
return `https://id.twitch.tv/oauth2/authorize?${params.toString()}`
|
||||
},
|
||||
|
||||
axiosOptions() {
|
||||
return {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.twitchToken}`,
|
||||
'Client-Id': twitchClientID,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
blockedUsers() {
|
||||
return this.filteredFollowers.filter(f => f.blocked).length
|
||||
},
|
||||
|
||||
bucketSize() {
|
||||
const tickSettings = {
|
||||
604800: 600,
|
||||
86400: 120,
|
||||
3600: 60,
|
||||
1800: 30,
|
||||
900: 15,
|
||||
600: 10,
|
||||
300: 5,
|
||||
0: 1,
|
||||
}
|
||||
|
||||
const timerangeSecs = Math.round((this.zoomArea.max - this.zoomArea.min) / 1000)
|
||||
for (const thresh of Object.keys(tickSettings).sort((b, a) => Number(a) - Number(b))) {
|
||||
if (timerangeSecs > Number(thresh)) {
|
||||
return tickSettings[thresh] * 1000
|
||||
}
|
||||
}
|
||||
|
||||
return 60000
|
||||
},
|
||||
|
||||
chartOptions() {
|
||||
return {
|
||||
chart: {
|
||||
panKey: 'shift',
|
||||
panning: {
|
||||
enabled: true,
|
||||
},
|
||||
|
||||
type: 'column',
|
||||
zoomType: 'x',
|
||||
},
|
||||
|
||||
legend: {
|
||||
enabled: false,
|
||||
},
|
||||
|
||||
title: {
|
||||
text: 'Follows over Time',
|
||||
},
|
||||
|
||||
subtitle: {
|
||||
text: 'Click and drag in the plot area to zoom in / Shift + Drag to pan around',
|
||||
},
|
||||
|
||||
xAxis: {
|
||||
events: {
|
||||
afterSetExtremes: evt => this.updateZoomArea(evt),
|
||||
},
|
||||
|
||||
minRange: 60 * 1000,
|
||||
tickInterval: 60 * 1000,
|
||||
type: 'datetime',
|
||||
},
|
||||
|
||||
yAxis: {
|
||||
min: 0,
|
||||
title: {
|
||||
text: '',
|
||||
},
|
||||
},
|
||||
|
||||
series: [
|
||||
{
|
||||
name: '',
|
||||
data: this.followCounts,
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
|
||||
filteredFollowers() {
|
||||
return this.follows
|
||||
.filter(f => new Date(f.followed_at).getTime() > this.zoomArea.min && new Date(f.followed_at).getTime() < this.zoomArea.max)
|
||||
.sort((b, a) => new Date(a.followed_at).getTime() - new Date(b.followed_at).getTime())
|
||||
},
|
||||
|
||||
followCounts() {
|
||||
const buckets = {}
|
||||
|
||||
if (this.follows.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Set current time in order to have the area better zoomable
|
||||
const nTime = new Date().getTime() + 300000
|
||||
buckets[nTime - nTime % this.bucketSize] = 0
|
||||
|
||||
const sTime = new Date().getTime() - this.timespan * 86400 * 1000
|
||||
buckets[sTime - sTime % this.bucketSize] = 0
|
||||
|
||||
for (const follow of this.follows) {
|
||||
const fTime = new Date(follow.followed_at).getTime()
|
||||
const bucket = fTime - fTime % this.bucketSize
|
||||
if (!buckets[bucket]) {
|
||||
buckets[bucket] = 0
|
||||
}
|
||||
buckets[bucket]++
|
||||
}
|
||||
|
||||
const pcta = Object.values(buckets)
|
||||
.sort((a, b) => a - b)
|
||||
const pct = pcta[Math.round(pcta.length * 0.95)]
|
||||
|
||||
return Object.keys(buckets)
|
||||
.map(t => ({
|
||||
color: buckets[t] > pct ? 'red' : '#7cb5ec',
|
||||
x: Number(t),
|
||||
y: buckets[t],
|
||||
}))
|
||||
.sort((a, b) => a.x - b.x)
|
||||
},
|
||||
|
||||
protectedUsers() {
|
||||
return this.filteredFollowers.filter(f => this.protectedIDs.indexOf(f.from_id) > -1).length
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
blockButtonUnlocked: false,
|
||||
blockRunStarted: false,
|
||||
// bucketSize: 15000,
|
||||
chart: null,
|
||||
displayFollowers: false,
|
||||
follows: [],
|
||||
overrideUser: '',
|
||||
protectedIDs: [],
|
||||
timespan: 2,
|
||||
timespanOpts: [
|
||||
{ value: 2, text: '2 days' },
|
||||
{ value: 7, text: '7 days' },
|
||||
{ value: 14, text: '14 days' },
|
||||
{ value: 30, text: '30 days' },
|
||||
],
|
||||
|
||||
twitchToken: null,
|
||||
twitchUserID: null,
|
||||
twitchAuthorizedUserID: null,
|
||||
zoomArea: { max: 0, min: 0 },
|
||||
}
|
||||
},
|
||||
|
||||
el: '#app',
|
||||
|
||||
methods: {
|
||||
executeBlocks() {
|
||||
this.blockRunStarted = true
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const fn = () => {
|
||||
const toBlock = this.filteredFollowers
|
||||
.filter(f => this.protectedIDs.indexOf(f.from_id) === -1 && !f.blocked)
|
||||
|
||||
if (toBlock.length === 0) {
|
||||
console.log(`Block completed`)
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
axios.put(`https://api.twitch.tv/helix/users/blocks?target_user_id=${toBlock[0].from_id}`, null, this.axiosOptions)
|
||||
.then(resp => {
|
||||
Vue.set(toBlock[0], 'blocked', true)
|
||||
window.setTimeout(() => fn(), 100)
|
||||
})
|
||||
.catch(() => reject())
|
||||
}
|
||||
|
||||
fn()
|
||||
})
|
||||
},
|
||||
|
||||
fetchFollowers(clear = false, cursor = null) {
|
||||
this.blockButtonUnlocked = false
|
||||
|
||||
const cursorQuery = cursor ? `&after=${cursor}` : ''
|
||||
axios.get(`https://api.twitch.tv/helix/users/follows?first=100&to_id=${this.twitchUserID}${cursorQuery}`, this.axiosOptions)
|
||||
.then(resp => {
|
||||
if (clear) {
|
||||
this.follows = []
|
||||
this.protectedIDs = []
|
||||
}
|
||||
this.follows = [...this.follows, ...resp.data.data]
|
||||
|
||||
let oldest = new Date().getTime() // Assume now and count down
|
||||
for (const follow of this.follows) {
|
||||
oldest = Math.min(oldest, new Date(follow.followed_at).getTime())
|
||||
}
|
||||
return { cursor: resp.data.pagination.cursor, oldest }
|
||||
})
|
||||
.then(res => {
|
||||
if (new Date().getTime() - res.oldest > this.timespan * 24 * 3600 * 1000) {
|
||||
return
|
||||
}
|
||||
return this.fetchFollowers(false, res.cursor)
|
||||
})
|
||||
},
|
||||
|
||||
fetchUserID(username = null, isAuth = false) {
|
||||
const userQuery = username ? `?login=${username}` : ''
|
||||
axios.get(`https://api.twitch.tv/helix/users${userQuery}`, this.axiosOptions)
|
||||
.then(resp => {
|
||||
if (!resp.data?.data || resp.data.data.length < 1) {
|
||||
throw new Error('no user profiles found')
|
||||
}
|
||||
|
||||
this.twitchUserID = resp.data.data[0].id
|
||||
this.overrideUser = resp.data.data[0].login
|
||||
if (isAuth) {
|
||||
this.twitchAuthorizedUserID = resp.data.data[0].id
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
initChart() {
|
||||
return new Promise(resolve => {
|
||||
const fn = () => {
|
||||
if (document.querySelector('#chart')) {
|
||||
this.chart = Highcharts.chart('chart', this.chartOptions)
|
||||
resolve()
|
||||
} else {
|
||||
window.setTimeout(() => fn(), 100)
|
||||
}
|
||||
}
|
||||
|
||||
fn()
|
||||
})
|
||||
},
|
||||
|
||||
moment,
|
||||
|
||||
toggleProtect(id) {
|
||||
if (this.protectedIDs.indexOf(id) > -1) {
|
||||
this.protectedIDs = this.protectedIDs.filter(pid => pid !== id)
|
||||
return
|
||||
}
|
||||
this.protectedIDs = [...this.protectedIDs, id]
|
||||
},
|
||||
|
||||
updateZoomArea(evt) {
|
||||
this.zoomArea = { max: evt.max || evt.dataMax, min: evt.min || evt.dataMin }
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.twitchToken = new URLSearchParams(window.location.hash.substr(1)).get('access_token') || null
|
||||
if (this.twitchToken) {
|
||||
this.initChart()
|
||||
}
|
||||
},
|
||||
|
||||
name: 'AntiFollowBot',
|
||||
|
||||
watch: {
|
||||
chartOptions(to) {
|
||||
this.chart.update(to)
|
||||
},
|
||||
|
||||
timespan(to, from) {
|
||||
if (to === from) {
|
||||
return
|
||||
}
|
||||
this.fetchFollowers(true, null)
|
||||
},
|
||||
|
||||
twitchToken() {
|
||||
this.fetchUserID(null, true)
|
||||
},
|
||||
|
||||
twitchUserID() {
|
||||
this.fetchFollowers(true, null)
|
||||
},
|
||||
},
|
||||
|
||||
})
|
256
index.html
Normal file
256
index.html
Normal file
|
@ -0,0 +1,256 @@
|
|||
<html>
|
||||
|
||||
<title>Anti-FollowBot</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/combine/npm/bootstrap@4/dist/css/bootstrap.min.css,npm/bootstrap-vue@2/dist/bootstrap-vue.min.css,npm/bootswatch@5/dist/darkly/bootstrap.min.css">
|
||||
<script src="https://kit.fontawesome.com/0caf4eb225.js" crossorigin="anonymous"></script>
|
||||
|
||||
<style>
|
||||
#chart {
|
||||
height: 100%;
|
||||
left:0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.embed-responsive-custom::before {
|
||||
padding-top: calc(6 / 21 * 100%);
|
||||
}
|
||||
</style>
|
||||
|
||||
<div id="app">
|
||||
<b-container>
|
||||
<template v-if="twitchUserID">
|
||||
|
||||
<b-row class="my-3" v-if="!blockRunStarted">
|
||||
<b-col>
|
||||
<div class="embed-responsive embed-responsive-custom">
|
||||
<div id="chart"></div>
|
||||
</div>
|
||||
</b-col>
|
||||
</b-row>
|
||||
|
||||
<b-row class="my-3">
|
||||
<b-col cols="7">
|
||||
<b-card
|
||||
no-body
|
||||
>
|
||||
<b-card-header class="d-flex justify-content-between">
|
||||
Filtered followers
|
||||
<b-badge>
|
||||
{{ filteredFollowers.length }}
|
||||
</b-badge>
|
||||
</b-card-header>
|
||||
|
||||
<b-list-group
|
||||
flush
|
||||
v-if="displayFollowers"
|
||||
>
|
||||
<b-list-group-item
|
||||
class="d-flex justify-content-between align-items-center"
|
||||
v-for="follower in filteredFollowers"
|
||||
:key="follower.from_id"
|
||||
>
|
||||
<span>
|
||||
<i class="fas fa-fw fa-user mr-1" v-if="!follower.blocked"></i>
|
||||
<i class="fas fa-fw fa-user-slash mr-1 text-danger" v-else></i>
|
||||
{{ follower.from_name }}
|
||||
</span>
|
||||
<span>
|
||||
<b-badge class="mr-2">
|
||||
{{ moment(follower.followed_at).format('lll') }}
|
||||
</b-badge>
|
||||
<b-btn-group size="sm">
|
||||
<b-btn
|
||||
:disabled="actionsDisabled || protectedIDs.indexOf(follower.from_id) > -1"
|
||||
title="Block"
|
||||
variant="danger"
|
||||
>
|
||||
<i class="fas fa-fw fa-ban"></i>
|
||||
</b-btn>
|
||||
<b-btn
|
||||
@click="toggleProtect(follower.from_id)"
|
||||
:disabled="actionsDisabled"
|
||||
title="Protect from actions"
|
||||
:variant="protectedIDs.indexOf(follower.from_id) > -1 ? 'warning' : 'success'"
|
||||
>
|
||||
<i class="fas fa-fw fa-shield"></i>
|
||||
</b-btn>
|
||||
</b-btn-group>
|
||||
</span>
|
||||
</b-list-group-item>
|
||||
</b-list-group>
|
||||
</b-card>
|
||||
</b-col>
|
||||
|
||||
<b-col cols="5">
|
||||
<b-card
|
||||
header="Settings"
|
||||
>
|
||||
|
||||
<b-form-group
|
||||
label="Timespan to load"
|
||||
label-for="timespan"
|
||||
>
|
||||
<b-form-select
|
||||
:disabled="blockRunStarted"
|
||||
id="timespan"
|
||||
:options="timespanOpts"
|
||||
v-model="timespan"
|
||||
></b-form-select>
|
||||
</b-form-group>
|
||||
|
||||
<p>
|
||||
<i class="fas fa-fw fa-info-circle mr-1"></i>
|
||||
Current zoom level: <strong>{{ bucketSize / 1000 }}s</strong> per bar.
|
||||
</p>
|
||||
|
||||
<b-form-checkbox
|
||||
class="mt-2"
|
||||
v-model="displayFollowers"
|
||||
switch
|
||||
>
|
||||
Display followers in the left column<br>
|
||||
<small>
|
||||
<i class="fas fa-fw fa-exclamation-triangle mr-1 text-warning"></i>
|
||||
Many displayed followers might slow or crash your browser, filter through the chart first!
|
||||
</small>
|
||||
</b-form-checkbox>
|
||||
|
||||
</b-card>
|
||||
<b-card
|
||||
class="mt-3"
|
||||
header="Actions"
|
||||
>
|
||||
|
||||
<b-row>
|
||||
<b-col>
|
||||
<b-form-group
|
||||
label="Affected Users"
|
||||
label-for="affectedUsers"
|
||||
>
|
||||
<b-form-input
|
||||
class="text-right"
|
||||
disabled
|
||||
id="affectedUsers"
|
||||
readonly
|
||||
:value="affectedUsers"
|
||||
/>
|
||||
</b-form-group>
|
||||
</b-col>
|
||||
<b-col>
|
||||
<b-form-group
|
||||
label="Protected Users"
|
||||
label-for="protectedUsers"
|
||||
>
|
||||
<b-form-input
|
||||
class="text-right"
|
||||
disabled
|
||||
id="protectedUsers"
|
||||
readonly
|
||||
:value="protectedUsers"
|
||||
/>
|
||||
</b-form-group>
|
||||
</b-col>
|
||||
</b-row>
|
||||
|
||||
<b-button-group
|
||||
class="w-100"
|
||||
v-if="!blockRunStarted"
|
||||
>
|
||||
<b-btn
|
||||
class="w-50"
|
||||
@click.prevent="blockButtonUnlocked = true"
|
||||
:disabled="actionsDisabled || affectedUsers === 0"
|
||||
variant="primary"
|
||||
v-if="!blockButtonUnlocked"
|
||||
>
|
||||
<i class="fas fa-fw fa-unlock"></i>
|
||||
Unlock Block
|
||||
</b-btn>
|
||||
<b-btn
|
||||
class="w-50"
|
||||
@click="executeBlocks"
|
||||
:disabled="actionsDisabled || affectedUsers === 0"
|
||||
variant="danger"
|
||||
v-else
|
||||
>
|
||||
<i class="fas fa-fw fa-ban"></i>
|
||||
Block Users
|
||||
</b-btn>
|
||||
<b-btn
|
||||
@click="protectedIDs = []"
|
||||
class="w-50"
|
||||
variant="warning"
|
||||
>
|
||||
<i class="fas fa-fw fa-broom"></i>
|
||||
Clear Protected
|
||||
</b-btn>
|
||||
</b-button-group>
|
||||
|
||||
<b-progress
|
||||
:max="affectedUsers"
|
||||
v-else
|
||||
>
|
||||
<b-progress-bar
|
||||
:label="`${((blockedUsers / affectedUsers) * 100).toFixed(1)}%`"
|
||||
:value="blockedUsers"
|
||||
></b-progress-bar>
|
||||
</b-progress>
|
||||
|
||||
<p
|
||||
class="mt-2 mb-0"
|
||||
v-if="blockRunStarted && affectedUsers === blockedUsers"
|
||||
>
|
||||
<i class="fas fa-fw fa-info-circle mr-1 text-success"></i>
|
||||
Blocks are now finished. Please reload this site and do a new analysis.
|
||||
(It might take some minutes for Twitch to have the follow removed!)
|
||||
</p>
|
||||
|
||||
</b-card>
|
||||
<b-card
|
||||
class="mt-3"
|
||||
header="Analyze another User"
|
||||
v-if="!blockRunStarted"
|
||||
>
|
||||
|
||||
<p>
|
||||
You can analyze another users followers but take no actions for them. They need to take actions themselves…
|
||||
</p>
|
||||
<b-input-group prepend="Username">
|
||||
<b-form-input
|
||||
v-on:keyup.enter="fetchUserID(overrideUser)"
|
||||
v-model="overrideUser"
|
||||
></b-form-input>
|
||||
<b-input-group-append>
|
||||
<b-button
|
||||
@click="fetchUserID(overrideUser)"
|
||||
variant="primary"
|
||||
>
|
||||
<i class="fas fa-download"></i>
|
||||
Fetch User
|
||||
</b-button>
|
||||
</b-input-group-append>
|
||||
</b-input-group>
|
||||
|
||||
</b-card>
|
||||
</b-col>
|
||||
</b-row>
|
||||
|
||||
</template>
|
||||
<template v-else>
|
||||
|
||||
<b-row class="my-3">
|
||||
<b-col class="text-center">
|
||||
<b-btn :href="authURL">
|
||||
Connect with Twitch
|
||||
</b-btn>
|
||||
</b-col>
|
||||
</b-row>
|
||||
|
||||
</template>
|
||||
</b-container>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/combine/npm/axios@0.21.1,npm/vue@2,npm/bootstrap-vue@2/dist/bootstrap-vue.min.js,npm/highcharts@9,npm/highcharts@9/themes/dark-unica.min.js,npm/moment@2/moment.min.js"></script>
|
||||
<script type="module" src="app.js"></script>
|
||||
</html>
|
Loading…
Reference in a new issue