Track trending tags (#7638)
* Track trending tags - Half-life of 1 day - Historical usage in daily buckets (last 7 days stored) - GET /api/v1/trends Fix #271 * Add trends to web UI * Don't render compose form on search route, adjust search results header * Disqualify tag from trends if it's in disallowed hashtags setting * Count distinct accounts using tag, ignore silenced accountsmaster
parent
63c7b91572
commit
9bd23dc4e5
@ -0,0 +1,17 @@ |
||||
# frozen_string_literal: true |
||||
|
||||
class Api::V1::TrendsController < Api::BaseController |
||||
before_action :set_tags |
||||
|
||||
respond_to :json |
||||
|
||||
def index |
||||
render json: @tags, each_serializer: REST::TagSerializer |
||||
end |
||||
|
||||
private |
||||
|
||||
def set_tags |
||||
@tags = TrendingTags.get(limit_param(10)) |
||||
end |
||||
end |
@ -0,0 +1,32 @@ |
||||
import api from '../api'; |
||||
|
||||
export const TRENDS_FETCH_REQUEST = 'TRENDS_FETCH_REQUEST'; |
||||
export const TRENDS_FETCH_SUCCESS = 'TRENDS_FETCH_SUCCESS'; |
||||
export const TRENDS_FETCH_FAIL = 'TRENDS_FETCH_FAIL'; |
||||
|
||||
export const fetchTrends = () => (dispatch, getState) => { |
||||
dispatch(fetchTrendsRequest()); |
||||
|
||||
api(getState) |
||||
.get('/api/v1/trends') |
||||
.then(({ data }) => dispatch(fetchTrendsSuccess(data))) |
||||
.catch(err => dispatch(fetchTrendsFail(err))); |
||||
}; |
||||
|
||||
export const fetchTrendsRequest = () => ({ |
||||
type: TRENDS_FETCH_REQUEST, |
||||
skipLoading: true, |
||||
}); |
||||
|
||||
export const fetchTrendsSuccess = trends => ({ |
||||
type: TRENDS_FETCH_SUCCESS, |
||||
trends, |
||||
skipLoading: true, |
||||
}); |
||||
|
||||
export const fetchTrendsFail = error => ({ |
||||
type: TRENDS_FETCH_FAIL, |
||||
error, |
||||
skipLoading: true, |
||||
skipAlert: true, |
||||
}); |
@ -1,8 +1,14 @@ |
||||
import { connect } from 'react-redux'; |
||||
import SearchResults from '../components/search_results'; |
||||
import { fetchTrends } from '../../../actions/trends'; |
||||
|
||||
const mapStateToProps = state => ({ |
||||
results: state.getIn(['search', 'results']), |
||||
trends: state.get('trends'), |
||||
}); |
||||
|
||||
export default connect(mapStateToProps)(SearchResults); |
||||
const mapDispatchToProps = dispatch => ({ |
||||
fetchTrends: () => dispatch(fetchTrends()), |
||||
}); |
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(SearchResults); |
||||
|
@ -0,0 +1,13 @@ |
||||
import { TRENDS_FETCH_SUCCESS } from '../actions/trends'; |
||||
import { fromJS } from 'immutable'; |
||||
|
||||
const initialState = null; |
||||
|
||||
export default function trendsReducer(state = initialState, action) { |
||||
switch(action.type) { |
||||
case TRENDS_FETCH_SUCCESS: |
||||
return fromJS(action.trends); |
||||
default: |
||||
return state; |
||||
} |
||||
}; |
@ -0,0 +1,61 @@ |
||||
# frozen_string_literal: true |
||||
|
||||
class TrendingTags |
||||
KEY = 'trending_tags' |
||||
HALF_LIFE = 1.day.to_i |
||||
MAX_ITEMS = 500 |
||||
EXPIRE_HISTORY_AFTER = 7.days.seconds |
||||
|
||||
class << self |
||||
def record_use!(tag, account, at_time = Time.now.utc) |
||||
return if disallowed_hashtags.include?(tag.name) || account.silenced? |
||||
|
||||
increment_vote!(tag.id, at_time) |
||||
increment_historical_use!(tag.id, at_time) |
||||
increment_unique_use!(tag.id, account.id, at_time) |
||||
end |
||||
|
||||
def get(limit) |
||||
tag_ids = redis.zrevrange(KEY, 0, limit).map(&:to_i) |
||||
tags = Tag.where(id: tag_ids).to_a.map { |tag| [tag.id, tag] }.to_h |
||||
tag_ids.map { |tag_id| tags[tag_id] }.compact |
||||
end |
||||
|
||||
private |
||||
|
||||
def increment_vote!(tag_id, at_time) |
||||
redis.zincrby(KEY, (2**((at_time.to_i - epoch) / HALF_LIFE)).to_f, tag_id.to_s) |
||||
redis.zremrangebyrank(KEY, 0, -MAX_ITEMS) if rand < (2.to_f / MAX_ITEMS) |
||||
end |
||||
|
||||
def increment_historical_use!(tag_id, at_time) |
||||
key = "activity:tags:#{tag_id}:#{at_time.beginning_of_day.to_i}" |
||||
redis.incrby(key, 1) |
||||
redis.expire(key, EXPIRE_HISTORY_AFTER) |
||||
end |
||||
|
||||
def increment_unique_use!(tag_id, account_id, at_time) |
||||
key = "activity:tags:#{tag_id}:#{at_time.beginning_of_day.to_i}:accounts" |
||||
redis.pfadd(key, account_id) |
||||
redis.expire(key, EXPIRE_HISTORY_AFTER) |
||||
end |
||||
|
||||
# The epoch needs to be 2.5 years in the future if the half-life is one day |
||||
# While dynamic, it will always be the same within one year |
||||
def epoch |
||||
@epoch ||= Date.new(Date.current.year + 2.5, 10, 1).to_datetime.to_i |
||||
end |
||||
|
||||
def disallowed_hashtags |
||||
return @disallowed_hashtags if defined?(@disallowed_hashtags) |
||||
|
||||
@disallowed_hashtags = Setting.disallowed_hashtags.nil? ? [] : Setting.disallowed_hashtags |
||||
@disallowed_hashtags = @disallowed_hashtags.split(' ') if @disallowed_hashtags.is_a? String |
||||
@disallowed_hashtags = @disallowed_hashtags.map(&:downcase) |
||||
end |
||||
|
||||
def redis |
||||
Redis.current |
||||
end |
||||
end |
||||
end |
@ -0,0 +1,11 @@ |
||||
# frozen_string_literal: true |
||||
|
||||
class REST::TagSerializer < ActiveModel::Serializer |
||||
include RoutingHelper |
||||
|
||||
attributes :name, :url, :history |
||||
|
||||
def url |
||||
tag_url(object) |
||||
end |
||||
end |
Loading…
Reference in new issue