Initial commit

This commit is contained in:
zhongjin
2020-06-15 10:58:47 +08:00
commit 4f1dfe7564
8590 changed files with 1516878 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
require 'zlib'
require_relative './datasources/base'
require_relative './datasources/base_oauth'
require_relative './datasources/base_file_stream'
require_relative './datasources/base_direct_stream'
require_relative './datasources/exceptions'
require_relative './datasources/url/public_url'
require_relative './datasources/url/dropbox'
require_relative './datasources/url/gdrive'
require_relative './datasources/url/instagram_oauth'
require_relative './datasources/url/mailchimp'
require_relative './datasources/url/arcgis'
require_relative './datasources/search/twitter'
require_relative './datasources/datasources_factory'
require_relative './datasources/util/csv_file_dumper'
require_relative './datasources/decorators/factory'
@@ -0,0 +1,141 @@
module CartoDB
module Datasources
class Base
# .csv
FORMAT_CSV = 'csv'
# .xls .xlsx
FORMAT_EXCEL = 'xls'
# .GPX
FORMAT_GPX = 'gpx'
# .KML
FORMAT_KML = 'kml'
# .png
FORMAT_PNG = 'png'
# .jpg .jpeg
FORMAT_JPG = 'jpg'
# .svg
FORMAT_SVG = 'svg'
# .zip
FORMAT_COMPRESSED = 'zip'
# If data size cannot be determined, this will be returned as its size in the item metadata
NO_CONTENT_SIZE_PROVIDED = 0
def initialize(*args)
@logger = nil
end
# Small helper method to know if metadata includes a valid resource size value or not
# @param resource_metadata Hash { :size, ... }
# @return bool
def has_resource_size?(resource_metadata)
resource_metadata[:size] && resource_metadata[:size] > NO_CONTENT_SIZE_PROVIDED
end
# Factory method
# @param config {}
# @return mixed
def get_new(config)
raise 'To be implemented in child classes'
end
# If will provide a url to download the resource, or requires calling get_resource()
# @return bool
def providers_download_url?
raise 'To be implemented in child classes'
end
# If will provide the url http response code
# @return string
def get_http_response_code
raise 'To be implemented in child classes'
end
# Perform the listing and return results
# @param filter Array : (Optional) filter to specify which resources to retrieve. Leave empty for all supported.
# @return [ { :id, :title, :url, :service, :filename, :checksum, :size } ]
def get_resources_list(filter={})
raise 'To be implemented in child classes'
end
# Retrieves a resource and returns its contents
# @param id string
# @return mixed
def get_resource(id)
raise 'To be implemented in child classes'
end
# @param id string
# @return Hash
def get_resource_metadata(id)
raise 'To be implemented in child classes'
end
# Retrieves current filters
# @return {}
def filter
raise 'To be implemented in child classes'
end
# Sets current filters
# @param filter_data {}
def filter=(filter_data={})
raise 'To be implemented in child classes'
end
# Log a message
# @param message String
def log(message)
puts message if @logger.nil?
@logger.append(message) unless @logger.nil?
end
# @param logger Mixed|nil Set or unset the logger
def logger=(logger=nil)
@logger = logger
end
# Just return datasource name
# @return string
def to_s
raise 'To be implemented in child classes'
end
# If this datasource accepts a data import instance
# @return Boolean
def persists_state_via_data_import?
raise 'To be implemented in child classes'
end
def set_audit_to_completed(table_id = nil)
raise 'To be implemented in child classes'
end
def set_audit_to_failed
raise 'To be implemented in child classes'
end
# @return Hash
def get_audit_stats
raise 'To be implemented in child classes'
end
# Stores the data import item instance to use/manipulate it
# @param value DataImport
def data_import_item=(value)
raise 'To be implemented in child classes'
end
# If true, a single resource id might return >1 subresources (each one spawning a table)
# @param id String
# @return Bool
def multi_resource_import_supported?(id)
false
end
private_class_method :new
end
end
end
@@ -0,0 +1,25 @@
require_relative 'base'
module CartoDB
module Datasources
# Performs streaming from the datasource directly to the caller in batches
class BaseDirectStream < Base
# Initial stream, to be used for container creation (table usually)
# @param id string
# @return String
def initial_stream(id)
raise 'To be implemented in child classes'
end
# @param id string
# @return String
def stream_resource(id)
raise 'To be implemented in child classes'
end
private_class_method :new
end
end
end
@@ -0,0 +1,17 @@
module CartoDB
module Datasources
# Performs streaming from the datasource to a file
class BaseFileStream < Base
# @param id string
# @param stream Stream
# @return Integer bytes streamed
def stream_resource(id, stream)
raise 'To be implemented in child classes'
end
private_class_method :new
end
end
end
@@ -0,0 +1,93 @@
require_relative '../../../importer/lib/importer/unp'
module CartoDB
module Datasources
class BaseOAuth < Base
# At least for CartoDB SaaS, this will be parsed by oauth endpoint to rewrite the url, so must be filled with
# e.g. CALLBACK_STATE_DATA_PLACEHOLDER.sub('user', @user.username).sub('service', DATASOURCE_NAME)
# And appended anywhere in the querystring used as callback url with param "state=xxxxxx"
CALLBACK_STATE_DATA_PLACEHOLDER = '__user__service__'
attr_reader :config
def initialize(config, user, mandatory_config_parameters, datasource_name)
super
mandatory_config_parameters.each { |param| check_config(config, param, datasource_name) }
@config = config
@user = user
end
# TODO: Helper method to aid with migration of endpoints, can be removed after full AR migration
def service_name_for_user(service_name, user)
service_name
end
# Return the url to be displayed or sent the user to to authenticate and get authorization code
# @param use_callback_flow : bool
def get_auth_url(use_callback_flow=true)
raise 'To be implemented in child classes'
end #get_auth_url
# Validate authorization code and store token
# @param auth_code : string
# @return string : Access token
def validate_auth_code(auth_code)
raise 'To be implemented in child classes'
end #validate_auth_code
# Validates the authorization callback
# @param params : mixed
def validate_callback(params)
raise 'To be implemented in child classes'
end #validate_callback
# Set token
# @param token string
def token=(token)
raise 'To be implemented in child classes'
end #token=
# Retrieve token
# @return string | nil
def token
raise 'To be implemented in child classes'
end #token
# Checks if token is still valid or has been revoked
# @return bool
def token_valid?
raise 'To be implemented in child classes'
end #token_valid?
# Revokes current set token
def revoke_token
raise 'To be implemented in child classes'
end #revoke_token
private_class_method :new
protected
# Calculates a checksum of given input
# @param origin string
# @return string
def checksum_of(origin)
#noinspection RubyArgCount
Zlib::crc32(origin).to_s
end
def supported_extensions
CartoDB::Importer2::Unp::SUPPORTED_FORMATS
.concat(CartoDB::Importer2::Unp::COMPRESSED_EXTENSIONS)
end
private
def check_config(config, param, datasource_name)
raise MissingConfigurationError.new("missing #{param}", datasource_name) unless config.include?(param)
end
end
end
end
@@ -0,0 +1,147 @@
require_relative './url/arcgis'
require_relative './url/dropbox'
require_relative './url/box'
require_relative './url/gdrive'
require_relative './url/instagram_oauth'
require_relative './url/mailchimp'
require_relative './url/public_url'
require_relative 'search/twitter'
module CartoDB
module Datasources
class DatasourcesFactory
NAME = 'DatasourcesFactory'.freeze
# in seconds
HTTP_CONNECT_TIMEOUT = 60
DEFAULT_HTTP_REQUEST_TIMEOUT = 600
# Retrieve a datasource instance
# @param datasource_name string
# @param user ::User
# @param additional_config Hash
# {
# :redis_storage => Redis|nil
# :ogr2ogr_instance => Ogr2ogr|nil
# }
# @return mixed
# @throws MissingConfigurationError
def self.get_datasource(datasource_name, user, additional_config = {})
if additional_config[:http_timeout].nil?
additional_config[:http_timeout] = DEFAULT_HTTP_REQUEST_TIMEOUT
end
if additional_config[:http_connect_timeout].nil?
additional_config[:http_connect_timeout] = HTTP_CONNECT_TIMEOUT
end
case datasource_name
when Url::Dropbox::DATASOURCE_NAME
Url::Dropbox.get_new(DatasourcesFactory.config_for(datasource_name, user), user)
when Url::Box::DATASOURCE_NAME
Url::Box.get_new(DatasourcesFactory.config_for(datasource_name, user), user)
when Url::GDrive::DATASOURCE_NAME
Url::GDrive.get_new(DatasourcesFactory.config_for(datasource_name, user), user)
when Url::InstagramOAuth::DATASOURCE_NAME
Url::InstagramOAuth.get_new(DatasourcesFactory.config_for(datasource_name, user), user)
when Url::PublicUrl::DATASOURCE_NAME
Url::PublicUrl.get_new(additional_config)
when Url::ArcGIS::DATASOURCE_NAME
Url::ArcGIS.get_new(user)
when Url::MailChimp::DATASOURCE_NAME
Url::MailChimp.get_new(DatasourcesFactory.config_for(datasource_name, user).merge(additional_config), user)
when Search::Twitter::DATASOURCE_NAME
Search::Twitter.get_new(DatasourcesFactory.config_for(datasource_name, user), user,
additional_config[:redis_storage], additional_config[:user_defined_limits])
when nil
nil
else
raise MissingConfigurationError.new("unrecognized datasource #{datasource_name}", NAME)
end
end
# Gets all available oauth datasources
def self.get_all_oauth_datasources
[
Url::Dropbox::DATASOURCE_NAME,
Url::Box::DATASOURCE_NAME,
Url::GDrive::DATASOURCE_NAME,
# Url::InstagramOAuth::DATASOURCE_NAME,
Url::MailChimp::DATASOURCE_NAME
]
end
# Gets the config of a certain datasource
# @param datasource_name string
# @param user ::User
# @return string
# @throws MissingConfigurationError
def self.config_for(datasource_name, user)
config, datasource_supports_custom_config = get_config(datasource_name)
if datasource_supports_custom_config
key = customized_config_key(config, datasource_name, user)
if key.nil?
config[datasource_name][:standard.to_s]
else
# This code assumes config is ok
name_config_map = config[datasource_name]['entity_to_config_map'].select { |u| !u[key].nil? }.first
config[datasource_name][:customized.to_s][name_config_map[key]]
end
else
config.fetch(datasource_name)
end
end
def self.customized_config?(datasource_name, user)
config, datasource_supports_custom_config = get_config(datasource_name)
datasource_supports_custom_config && customized_config_key(config, datasource_name, user).present?
end
# Allows to set a custom config (useful for testing)
# @param custom_config string
def self.set_config(custom_config)
@forced_config = custom_config
end
def self.get_config(datasource_name)
config_source = @forced_config ? @forced_config : Cartodb.config
datasource_supports_custom_config = false
case datasource_name
when Url::Dropbox::DATASOURCE_NAME, Url::Box::DATASOURCE_NAME, Url::GDrive::DATASOURCE_NAME, Url::InstagramOAuth::DATASOURCE_NAME,
Url::MailChimp::DATASOURCE_NAME
config = (config_source[:oauth] rescue nil)
config ||= (config_source[:oauth.to_s] rescue nil)
when Search::Twitter::DATASOURCE_NAME
config = (config_source[:datasource_search] rescue nil)
config ||= (config_source[:datasource_search.to_s] rescue nil)
datasource_supports_custom_config = true
else
config = nil
end
if config.nil? || config.empty?
raise MissingConfigurationError.new("missing configuration for datasource #{datasource_name}", NAME)
end
[config, datasource_supports_custom_config]
end
private_class_method :get_config
def self.customized_config_key(config, datasource_name, user)
custom_config_orgs = config[datasource_name].fetch(:customized_orgs_list.to_s, [])
custom_config_users = config[datasource_name][:customized_user_list.to_s]
if user.organization_user? && custom_config_orgs.include?(user.organization.name)
user.organization.name
elsif custom_config_users.include?(user.username)
user.username
end
end
private_class_method :customized_config_key
end
end
end
@@ -0,0 +1,25 @@
module CartoDB
module Datasources
module Decorators
class BaseDecorator
# @return bool
def decorates_layer?
raise 'To be implemented in child classes'
end
# @param layer Layer|nil
# @return bool
def layer_eligible?(layer=nil)
raise 'To be implemented in child classes'
end
# @param layer Layer|nil
def decorate_layer!(layer=nil)
raise 'To be implemented in child classes'
end
end
end
end
end
@@ -0,0 +1,27 @@
require_relative '../url/instagram_oauth'
require_relative './base_decorator'
require_relative './instagram_decorator'
require_relative './mailchimp_decorator'
module CartoDB
module Datasources
module Decorators
class Factory
def self.decorator_for(data_import_service_name='')
case data_import_service_name
when Url::InstagramOAuth::DATASOURCE_NAME
Decorators::InstagramDecorator.new
when Url::MailChimp::DATASOURCE_NAME
Decorators::MailchimpDecorator.new
else
nil
end
end
end
end
end
end
@@ -0,0 +1,46 @@
require_relative './base_decorator'
module CartoDB
module Datasources
module Decorators
class InstagramDecorator < BaseDecorator
# @return bool
def decorates_layer?
true
end
# @param layer Layer|nil
# @return bool
def layer_eligible?(layer=nil)
return false if layer.nil?
# Only data/cartodb layers
return false unless layer.respond_to?(:data_layer?)
layer.data_layer?
end
# @param layer Layer|nil
def decorate_layer!(layer=nil)
return nil unless layer_eligible?(layer)
layer.infowindow = {
fields: [
{position: 0, name: "thumbnail", title: true},
{position: 1, name: "caption", title: true},
{position: 2, name: "comments_count", title: true},
{position: 3, name: "likes_count", title: true},
{position: 4, name: "link", title: true}
],
template_name: "infowindow_header_with_image",
alternative_names: {},
maxHeight: 275,
width: 226,
template: ""
}
nil
end
end
end
end
end
@@ -0,0 +1,135 @@
require_relative './base_decorator'
module CartoDB
module Datasources
module Decorators
class MailchimpDecorator < BaseDecorator
CATEGORY_COLUMN = 'opened'
CSS_PROPERTIES = {
"marker-opacity" => 1,
"marker-fill-opacity" => 0.5,
"marker-line-color" => "#FFF",
"marker-line-width" => 1,
"marker-line-opacity" => 1,
"marker-placement" => "point",
"marker-type" => "ellipse",
"marker-width" => 6,
"marker-allow-overlap" => true,
"marker-comp-op" => 'multiply'
}
CATEGORIES = [
{
title: true,
color: "#A53ED5"
},
{
title: false,
color: "#00ceff"
}
]
# @return bool
def decorates_layer?
true
end
# @param layer Layer|nil
# @return bool
def layer_eligible?(layer=nil)
return false if layer.nil?
# Only data/cartodb layers
return false unless layer.respond_to?(:data_layer?)
layer.data_layer?
end
# @param layer Layer|nil
def decorate_layer!(layer=nil)
return nil unless layer_eligible?(layer)
enable_category_wizard(layer)
enable_category_legend(layer)
set_carto_css(layer)
nil
end
private
def enable_category_wizard(layer)
wizard_properties = {
type: "category",
properties: {
property: CATEGORY_COLUMN,
"geometry_type" => "point",
categories: []
}
}
wizard_properties[:properties].merge!(CSS_PROPERTIES)
wizard_properties[:properties][:categories] = CATEGORIES.map do |category|
{
"title" => category[:title],
"title_type" => "boolean",
"color" => category[:color],
"value_type" => "color"
}
end
layer.set_option('wizard_properties', wizard_properties)
end
def enable_category_legend(layer)
legend = {
"type" => "category",
"show_title" => false,
"title" => "",
"template" => "",
"visible" => true
}
legend[:items] = CATEGORIES.map do |category|
{
name: category[:title].to_s,
visible: true,
value: category[:color]
}
end
layer.set_option(:legend, legend)
end
def set_carto_css(layer)
matches = layer.options['tile_style'].match(/^#(.*) \{/)
unless matches.nil?
css_selector = "##{matches[1]}"
css_properties = CSS_PROPERTIES.map{|property, value| " #{property}: #{value};"}.join("\n")
carto_css = []
carto_css << "#{css_selector} {"
carto_css << css_properties
carto_css << " [zoom>4] {"
carto_css << " marker-width: 7;"
carto_css << " }"
carto_css << " [zoom>5] {"
carto_css << " marker-width: 8;"
carto_css << " }"
carto_css << " [zoom>6] {"
carto_css << " marker-width: 9;"
carto_css << " }"
carto_css << "}"
CATEGORIES.each do |category|
carto_css << "#{css_selector}[#{CATEGORY_COLUMN}=#{category[:title]}] {"
carto_css << " marker-fill: #{category[:color]};"
carto_css << "}"
end
layer.set_option('tile_style', carto_css.join("\n"))
layer.set_option('tile_style_custom', false)
end
end
end
end
end
end
@@ -0,0 +1,66 @@
module CartoDB
module Datasources
# Remember to add new errors to:
# config/initializers/carto_db.rb
# services/importer/lib/importer/exceptions.rb
class DatasourceBaseError < StandardError
UNKNOWN_SERVICE = 'UNKNOWN'.freeze
attr_reader :service_name
def initialize(message = 'General error', service = UNKNOWN_SERVICE, username = nil)
@service_name = service
message = "#{message}"
message << " @ #{@service_name}" if @service_name != UNKNOWN_SERVICE
message << " User: #{username}" unless username.nil?
super(message)
end
end
class AuthError < DatasourceBaseError; end
# This exception is ONLY throwed if oauth token is wrong or expired, and should be deleted if exists
class TokenExpiredOrInvalidError < AuthError; end
class InvalidServiceError < DatasourceBaseError; end
class DataDownloadError < DatasourceBaseError; end
class UnsupportedOperationError < DatasourceBaseError; end
class NotFoundDownloadError < DatasourceBaseError; end
class MissingConfigurationError < DatasourceBaseError; end
class UninitializedError < DatasourceBaseError; end
class NoResultsError < DatasourceBaseError; end
class ParameterError < DatasourceBaseError; end
class OutOfQuotaError < DatasourceBaseError; end
class InvalidInputDataError < DatasourceBaseError; end
class ResponseError < DatasourceBaseError; end
class ExternalServiceError < DatasourceBaseError; end
class GNIPServiceError < ExternalServiceError; end
class ServiceDisabledError < DatasourceBaseError
def initialize(service = UNKNOWN_SERVICE, username = nil)
super("Service disabled", service, username)
end
end
class DataDownloadTimeoutError < DatasourceBaseError
def initialize(service = UNKNOWN_SERVICE, username = nil)
super("Data download timed out. Check the source is not running slow and/or try again.", service, username)
end
end
class ExternalServiceTimeoutError < DatasourceBaseError
def initialize(service = UNKNOWN_SERVICE, username = nil)
super("External service timed out. Check the source is not running slow and/or try again.", service, username)
end
end
class DatasourcePermissionError < DatasourceBaseError; end
class DropboxPermissionError < DatasourcePermissionError; end
class BoxPermissionError < DatasourcePermissionError; end
class GDriveNoExternalAppsAllowedError < DatasourceBaseError; end
end
end
@@ -0,0 +1,597 @@
require 'json'
require_relative '../util/csv_file_dumper'
require_relative '../../../../twitter-search/twitter-search'
require_relative '../../../../../lib/cartodb/logger'
require_relative '../base_file_stream'
module CartoDB
module Datasources
module Search
# NOTE: 'redis_storage' is only sent in normal imports, not at OAuth or Synchronizations,
# as this datasource is not intended to be used in such.
class Twitter < BaseFileStream
# Required for all datasources
DATASOURCE_NAME = 'twitter_search'
NO_TOTAL_RESULTS = -1
MAX_CATEGORIES = 4
DEBUG_FLAG = false
# Used for each query page size, not as total
FILTER_MAXRESULTS = :maxResults
FILTER_FROMDATE = :fromDate
FILTER_TODATE = :toDate
FILTER_CATEGORIES = :categories
FILTER_TOTAL_RESULTS = :totalResults
USER_LIMITS_FILTER_CREDITS = :twitter_credits_limit
CATEGORY_NAME_KEY = :name
CATEGORY_TERMS_KEY = :terms
GEO_SEARCH_FILTER = 'has:geo'
PROFILE_GEO_SEARCH_FILTER = 'has:profile_geo'
OR_SEARCH_FILTER = 'OR'
# Seconds to substract from current time as threshold to consider a time
# as "now or from the future" upon date filter build
TIMEZONE_THRESHOLD = 60
# Gnip's 30 limit minus 'has:geo' one
MAX_SEARCH_TERMS = 30 - 1
MAX_QUERY_SIZE = 2048
MAX_TABLE_NAME_SIZE = 30
# Constructor
# @param config Array
# [
# 'auth_required'
# 'username'
# 'password'
# 'search_url'
# ]
# @param user ::User
# @param redis_storage Redis|nil (optional)
# @param user_defined_limits Hash|nil (optional)
# @throws UninitializedError
def initialize(config, user, redis_storage = nil, user_defined_limits={})
@service_name = DATASOURCE_NAME
@filters = Hash.new
raise UninitializedError.new('missing user instance', DATASOURCE_NAME) if user.nil?
raise MissingConfigurationError.new('missing auth_required', DATASOURCE_NAME) unless config.include?('auth_required')
raise MissingConfigurationError.new('missing username', DATASOURCE_NAME) unless config.include?('username')
raise MissingConfigurationError.new('missing password', DATASOURCE_NAME) unless config.include?('password')
raise MissingConfigurationError.new('missing search_url for GNIP API', DATASOURCE_NAME) unless config.include?('search_url')
@user_defined_limits = user_defined_limits
@search_api_config = {
TwitterSearch::SearchAPI::CONFIG_AUTH_REQUIRED => config['auth_required'],
TwitterSearch::SearchAPI::CONFIG_AUTH_USERNAME => config['username'],
TwitterSearch::SearchAPI::CONFIG_AUTH_PASSWORD => config['password'],
TwitterSearch::SearchAPI::CONFIG_SEARCH_URL => config['search_url'],
TwitterSearch::SearchAPI::CONFIG_REDIS_RL_ACTIVE => config.fetch('ratelimit_active', nil),
TwitterSearch::SearchAPI::CONFIG_REDIS_RL_MAX_CONCURRENCY => config.fetch('ratelimit_concurrency', nil),
TwitterSearch::SearchAPI::CONFIG_REDIS_RL_TTL => config.fetch('ratelimit_ttl', nil),
TwitterSearch::SearchAPI::CONFIG_REDIS_RL_WAIT_SECS => config.fetch('ratelimit_wait_secs', nil)
}
@redis_storage = redis_storage
@csv_dumper = CSVFileDumper.new(TwitterSearch::JSONToCSVConverter.new, DEBUG_FLAG)
@user = user
@data_import_item = nil
@logger = nil
@used_quota = 0
@user_semaphore = Mutex.new
end
# Factory method
# @param config {}
# @param user ::User
# @param redis_storage Redis|nil
# @param user_defined_limits Hash|nil
# @return CartoDB::Datasources::Search::TwitterSearch
def self.get_new(config, user, redis_storage = nil, user_defined_limits={})
return new(config, user, redis_storage, user_defined_limits)
end
# If will provide a url to download the resource, or requires calling get_resource()
# @return bool
def providers_download_url?
false
end
# Perform the listing and return results
# @param filter Array : (Optional) filter to specify which resources to retrieve. Leave empty for all supported.
# @return [ { :id, :title, :url, :service } ]
def get_resources_list(filter=[])
filter
end
# @param id string
# @param stream Stream
# @return Integer bytes streamed
def stream_resource(id, stream)
unless has_enough_quota?(@user)
raise OutOfQuotaError.new("#{@user.username} out of quota for tweets", DATASOURCE_NAME)
end
raise ServiceDisabledError.new(DATASOURCE_NAME, @user.username) unless is_service_enabled?(@user)
fields_from(id)
do_search(@search_api_config, @redis_storage, @filters, stream)
end
# Retrieves a resource and returns its contents
# @param id string Will contain a stringified JSON
# @return mixed
# @throws ServiceDisabledError
# @throws OutOfQuotaError
# @throws ParameterError
# @deprecated Use stream_resource instead
def get_resource(id)
unless has_enough_quota?(@user)
raise OutOfQuotaError.new("#{@user.username} out of quota for tweets", DATASOURCE_NAME)
end
raise ServiceDisabledError.new(DATASOURCE_NAME, @user.username) unless is_service_enabled?(@user)
fields_from(id)
do_search(@search_api_config, @redis_storage, @filters, stream = nil)
end
# @param id string
# @return Hash
def get_resource_metadata(id)
fields_from(id)
{
id: id,
title: DATASOURCE_NAME,
url: nil,
service: DATASOURCE_NAME,
checksum: nil,
size: 0,
filename: "#{table_name}.csv"
}
end
# Retrieves current filters. Unused as here there's no get_resources_list
# @return {}
def filter
{}
end
# Sets current filters. Unused as here there's no get_resources_list
# @param filter_data {}
def filter=(filter_data=[])
filter_data
end
# Hide sensitive fields
def to_s
"<CartoDB::Datasources::Search::Twitter @user=#{@user.username} @filters=#{@filters} @search_api_config=#{search_api_config_public_values}>"
end
# If this datasource accepts a data import instance
# @return Boolean
def persists_state_via_data_import?
true
end
# Stores the data import item instance to use/manipulate it
# @param value DataImport
def data_import_item=(value)
@data_import_item = value
end
def set_audit_to_completed(table_id = nil)
entry = audit_entry.class.where(data_import_id:@data_import_item.id).first
raise DatasourceBaseError.new("Couldn't fetch SearchTweet entry for data import #{@data_import_item.id}", \
DATASOURCE_NAME) if entry.nil?
entry.set_complete_state
entry.table_id = table_id unless table_id.nil?
entry.save
end
def set_audit_to_failed
entry = audit_entry.class.where(data_import_id:@data_import_item.id).first
raise DatasourceBaseError.new("Couldn't fetch SearchTweet entry for data import #{@data_import_item.id}", \
DATASOURCE_NAME) if entry.nil?
entry.set_failed_state
entry.save
end
# @return Hash
def get_audit_stats
entry = audit_entry.class.where(data_import_id:@data_import_item.id).first
raise DatasourceBaseError.new("Couldn't fetch SearchTweet entry for data import #{@data_import_item.id}", \
DATASOURCE_NAME) if entry.nil?
{ :retrieved_items => entry.retrieved_items }
end
private
# Used at specs
attr_accessor :search_api_config, :csv_dumper
attr_reader :data_import_item
def search_api_config_public_values
{
TwitterSearch::SearchAPI::CONFIG_AUTH_REQUIRED =>
@search_api_config[TwitterSearch::SearchAPI::CONFIG_AUTH_REQUIRED],
TwitterSearch::SearchAPI::CONFIG_AUTH_USERNAME =>
@search_api_config[TwitterSearch::SearchAPI::CONFIG_AUTH_USERNAME],
TwitterSearch::SearchAPI::CONFIG_SEARCH_URL =>
@search_api_config[TwitterSearch::SearchAPI::CONFIG_SEARCH_URL]
}
end
# Returns if the user set a maximum credits to use
# @return Integer
def twitter_credit_limits
@user_defined_limits.fetch(USER_LIMITS_FILTER_CREDITS, 0)
end
# Wraps check of specified user limit or not (to use instead his max quota)
# @return Integer
def remaining_quota
twitter_credit_limits > 0 ? [@user.remaining_twitter_quota, twitter_credit_limits].min
: @user.remaining_twitter_quota
end
def table_name
terms_fragment = @filters[FILTER_CATEGORIES].map { |category|
clean_category(category[CATEGORY_TERMS_KEY]).gsub(/[^0-9a-z,]/i, '').gsub(/[,]/i, '_')
}.join('_').slice(0,MAX_TABLE_NAME_SIZE)
"twitter_#{terms_fragment}"
end
def clean_category(category)
category.gsub(" (#{GEO_SEARCH_FILTER} OR #{PROFILE_GEO_SEARCH_FILTER})", '')
.gsub(" #{OR_SEARCH_FILTER} ", ', ')
.gsub(/^\(/, '')
.gsub(/\)$/, '')
end
def fields_from(id)
return unless @filters.count == 0
fields = ::JSON.parse(id, symbolize_names: true)
@filters[FILTER_CATEGORIES] = build_queries_from_fields(fields)
if @filters[FILTER_CATEGORIES].size > MAX_CATEGORIES
raise ParameterError.new("Max allowed categories are #{FILTER_CATEGORIES}", DATASOURCE_NAME)
end
@filters[FILTER_FROMDATE] = build_date_from_fields(fields, 'from')
@filters[FILTER_TODATE] = build_date_from_fields(fields, 'to')
@filters[FILTER_MAXRESULTS] = build_maxresults_field(@user)
@filters[FILTER_TOTAL_RESULTS] = build_total_results_field(@user)
end
# Signature must be like: .report_message('Import error', 'error', error_info: stacktrace)
def report_error(message, additional_data)
log("Error: #{message} Additional Info: #{additional_data}")
CartoDB::Logger.error(message: message, error_info: additional_data)
end
# @param api_config Hash
# @param redis_storage Mixed
# @param filters Hash
# @param stream IO
# @return Mixed The data
def do_search(api_config, redis_storage, filters, stream)
threads = {}
base_filters = filters.select { |k, v| k != FILTER_CATEGORIES }
category_totals = {}
dumper_additional_fields = {}
filters[FILTER_CATEGORIES].each { |category|
dumper_additional_fields[category[CATEGORY_NAME_KEY]] = {
category_name: category[CATEGORY_NAME_KEY],
category_terms: clean_category(category[CATEGORY_TERMS_KEY])
}
@csv_dumper.begin_dump(category[CATEGORY_NAME_KEY])
}
@csv_dumper.additional_fields = dumper_additional_fields
log("Searching #{filters[FILTER_CATEGORIES].length} categories")
filters[FILTER_CATEGORIES].each { |category|
# If all threads are created at the same time, redis semaphore inside search_api
# might not yet have new value, so introduce a small delay on each thread creation
sleep(0.1)
threads[category[CATEGORY_NAME_KEY]] = Thread.new {
api = TwitterSearch::SearchAPI.new(api_config, redis_storage, @csv_dumper)
# Dumps happen inside upon each block response
total_results = search_by_category(api, base_filters, category)
category_totals[category[CATEGORY_NAME_KEY]] = total_results
}
}
threads.each {|key, thread|
thread.join
}
# INFO: For now we don't treat as error a no results scenario, else use:
# raise NoResultsError.new if category_totals.values.inject(:+) == 0
filters[FILTER_CATEGORIES].each { |category|
@csv_dumper.end_dump(category[CATEGORY_NAME_KEY])
}
streamed_size = @csv_dumper.merge_dumps_into_stream(dumper_additional_fields.keys, stream)
log("Temp files:\n#{@csv_dumper.file_paths}")
log("#{@csv_dumper.original_file_paths}\n#{@csv_dumper.headers_path}")
if twitter_credit_limits > 0 || !@user.soft_twitter_datasource_limit
if (remaining_quota - @used_quota) < 0
# Make sure we don't charge extra tweets (even if we "lose" charging a block or two of tweets)
@used_quota = remaining_quota
end
end
# remaining quota is calc. on the fly based on audits/imports
save_audit(@user, @data_import_item, @used_quota)
streamed_size
end
def search_by_category(api, base_filters, category)
api.params = base_filters
exception = nil
next_results_cursor = nil
total_results = 0
begin
exception = nil
out_of_quota = false
@user_semaphore.synchronize {
# Credit limits must be honoured above soft limit
if twitter_credit_limits > 0 || !@user.soft_twitter_datasource_limit
if remaining_quota - @used_quota <= 0
out_of_quota = true
next_results_cursor = nil
end
end
}
unless out_of_quota
api.query_param = category[CATEGORY_TERMS_KEY]
begin
results_page = api.fetch_results(next_results_cursor)
rescue TwitterSearch::TwitterHTTPException => e
exception = e
report_error(e.to_s, e.backtrace)
# Stop gracefully to not break whole import process
results_page = {
results: [],
next: nil
}
end
dumped_items_count = @csv_dumper.dump(category[CATEGORY_NAME_KEY], results_page[:results])
next_results_cursor = results_page[:next].nil? ? nil : results_page[:next]
@user_semaphore.synchronize {
@used_quota += dumped_items_count
}
total_results += dumped_items_count
end
end while (!next_results_cursor.nil? && !out_of_quota && !exception)
log("'#{category[CATEGORY_NAME_KEY]}' got #{total_results} results")
log("Got exception at '#{category[CATEGORY_NAME_KEY]}': #{exception.inspect}") if exception
# If fails on the first request, bubble up the error, else will return as many tweets as possible
if !exception.nil? && total_results == 0
log("ERROR: 0 results & exception: #{exception} (HTTP #{exception.http_code}) #{exception.additional_data}")
# @see http://support.gnip.com/apis/search_api/api_reference.html
if exception.http_code == 422 && exception.additional_data =~ /request usage cap exceeded/i
raise OutOfQuotaError.new(exception.to_s, DATASOURCE_NAME)
end
if [401, 404].include?(exception.http_code)
raise MissingConfigurationError.new(exception.to_s, DATASOURCE_NAME)
end
if [400, 422].include?(exception.http_code)
raise InvalidInputDataError.new(exception.to_s, DATASOURCE_NAME)
end
if exception.http_code == 429
raise ResponseError.new(exception.to_s, DATASOURCE_NAME)
end
if exception.http_code >= 500 && exception.http_code < 600
raise GNIPServiceError.new(exception.to_s, DATASOURCE_NAME)
end
raise DatasourceBaseError.new(exception.to_s, DATASOURCE_NAME)
end
total_results
end
def build_date_from_fields(fields, date_type)
raise ParameterError.new('missing dates', DATASOURCE_NAME) \
if fields[:dates].nil?
case date_type
when 'from'
date_sym = :fromDate
hour_sym = :fromHour
min_sym = :fromMin
when 'to'
date_sym = :toDate
hour_sym = :toHour
min_sym = :toMin
else
raise ParameterError.new("unknown date type #{date_type}", DATASOURCE_NAME)
end
if fields[:dates][date_sym].nil? || fields[:dates][hour_sym].nil? || fields[:dates][min_sym].nil?
date = nil
else
# Sent by JS in minutes
timezone = fields[:dates][:user_timezone].nil? ? 0 : fields[:dates][:user_timezone].to_i
begin
year, month, day = fields[:dates][date_sym].split('-')
timezoned_date = Time.gm(year, month, day, fields[:dates][hour_sym], fields[:dates][min_sym])
rescue ArgumentError
raise ParameterError.new('Invalid date format', DATASOURCE_NAME)
end
timezoned_date += timezone*60
# Gnip doesn't allows searches "in the future"
date = timezoned_date >= (Time.now - TIMEZONE_THRESHOLD).utc ? nil : timezoned_date.strftime("%Y%m%d%H%M")
end
date
end
def build_queries_from_fields(fields)
raise ParameterError.new('missing categories', DATASOURCE_NAME) \
if fields[:categories].nil? || fields[:categories].empty?
queries = []
fields[:categories].each { |category|
raise ParameterError.new('missing category', DATASOURCE_NAME) if category[:category].nil?
raise ParameterError.new('missing terms', DATASOURCE_NAME) if category[:terms].nil?
# Gnip limitation
if category[:terms].count > MAX_SEARCH_TERMS
category[:terms] = category[:terms].slice(0, MAX_SEARCH_TERMS)
end
category[:terms] = sanitize_terms(category[:terms])
query = {
CATEGORY_NAME_KEY => category[:category].to_s,
CATEGORY_TERMS_KEY => ''
}
unless category[:terms].count == 0
query[CATEGORY_TERMS_KEY] << '('
query[CATEGORY_TERMS_KEY] << category[:terms].join(' OR ')
query[CATEGORY_TERMS_KEY] << ") (#{GEO_SEARCH_FILTER} OR #{PROFILE_GEO_SEARCH_FILTER})"
end
if query[CATEGORY_TERMS_KEY].length > MAX_QUERY_SIZE
raise ParameterError.new("Obtained search query is bigger than #{MAX_QUERY_SIZE} chars", DATASOURCE_NAME)
end
queries << query
}
queries
end
# @param terms_list Array
def sanitize_terms(terms_list)
terms_list.map{ |term|
# Remove unwanted stuff
sanitized = term.to_s.gsub(/^ /, '').gsub(/ $/, '').gsub('"', '')
# Quote if needed
if sanitized.gsub(/[a-z0-9@#]/i,'') != ''
sanitized = '"' + sanitized + '"'
end
sanitized.length == 0 ? nil : sanitized
}.compact
end
# Max results per page
# @param user ::User
def build_maxresults_field(user)
if twitter_credit_limits > 0
[remaining_quota, TwitterSearch::SearchAPI::MAX_PAGE_RESULTS].min
else
# user about to hit quota?
if remaining_quota < TwitterSearch::SearchAPI::MAX_PAGE_RESULTS
if user.soft_twitter_datasource_limit
# But can go beyond limits
TwitterSearch::SearchAPI::MAX_PAGE_RESULTS
else
remaining_quota
end
else
TwitterSearch::SearchAPI::MAX_PAGE_RESULTS
end
end
end
# Max total results
# @param user ::User
def build_total_results_field(user)
if twitter_credit_limits == 0 && user.soft_twitter_datasource_limit
NO_TOTAL_RESULTS
else
remaining_quota
end
end
# @param user ::User
def is_service_enabled?(user)
if !user.organization.nil?
enabled = user.organization.twitter_datasource_enabled
if enabled
user.twitter_datasource_enabled
else
# If disabled org-wide, disabled for everyone
false
end
else
user.twitter_datasource_enabled
end
end
# @param user ::User
# @return boolean
def has_enough_quota?(user)
# As this is used to disallow searches (and throw exceptions) don't use here user limits
user.soft_twitter_datasource_limit || (user.remaining_twitter_quota > 0)
end
# @param user ::User
# @param data_import_item DataImport
# @param retrieved_items_count Integer
def save_audit(user, data_import_item, retrieved_items_count)
entry = audit_entry
entry.set_importing_state
entry.user_id = user.id
entry.data_import_id = data_import_item.id
entry.service_item_id = data_import_item.service_item_id
entry.retrieved_items = retrieved_items_count
entry.save
end
# Call this inside specs to override returned class
# @param override_class SearchTweet|nil (optional)
# @return SearchTweet
def audit_entry(override_class = nil)
if @audit_entry.nil?
if override_class.nil?
require_relative '../../../../../app/models/search_tweet'
@audit_entry = ::SearchTweet.new
else
@audit_entry = override_class.new
end
end
@audit_entry
end
end
end
end
end
@@ -0,0 +1,540 @@
require 'json'
require 'addressable/uri'
require_relative '../base_direct_stream'
require_relative '../../../../../lib/carto/http/client'
module CartoDB
module Datasources
module Url
class ArcGIS < BaseDirectStream
# Required for all datasources
DATASOURCE_NAME = 'arcgis'
ARCGIS_API_LIKE_URL_RE = /\/rest\/services/i
METADATA_URL = '%s?f=json'
FEATURE_IDS_URL = '%s/query?where=1%%3D1&returnIdsOnly=true&f=json'
FEATURE_DATA_POST_URL = '%s/query'
LAYERS_URL = '%s/layers?f=json'
MINIMUM_SUPPORTED_VERSION = 10.1
# In seconds, for connecting
HTTP_CONNECTION_TIMEOUT = 60
# In seconds, for the full request
HTTP_TIMEOUT = 60
# Amount to multiply or divide
BLOCK_FACTOR = 2
MIN_BLOCK_SIZE = 1
# GeoJSON can get too big in memory, or ArcGIS have mem problems, so keep reasonable number
MAX_BLOCK_SIZE = 100
# In seconds, use 0 to disable
BLOCK_SLEEP_TIME = 1
# Each retry will be after SLEEP_REQUEST_TIME^(current_retries_count). Set to 0 to disable retrying
MAX_RETRIES = 0
SLEEP_REQUEST_TIME = 3
SKIP_FAILED_IDS = true
# Used to display more data only (for local debugging purposes)
DEBUG = false
VECTOR_LAYER_TYPE = 'Feature Layer'.freeze
OID_FIELD_TYPE = 'esriFieldTypeOID'.freeze
attr_reader :metadata
# Constructor
# @param user ::User
def initialize(user)
super
@service_name = DATASOURCE_NAME
# Fields:
# @metadata = {
# arcgis_version: nil,
# name: nil,
# description: nil,
# type: nil,
# geometry_type: nil,
# copyright: nil,
# fields: [],
# max_records_per_query: 500,
# supported_formats: [],
# advanced_queries_supported: false
# }
@metadata = nil
@user = user
@url = nil
@ids_total = 0
@ids_retrieved = 0
@block_size = 0
@current_stream_status = true
@last_stream_status = true
@ids = nil
end
# Factory method
# @param user ::User
# @return CartoDB::Datasources::Url::ArcGIS
def self.get_new(user)
return new(user)
end
# @return String
def to_s
"<CartoDB::Datasources::Url::ArcGis @url=#{@url} @metadata=#{@metadata} @ids_total=#{@ids_total}" +
" @ids_retrieved=#{@ids_retrieved} current_block_size=#{block_size(update=false)}>"
end
# If will provide a url to download the resource, or requires calling get_resource()
# @return Bool
def providers_download_url?
false
end
# Perform the listing and return results
# @param filter Array : (Optional) filter to specify which resources to retrieve. Leave empty for all supported.
# @return [ Hash ]
def get_resources_list(filter=[])
filter
end
# Retrieves a resource and returns its contents
# @param id string
# @return mixed
def get_resource(id)
raise 'Not supported by this datasource'
end
# Initial stream, to be used for container creation (table usually)
# @param id string
# @return String
def initial_stream(id)
sub_id = get_subresource_id(id)
@url = sanitize_id(id, sub_id)
@ids = get_ids_list(@url)
@ids_total = @ids.length
first_item = get_by_ids(@url, [@ids.slice!(0)], @metadata[:fields])
@ids_retrieved += 1
# Start optimistic
@block_size = [MAX_BLOCK_SIZE, @metadata[:max_records_per_query]].min
::JSON.dump(first_item)
end
# @param id string
# @return String|nil Nil if no more items
def stream_resource(id)
return nil if @ids.empty?
retries = 0
begin
ids_block = @ids.slice!(0, [@ids.length, block_size].min)
puts "#{@ids_retrieved}/#{@ids_total} (#{ids_block.length})" if DEBUG
items = get_by_ids(@url, ids_block, @metadata[:fields])
@last_stream_status = @current_stream_status
@current_stream_status = true
retries = 0
sleep(BLOCK_SLEEP_TIME) unless BLOCK_SLEEP_TIME == 0
rescue ExternalServiceError => exception
if @block_size == MIN_BLOCK_SIZE && retries >= MAX_RETRIES
if SKIP_FAILED_IDS
items = []
else
raise exception
end
else
@last_stream_status = @current_stream_status
@current_stream_status = false
# Add back, next pass will get fewer items
@ids = ids_block + @ids
if @block_size == MIN_BLOCK_SIZE
retries += 1
sleep_time = SLEEP_REQUEST_TIME ** retries
puts "Retry delay (#{sleep_time}s)" if DEBUG
sleep(sleep_time)
end
retry
end
end
@ids_retrieved += ids_block.length
items.length > 0 ? ::JSON.dump(items) : ''
end
# @param id string
# @return Hash
# @throws DataDownloadError
# @throws ResponseError
# @throws InvalidServiceError
# @throws ServiceDisabledError
def get_resource_metadata(id)
if is_multiresource?(id)
@url = sanitize_id(id)
{
# Store original id, not the sanitized one
id: id,
subresources: get_layers_list(@url)
}
else
sub_id = get_subresource_id(id)
get_subresource_metadata(id, sub_id)
end
end
# Retrieves current filters. Unused as here there's no get_resources_list
# @return {}
def filter
{}
end
# Sets current filters. Unused as here there's no get_resources_list
# @param filter_data {}
def filter=(filter_data=[])
filter_data
end
# If this datasource accepts a data import instance
# @return Boolean
def persists_state_via_data_import?
false
end
# If true, a single resource id might return >1 subresources (each one spawning a table)
# @param id String
# @return Bool
def multi_resource_import_supported?(id)
is_multiresource?(id)
end
private
def http_client
@http_client ||= Carto::Http::Client.get('arcgis')
end
# @param id String
# @param subresource_id String
# @return Hash
# @throws DataDownloadError
# @throws ResponseError
# @throws InvalidServiceError
def get_subresource_metadata(id, subresource_id)
@url = sanitize_id(id, subresource_id)
response = http_client.get(METADATA_URL % [@url], http_options)
validate_response(METADATA_URL % [@url], response)
# non-rails symbolize keys
data = ::JSON.parse(response.body).inject({}){|memo,(k,v)| memo[k.to_sym] = v; memo}
raise ResponseError.new("Invalid layer type: '#{data[:type]}'") if data[:type] != VECTOR_LAYER_TYPE
raise ResponseError.new("Missing data: 'fields'") if data[:fields].nil?
if data[:supportedQueryFormats].present?
supported_formats = data.fetch(:supportedQueryFormats).gsub(' ', '').split(',')
else
supported_formats = []
end
begin
@metadata = {
arcgis_version: data.fetch(:currentVersion),
name: data.fetch(:name),
description: data.fetch(:description, ''),
type: data.fetch(:type),
geometry_type: data.fetch(:geometryType),
copyright: data.fetch(:copyrightText, ''),
fields: data.fetch(:fields).try(:map) { |field|
{
name: field['name'],
type: field['type']
}
},
max_records_per_query: data.fetch(:maxRecordCount, 500),
supported_formats: supported_formats,
advanced_queries_supported: data.fetch(:supportsAdvancedQueries, false)
}
rescue => exception
raise ResponseError.new("Missing data: #{exception.to_s} #{exception.backtrace}")
end
raise InvalidServiceError.new("Unsupported ArcGIS version #{@metadata[:arcgis_Version]}, must be >= #{MINIMUM_SUPPORTED_VERSION}") \
if @metadata[:arcgis_version] < MINIMUM_SUPPORTED_VERSION
{
id: id,
title: @metadata[:name],
url: nil,
service: DATASOURCE_NAME,
checksum: nil,
size: NO_CONTENT_SIZE_PROVIDED,
filename: filename_from(@metadata[:name])
}
end
# Just detects if id is a full map or a specific layer
# @param id String
# @return Bool
def is_multiresource?(id)
unless id.rindex('?').nil?
id = id.slice(0, id.rindex('?'))
end
(id =~ /\/([0-9]+\/|[0-9]+)$/).nil?
end
def get_subresource_id(id)
id.match(/([0-9]+\/|[0-9]+)$?/)[0]
end
# @param id String
# @param sub_id String|nil
# @return String
# @throws InvalidInputDataError
def sanitize_id(id, sub_id=nil)
# http://<host>/<site>/rest/services/<folder>/<serviceName>/<serviceType>/
# <site> is almost always "arcgis" (according to official doc)
unless id =~ ARCGIS_API_LIKE_URL_RE
raise InvalidInputDataError.new("Url doesn't looks as from ArcGIS server")
end
unless id.rindex('?').nil?
id = id.slice(0, id.rindex('?'))
end
if is_multiresource?(id) && !sub_id.nil?
id = (id.end_with?('/') ? id : id + '/') + sub_id
end
id
end
# @return Array [ { :id, :title} ]
# @throws DataDownloadError
# @throws ResponseError
def get_layers_list(url)
request_url = LAYERS_URL % [url]
response = http_client.get(request_url, http_options)
validate_response(request_url, response)
begin
data = ::JSON.parse(response.body).fetch('layers')
rescue => exception
raise ResponseError.new("Missing data: #{exception.to_s} #{request_url} #{exception.backtrace}")
end
# We only support vector layers (not raster layers)
data = data.reject { |layer| layer['type'] != VECTOR_LAYER_TYPE }
raise ResponseError.new("Empty layers list #{request_url}") if data.length == 0
begin
data.collect { |item|
{
# Leave prepared all child urls
id: (url.end_with?('/') ? url : url + '/') + item.fetch('id').to_s,
title: item.fetch('name')
}
}
rescue => exception
raise ResponseError.new("Missing data: #{exception.to_s} #{request_url} #{exception.backtrace}")
end
end
# NOTE: Assumes url is valid
# NOTE: Returned ids are sorted so they can be chunked into blocks to
# be requested by range queries: `(OBJECTID >= ... AND OBJECTID <= ... )`
# @param url String
# @return Array
# @throws DataDownloadError
# @throws ResponseError
def get_ids_list(url)
request_url = FEATURE_IDS_URL % [url]
response = http_client.get(request_url, http_options)
validate_response(request_url, response)
begin
data = ::JSON.parse(response.body).fetch('objectIds').sort
rescue => exception
raise ResponseError.new("Missing data: #{exception.to_s} #{request_url} #{exception.backtrace}")
end
raise ResponseError.new("Empty ids list #{request_url}") if data.length == 0
data
end
# NOTE: Assumes url is valid
# @param url String
# @param ids Array
# @param fields Array
# @return Array [ Hash ] (non-symbolized keys)
# @throws InvalidInputDataError
# @throws DataDownloadError
# @throws ExternalServiceError
def get_by_ids(url, ids, fields)
raise InvalidInputDataError.new("'ids' empty or invalid") if (ids.nil? || ids.length == 0)
raise InvalidInputDataError.new("'fields' empty or invalid") if (fields.nil? || fields.length == 0)
oid_field = fields.find { |field| field[:type] == OID_FIELD_TYPE }
if ids.length == 1
ids_field = { objectIds: ids.first }
else
if oid_field
# Note that ids is sorted
ids_field = { where: "#{oid_field[:name]} >=#{ids.first} AND #{oid_field[:name]} <=#{ids.last}" }
else
# This could be innefficient with large number of ids, but it is limited to MAX_BLOCK_SIZE
ids_field = { objectIds: ids.join(',') }
end
end
prepared_fields = fields.map { |field| "#{field[:name]}" }.join(',')
prepared_url = FEATURE_DATA_POST_URL % [url]
# @see http://resources.arcgis.com/en/help/arcgis-rest-api/index.html#/Query_Map_Service_Layer/02r3000000p1000000/
params_data = {
outFields: prepared_fields,
outSR: 4326,
f: 'json'
}
params_data.merge! ids_field
puts "#{prepared_url} (POST) Params:#{params_data}" if DEBUG
response = http_client.post(prepared_url, http_options(params_data, :post))
# Timeout connecting to ArcGIS
if response.code == 0
raise ExternalServiceError.new("TIMEOUT: #{prepared_url} : #{response.body} #{self.to_s}")
end
if response.code != 200
raise DataDownloadError.new("ERROR: #{prepared_url} POST " +
"#{params_data} (#{response.code}) : #{response.body} #{self.to_s}")
end
if response.code == 400 && !response.return_message.nil? \
&& response.return_message.downcase.include?('operation is not supported')
raise UnsupportedOperationError.new("#{request_url} (#{response.code}) : #{response.body}") \
end
begin
body = ::JSON.parse(response.body)
success = true
rescue JSON::ParserError
success = false
end
unless success
begin
# HACK: JSON spec does not cover Infinity
body = ::JSON.parse(response.body.gsub(':INF,', ':"Infinity",'))
rescue JSON::ParserError
raise ResponseError.new("JSON parsing error. URL: #{prepared_url} #{to_s}")
end
end
# Arcgis error
raise ExternalServiceError.new("#{prepared_url} : #{response.body}") if body.include?('error')
begin
retrieved_items = body.fetch('features')
return [] if retrieved_items.nil? || retrieved_items.empty?
retrieved_fields = body.fetch('fields')
geometry_type = body.fetch('geometryType')
spatial_reference = body.fetch('spatialReference')
rescue => exception
raise ResponseError.new("Missing data: #{exception.to_s} #{prepared_url} #{exception.backtrace}")
end
raise ResponseError.new("'fields' empty or invalid #{prepared_url}") \
if (retrieved_fields.nil? || retrieved_fields.length == 0)
raise ResponseError.new("'features' empty or invalid #{prepared_url}") \
if (retrieved_items.nil? || !retrieved_items.kind_of?(Array))
# Fields can be optional, cannot be enforced to always be present
desired_fields = fields.map { |field| field[:name] }
{
geometryType: geometry_type,
spatialReference: spatial_reference,
fields: retrieved_fields,
features: retrieved_items.collect { |item|
{
'attributes' => item['attributes'].select{ |k, v| desired_fields.include?(k) },
'geometry' => item['geometry']
}
}
}
end
# By default, will update the block size, incrementing or decrementing it according to stream operation results
# Block size only gets incremented after 2 successful streams to avoid scenario of:
# X items -> FAIL
# X/2 items -> PASS
# X items -> FAIL (again, because erroring item was at second half of X)
def block_size(update=true)
if update
if @current_stream_status && @last_stream_status && @block_size < MAX_BLOCK_SIZE
@block_size = [@block_size * BLOCK_FACTOR, MAX_BLOCK_SIZE].min
end
if !@current_stream_status && @block_size > MIN_BLOCK_SIZE
@block_size = [[(@block_size / BLOCK_FACTOR).floor, 1].max, MAX_BLOCK_SIZE].min
end
@block_size = [@block_size, @metadata[:max_records_per_query]].min
end
@block_size
end
def http_options(params={}, method=:get)
{
method: method,
params: method == :get ? params : {},
body: method == :post ? params : {},
followlocation: true,
ssl_verifypeer: false,
accept_encoding: 'gzip',
headers: { 'Accept-Charset' => 'utf-8' },
ssl_verifyhost: 0,
nosignal: true,
connecttimeout: HTTP_CONNECTION_TIMEOUT,
timeout: HTTP_TIMEOUT
}
end
def filename_from(feature_name)
feature_name.gsub(/[^\w]/, '_').downcase + '.json'
end
def validate_response(request_url, response)
raise ExternalServiceTimeoutError.new("TIMEOUT: #{request_url} : #{response.return_message}") \
if response.timed_out? || (response.code.zero? && !response.return_message.nil? \
&& response.return_message.downcase.include?('timeout'))
raise UnsupportedOperationError.new("#{request_url} (#{response.code}) : #{response.body}") \
if response.code == 400 && !response.return_message.nil? \
&& response.return_message.downcase.include?('operation is not supported')
raise DataDownloadError.new("#{request_url} (#{response.code}) : #{response.body}") \
if response.code != 200
end
end
end
class URLTooLargeError < StandardError
end
end
end
@@ -0,0 +1,543 @@
require_relative '../../../../../lib/carto/http/client'
module CartoDB
module Datasources
module Url
# BoxAPI module is a replacement for Boxr, which requires Ruby >= 2.
# Most of this code has been extracted from Boxr.
# This should be migrated when we upgrade to Ruby 2.
module BoxAPI
class ExpiredTokenError < StandardError; end
def self.oauth_url(state, options = {})
host = options.fetch(:host, "app.box.com")
response_type = options.fetch(:response_type, "code")
scope = options[:scope]
folder_id = options[:folder_id]
client_id = options[:client_id]
template = Addressable::Template.new("https://{host}/api/oauth2/authorize{?query*}")
query = { "response_type" => "#{response_type}", "state" => "#{state}", "client_id" => "#{client_id}" }
query["scope"] = "#{scope}" unless scope.nil?
query["folder_id"] = "#{folder_id}" unless folder_id.nil?
template.expand("host" => "#{host}", "query" => query)
end
def self.get_tokens(code, options = {})
grant_type = options[:grant_type]
assertion = options[:assertion]
scope = options[:scope]
username = options[:username]
client_id = options[:client_id]
client_secret = options[:client_secret]
uri = "https://api.box.com/oauth2/token"
body = "grant_type=#{grant_type}&client_id=#{client_id}&client_secret=#{client_secret}"
body = body + "&code=#{code}" unless code.nil?
body = body + "&scope=#{scope}" unless scope.nil?
body = body + "&username=#{username}" unless username.nil?
body = body + "&assertion=#{assertion}" unless assertion.nil?
auth_post(uri, body)
end
def self.refresh_tokens(refresh_token, options = {})
client_id = options[:client_id]
client_secret = options[:client_secret]
uri = "https://api.box.com/oauth2/token"
body = "grant_type=refresh_token&refresh_token=#{refresh_token}&client_id=#{client_id}&client_secret=#{client_secret}"
auth_post(uri, body)
end
def self.auth_post(uri, body, json_body = true)
uri = Addressable::URI.encode(uri)
res = post(uri, body: body)
if res.response_code == 200
json_body ? JSON.parse(res.response_body) : res.response_body
else
handle_error_response(res)
end
end
def self.handle_error_response(res)
body_json = JSON.parse(res.response_body)
if body_json['error'] == 'invalid_grant'
raise ExpiredTokenError.new(body_json.fetch('error_description', 'Expired token'))
else
raise "Box Error status: #{res.response_code}, body: #{res.response_body}, headers: #{res.response_headers}"
end
rescue => e
CartoDB.notify_exception(e, self: inspect, response: res.inspect)
raise e
end
def self.post(uri, options = {})
http_client = Carto::Http::Client.get('box',
connecttimeout: 60,
timeout: 600
)
http_client.post(uri.to_s, options)
end
def self.get(uri, options = {})
query = options[:query]
header = options[:header]
follow_redirect = options[:follow_redirect]
http_client = Carto::Http::Client.get('box',
connecttimeout: 60,
timeout: 600)
response = http_client.get(uri.to_s, headers: header, followlocation: follow_redirect, params: query)
response
end
end
module BoxAPI
class Client
API_URI = "https://api.box.com/2.0"
SEARCH_URI = "#{API_URI}/search"
FILES_URI = "#{API_URI}/files"
def initialize(access_token, options = {})
client_id = options[:client_id]
client_secret = options[:client_secret]
@access_token = access_token
raise "Access token cannot be nil" if @access_token.nil?
@client_id = client_id
@client_secret = client_secret
end
def search(query, options = {})
scope = options[:scope]
file_extensions = options[:file_extensions]
created_at_range = options[:created_at_range]
updated_at_range = options[:updated_at_range]
size_range = options[:size_range]
owner_user_ids = options[:owner_user_ids]
ancestor_folder_ids = options[:ancestor_folder_ids]
content_types = options[:content_types]
type = options[:type]
limit = options.fetch(:limit, 30)
offset = options.fetch(:offset, 0)
query = { query: query }
query[:scope] = scope unless scope.nil?
query[:file_extensions] = file_extensions unless file_extensions.nil?
query[:created_at_range] = created_at_range unless created_at_range.nil?
query[:updated_at_range] = updated_at_range unless updated_at_range.nil?
query[:size_range] = size_range unless size_range.nil?
query[:owner_user_ids] = owner_user_ids unless owner_user_ids.nil?
query[:ancestor_folder_ids] = ancestor_folder_ids unless ancestor_folder_ids.nil?
query[:content_types] = content_types unless content_types.nil?
query[:type] = type unless type.nil?
query[:limit] = limit unless limit.nil?
query[:offset] = offset unless offset.nil?
results, _response = get(SEARCH_URI, query: query)
results['entries']
end
def download_url(file, options = {})
version = options[:version]
download_file(file, version: version, follow_redirect: false)
end
def download_file(file, options = {})
version = options[:version]
follow_redirect = options.fetch(:follow_redirect, true)
file_id = ensure_id(file)
begin
uri = "#{FILES_URI}/#{file_id}/content"
query = {}
query[:version] = version unless version.nil?
# Boxr didn't have 200
_body_json, response = get(uri, query: query, success_codes: [302, 202, 200], follow_redirect: false, process_response: false)
if response.response_code == 302
location = response.header['Location'][0]
if follow_redirect
file, response = get(location, process_response: false)
else
return location # simply return the url
end
elsif response.response_code == 202
retry_after_seconds = response.header['Retry-After'][0]
sleep retry_after_seconds.to_i
elsif response.response_code == 200
file = response.response_body
end
end until file
file
end
FOLDER_AND_FILE_FIELDS = [:type, :id, :sequence_id, :etag, :name, :created_at, :modified_at, :description,
:size, :path_collection, :created_by, :modified_by, :trashed_at, :purged_at,
:content_created_at, :content_modified_at, :owned_by, :shared_link,
:folder_upload_email,
:parent, :item_status, :item_collection, :sync_state, :has_collaborations,
:permissions, :tags,
:sha1, :shared_link, :version_number, :comment_count, :lock, :extension,
:is_package,
:expiring_embed_link, :can_non_owners_invite]
FOLDER_AND_FILE_FIELDS_QUERY = FOLDER_AND_FILE_FIELDS.join(',')
def file_from_id(file_id, fields = [])
file_id = ensure_id(file_id)
uri = "#{FILES_URI}/#{file_id}"
query = build_fields_query(fields, FOLDER_AND_FILE_FIELDS_QUERY)
file, _response = get(uri, query: query)
file
end
# Required for all providers
DATASOURCE_NAME = 'box'
def revoke_tokens(token)
uri = "https://api.box.com/oauth2/revoke"
body = "client_id=#{@client_id}&client_secret=#{@client_secret}&token=#{token}"
BoxAPI::auth_post(uri, body, false)
rescue => ex
raise AuthError.new("revoke_token: #{ex.message}", DATASOURCE_NAME)
end
private
def build_fields_query(fields, all_fields_query)
if fields == :all
{ fields: all_fields_query }
elsif fields.is_a?(Array) && fields.length > 0
{ fields: fields.join(',') }
else
{}
end
end
def ensure_id(item)
return item if item.class == String || item.class == Integer || item.nil?
return item.id if item.respond_to?(:id)
return item['id'] if item.class == Hash
raise "Expecting an id of class String or Integer, or object that responds to :id"
end
def get(uri, options = {})
query = options[:query]
success_codes = options.fetch(:success_codes, [200])
process_response = options.fetch(:process_response, true)
if_match = options[:if_match]
box_api_header = options[:box_api_header]
follow_redirect = options.fetch(follow_redirect, true)
headers = standard_headers
headers['If-Match'] = if_match unless if_match.nil?
headers['BoxApi'] = box_api_header unless box_api_header.nil?
res = BoxAPI::get(uri, query: query, header: headers, follow_redirect: follow_redirect)
check_response_status(res, success_codes)
if process_response
return JSON.parse(res.response_body)
else
return res.response_body, res
end
end
def check_response_status(res, success_codes)
unless success_codes.include?(res.response_code)
raise "BoxError status: #{res.response_code}, body: #{res.response_body}, header: #{res.response_headers}"
end
end
def standard_headers
{ "Authorization" => "Bearer #{@access_token}" }
end
end
end
class Box < BaseOAuth
# Required for all providers
DATASOURCE_NAME = 'box'
# Constructor (hidden)
# @param config
# [
# 'application_name'
# 'client_id'
# 'client_secret'
# ]
# @param user ::User
# @throws UninitializedError
# @throws MissingConfigurationError
def initialize(config, user)
super(config, user, %w{ application_name client_id client_secret box_host }, DATASOURCE_NAME)
raise UninitializedError.new('missing user instance', DATASOURCE_NAME) if user.nil?
@access_token = nil
@refresh_token = nil
end
# Factory method
# @param config {}
# @param user ::User
# @return CartoDB::Datasources::Url::GDrive
def self.get_new(config, user)
new(config, user)
end
# If will provide a url to download the resource, or requires calling get_resource()
# @return bool
def providers_download_url?
false
end
# Return the url to be displayed or sent the user to to authenticate and get authorization code.
# Older implementations had a use_callback_flow parameter that became deprecated. Not implemented.
# @return string | nil
def get_auth_url
service_name = service_name_for_user(DATASOURCE_NAME, @user)
state = CALLBACK_STATE_DATA_PLACEHOLDER.sub('user', @user.username).sub('service', service_name)
BoxAPI::oauth_url(state,
host: config['box_host'],
response_type: "code",
scope: nil,
folder_id: nil,
client_id: config['client_id']).to_s
end
# Validates authorization code, sets access and refresh tokens for current instance and stores (refresh) token
# @param auth_code : string
# @return string : Refresh token
# Older implementations had a use_callback_flow parameter that became deprecated. Not implemented.
# @throws AuthError
def validate_auth_code(auth_code)
set_tokens(get_tokens(auth_code))
@refresh_token
end
# Validates the authorization callback
# @param params : mixed
def validate_callback(params)
if params[:error].present?
raise AuthError.new("validate_callback: #{params[:error]}", DATASOURCE_NAME)
end
if params[:code]
validate_auth_code(params[:code])
else
raise AuthError.new('validate_callback: Missing authorization code', DATASOURCE_NAME)
end
end
# Store (refresh) token. If it's not valid both access_token and refresh_token will be nil.
# Triggers generation of a valid access token for the lifetime of this instance
# @param token string
# @throws AuthError
def token=(token)
set_tokens(get_fresh_tokens(token))
rescue CartoDB::Datasources::Url::BoxAPI::ExpiredTokenError => e
CartoDB.notify_exception(e, self: inspect, token: token)
set_tokens('access_token' => nil, 'refresh_token' => nil)
end
# Retrieve (refresh) token
# @return string | nil
def token
@refresh_token
end
# Perform the listing and return results
# @param filter Array : (Optional) filter to specify which resources to retrieve. Leave empty for all supported.
# @return [ { :id, :title, :url, :service } ]
# @throws TokenExpiredOrInvalidError
# @throws DataDownloadError
def get_resources_list(filter = [])
self.filter = filter
# Box doesn't have a way to "retrieve everything" but it supports whitespaces for multiple search terms
result = client.search(supported_extensions.join(' '),
scope: nil,
file_extensions: nil,
created_at_range: nil,
updated_at_range: nil,
size_range: nil,
owner_user_ids: nil,
ancestor_folder_ids: nil,
content_types: nil,
type: nil,
limit: 200,
offset: 0)
result = result.map { |i| format_item_data(i) }.sort { |x, y| y[:updated_at] <=> x[:updated_at] }
unless @formats.nil? || @formats.empty?
result = result.select { |item| item[:filename] =~ /.*(#{@formats.join(')|(')})$/i }
end
result
end
# Retrieves a resource and returns its contents
# @param id string
# @return mixed
# @throws TokenExpiredOrInvalidError
# @throws DataDownloadError
def get_resource(id)
file = client.file_from_id(id)
client.download_file(file)
end
# @param id string
# @return Hash
# @throws TokenExpiredOrInvalidError
# @throws DataDownloadError
# @throws NotFoundDownloadError
def get_resource_metadata(id)
result = client.file_from_id(id)
if result.nil?
message = "Retrieving file #{id} metadata: #{result.inspect}, should stop syncing"
raise NotFoundDownloadError.new(message, DATASOURCE_NAME)
end
if result['item_status'] != 'active'
raise DataDownloadError.new("Retrieving file #{id} metadata: #{result.inspect}", DATASOURCE_NAME)
end
format_item_data(result)
end
# Retrieves current filters
# @return {}
def filter
@formats
end
# Sets current filters
# @param filter_data {}
def filter=(filter_data = [])
@formats = filter_data
end
# Just return datasource name
# @return string
def to_s
DATASOURCE_NAME
end
# If this datasource accepts a data import instance
# @return Boolean
def persists_state_via_data_import?
false
end
# Stores the data import item instance to use/manipulate it
# @param value DataImport
# Not implemented
def data_import_item=(_value)
nil
end
# Checks if token is still valid or has been revoked
# @return bool
# @throws AuthError
def token_valid?
raise 'invalid_token' unless token
# Any call would do, we just want to see if communicates or refuses the token
result = client.search('test search')
!result.nil?
rescue => e
if e.message =~ /invalid_token/
CartoDB.notify_debug('Box invalid_token', self: inspect)
false
else
CartoDB.notify_exception(e, self: inspect)
raise e
end
end
# Revokes current set token
def revoke_token
client.revoke_tokens(token)
end
private
def set_tokens(tokens)
@access_token = tokens['access_token']
@refresh_token = tokens['refresh_token']
end
def client
@client ||= get_client
end
def get_client
BoxAPI::Client.new(@access_token,
client_id: config['client_id'],
client_secret: config['client_secret'])
end
def get_tokens(code)
BoxAPI::get_tokens(code,
grant_type: "authorization_code",
assertion: nil,
scope: nil,
username: nil,
client_id: config['client_id'],
client_secret: config['client_secret'])
end
def get_fresh_tokens(refresh_token)
tokens = BoxAPI::refresh_tokens(refresh_token,
client_id: config['client_id'],
client_secret: config['client_secret'])
# Box refresh tokens can only be used once
update_user_oauth(tokens['refresh_token'])
tokens
end
def update_user_oauth(refresh_token)
carto_user = Carto::User.find(@user.id)
oauth = carto_user.oauth_for_service('box')
oauth.token = refresh_token
oauth.save
end
# Formats all data to comply with our desired format
# @param item_data Hash : Single item returned from GDrive API
# @return { :id, :title, :url, :service, :checksum, :size, :filename, :updated_at }
def format_item_data(item_data)
{
id: item_data['id'],
title: item_data['name'],
service: DATASOURCE_NAME,
checksum: checksum_of(item_data.fetch('modified_at')),
filename: item_data['name'],
size: item_data['size'].to_i,
updated_at: DateTime.rfc3339(item_data['content_modified_at'])
}
end
end
end
end
end
@@ -0,0 +1,281 @@
require 'dropbox_api'
require_relative '../base_oauth'
require_relative '../../../../../lib/dropbox_api/endpoints/auth/token/revoke'
module CartoDB
module Datasources
module Url
# In order to test Dropbox in local, do the following:
# 1. In Dropbox, change OAuth2 configuration, adding this (replace username and API key as needed):
# http://localhost:3000/u/juanignaciosl/api/v1/imports/service/dropbox/oauth_callback/?api_key=3312b39c6360862e13217a8aec540e57367f4a4b
# 2. Configure it in app_config.yml:
# dropbox:
# app_key: '528omteaww7fj86'
# app_secret: 'rhx2ovpuni266ra'
# callback_url: 'http://localhost:3000/u/juanignaciosl/api/v1/imports/service/dropbox/oauth_callback/?api_key=3312b39c6360862e13217a8aec540e57367f4a4b'
# This obviously will work for a single user.
class Dropbox < BaseOAuth
# Required for all datasources
DATASOURCE_NAME = 'dropbox'
# Specific of this datasource
FORMATS_TO_SEARCH_QUERIES = {
FORMAT_CSV => %W( .csv ),
FORMAT_EXCEL => %W( .xls .xlsx ),
FORMAT_GPX => %W( .gpx ),
FORMAT_KML => %W( .kml ),
FORMAT_COMPRESSED => %W( .zip )
}
# Constructor
# @param config Array
# [
# 'app_key'
# 'app_secret'
# 'callback_url'
# ]
# @param user ::User
# @throws UninitializedError
# @throws MissingConfigurationError
def initialize(config, user)
super(config, user, %w{ app_key app_secret callback_url }, DATASOURCE_NAME)
@user = user
@app_key = config.fetch('app_key')
@app_secret = config.fetch('app_secret')
@callback_url = config.fetch('callback_url')
self.filter = []
@access_token = nil
@auth_flow = nil
@client = nil
end
# Factory method
# @param config : {}
# @param user : ::User
# @return CartoDB::Datasources::Url::Dropbox
def self.get_new(config, user)
return new(config, user)
end
# If will provide a url to download the resource, or requires calling get_resource()
# @return bool
def providers_download_url?
false
end
# Return the url to be displayed or sent the user to to authenticate and get authorization code
# Older implementations had a use_callback_flow parameter that became deprecated. Not implemented.
# @throws AuthError
def get_auth_url
authenticator.authorize_url redirect_uri: @callback_url, state: state
rescue => ex
raise AuthError.new("get_auth_url(#{use_callback_flow}): #{ex.message}", DATASOURCE_NAME)
end
# Validates the authorization callback
# @param params : mixed
def validate_callback(params)
raise "state doesn't match" unless params[:state] == state
auth_bearer = authenticator.get_token(params[:code], redirect_uri: @callback_url)
@access_token = auth_bearer.token
@client = DropboxApi::Client.new(@access_token)
@access_token
rescue => ex
raise AuthError.new("validate_callback(#{params.inspect}): #{ex.message}", DATASOURCE_NAME)
end
# Set the token
# @param token string
# @throws TokenExpiredOrInvalidError
# @throws AuthError
def token=(token)
@access_token = token
@client = DropboxApi::Client.new(@access_token)
rescue => ex
handle_error(ex, "token= : #{ex.message}")
end
# Retrieve set token
# @return string | nil
def token
@access_token
end
# Perform the listing and return results
# @param filter Array : (Optional) filter to specify which resources to retrieve. Leave empty for all supported.
# @return [ { :id, :title, :url, :service } ]
# @throws TokenExpiredOrInvalidError
# @throws AuthError
# @throws DataDownloadError
def get_resources_list(filter=[])
all_results = []
self.filter = filter
@formats.each do |search_query|
start = 0
loop do
response = @client.search(search_query, '', max_results: SEARCH_BATCH_SIZE, start: start)
response.matches.select { |item| item.resource.is_a?(DropboxApi::Metadata::File) }.each do |item|
all_results.push(format_item_data(item.resource))
end
break unless response.has_more?
start += SEARCH_BATCH_SIZE
end
end
all_results
rescue => ex
handle_error(ex, "get_resources_list(): #{ex.message}")
end
# Retrieves a resource and returns its contents
# @param id string
# @return mixed
# @throws TokenExpiredOrInvalidError
# @throws AuthError
# @throws DataDownloadError
def get_resource(id)
file_contents = ''
@client.download(id) do |chunk|
file_contents << chunk
end
file_contents
rescue => ex
handle_error(ex, "get_resource() #{id}: #{ex.message}")
end
# @param id string
# @return Hash
# @throws TokenExpiredOrInvalidError
# @throws AuthError
# @throws DataDownloadError
def get_resource_metadata(id)
raise DropboxPermissionError.new('No Dropbox client', DATASOURCE_NAME) unless @client.present?
response = @client.get_metadata(id)
item_data = format_item_data(response)
item_data.to_hash
rescue => ex
handle_error(ex, "get_resource_metadata() #{id}: #{ex.message}")
end
# Retrieves current filters
# @return {}
def filter
@formats
end
# Sets current filters
# @param filter_data {}
def filter=(filter_data=[])
@formats = []
FORMATS_TO_SEARCH_QUERIES.each do |id, queries|
if filter_data.empty? || filter_data.include?(id)
queries.each do |query|
@formats = @formats.push(query)
end
end
end
end
# Just return datasource name
# @return string
def to_s
DATASOURCE_NAME
end
# If this datasource accepts a data import instance
# @return Boolean
def persists_state_via_data_import?
false
end
# Stores the data import item instance to use/manipulate it
# @param value DataImport
def data_import_item=(value)
nil
end
# Checks if token is still valid or has been revoked
# @return bool
# @throws AuthError
def token_valid?
# Any call would do, we just want to see if communicates or refuses the token
@client.get_current_account
true
rescue DropboxApi::Errors::HttpError => ex
CartoDB::Logger.debug(message: 'Invalid Dropbox token', exception: ex, user: @user)
false
end
# Revokes current set token
def revoke_token
@client.revoke
true
rescue DropboxApi::Errors::HttpError => ex
CartoDB::Logger.debug(message: 'Error revoking Dropbox token', exception: ex, user: @user)
true
rescue => ex
raise AuthError.new("revoke_token: #{ex.message}", DATASOURCE_NAME)
end
private
SEARCH_BATCH_SIZE = 1000
# Handles
# @param original_exception mixed
# @param message string
# @throws TokenExpiredOrInvalidError
# @throws AuthError
# @throws mixed
def handle_error(original_exception, message)
if original_exception.is_a? DropboxApi::Errors::NotFoundError
raise NotFoundDownloadError.new(message, DATASOURCE_NAME)
elsif original_exception.is_a? DropboxApi::Errors::BasicError
error_code = original_exception.http_response.code.to_i
if error_code == 401 || error_code == 403
raise TokenExpiredOrInvalidError.new(message, DATASOURCE_NAME)
else
raise AuthError.new(message)
end
elsif original_exception.is_a? ArgumentError
raise DataDownloadError.new(message, DATASOURCE_NAME)
else
raise original_exception
end
end
# Formats all data to comply with our desired format
# @param item_data Hash : Single item returned from Dropbox API
# @return { :id, :title, :url, :service, :size }
def format_item_data(resource)
path = resource.path_display
filename = path.split('/').last
{
id: path,
title: filename,
filename: filename,
service: DATASOURCE_NAME,
checksum: checksum_of(resource.rev),
size: resource.size
}
end
def authenticator
@authenticator ||= DropboxApi::Authenticator.new(@app_key, @app_secret)
end
def state
service_name = service_name_for_user(DATASOURCE_NAME, @user)
CALLBACK_STATE_DATA_PLACEHOLDER.sub('user', @user.username).sub('service', service_name)
end
end
end
end
end
@@ -0,0 +1,353 @@
require 'signet/oauth_2/client'
require 'google/apis/drive_v2'
require_relative '../../../../../lib/carto/http/client'
module CartoDB
module Datasources
module Url
class GDrive < BaseOAuth
# Required for all providers
DATASOURCE_NAME = 'gdrive'
OAUTH_SCOPES = ['https://www.googleapis.com/auth/drive'].freeze
# For when using authorization code instead of callback with token
REDIRECT_URI = 'urn:ietf:wg:oauth:2.0:oob'
FIELDS_TO_RETRIEVE = 'items(downloadUrl,exportLinks,id,modifiedDate,title,fileExtension,fileSize)'
# Specific of this provider
FORMATS_TO_MIME_TYPES = {
FORMAT_CSV => %w(text/csv),
FORMAT_EXCEL => %w(application/vnd.ms-excel application/vnd.google-apps.spreadsheet application/vnd.openxmlformats-officedocument.spreadsheetml.sheet),
# FORMAT_GPX => %w(text/xml), # Disabled because text/xml list any XML file
FORMAT_KML => %w(application/vnd.google-earth.kml+xml),
FORMAT_COMPRESSED => %w(application/zip application/x-zip-compressed), # application/x-compressed-tar application/x-gzip application/x-bzip application/x-tar )
}
# Constructor (hidden)
# @param config
# [
# 'application_name'
# 'client_id'
# 'client_secret'
# ]
# @param user ::User
# @throws UninitializedError
# @throws MissingConfigurationError
def initialize(config, user)
super(config, user, %w{ application_name client_id client_secret callback_url }, DATASOURCE_NAME)
raise UninitializedError.new('missing user instance', DATASOURCE_NAME) if user.nil?
self.filter=[]
@refresh_token = nil
@user = user
@callback_url = config.fetch('callback_url')
@client = Signet::OAuth2::Client.new(
authorization_uri: 'https://accounts.google.com/o/oauth2/auth',
token_credential_uri: 'https://oauth2.googleapis.com/token',
client_id: config.fetch('client_id'),
client_secret: config.fetch('client_secret'),
scope: OAUTH_SCOPES,
redirect_uri: @callback_url,
access_type: :offline
)
@drive = Google::Apis::DriveV2::DriveService.new
@drive.authorization = @client
end
# Factory method
# @param config {}
# @param user ::User
# @return CartoDB::Datasources::Url::GDrive
def self.get_new(config, user)
new(config, user)
end
# If will provide a url to download the resource, or requires calling get_resource()
# @return bool
def providers_download_url?
false
end
# Return the url to be displayed or sent the user to authenticate and get authorization code
# @param use_callback_flow : bool
# @return string | nil
def get_auth_url(use_callback_flow = true)
if use_callback_flow
service_name = service_name_for_user(DATASOURCE_NAME, @user)
@client.state = CALLBACK_STATE_DATA_PLACEHOLDER.sub('user', @user.username)
.sub('service', service_name)
else
@client.redirect_uri = REDIRECT_URI
end
@client.authorization_uri.to_s
end
# Validate authorization code and store token
# @param auth_code : string
# @param use_callback_flow : bool
# @return string : Access token
# @throws AuthError
def validate_auth_code(auth_code, use_callback_flow = true)
unless use_callback_flow
@client.redirect_uri = REDIRECT_URI
end
@client.code = auth_code
@client.fetch_access_token!
if @client.refresh_token.nil?
raise AuthError.new(
"Error validating auth token. Is this Google account linked to another CARTO account?",
DATASOURCE_NAME
)
end
@refresh_token = @client.refresh_token
rescue Google::Apis::AuthorizationError, Signet::AuthorizationError => ex
raise AuthError.new("validating auth code: #{ex.message}", DATASOURCE_NAME)
end
# Validates the authorization callback
# @param params : mixed
def validate_callback(params)
if params[:error].present?
raise AuthError.new("validate_callback: #{params[:error]}", DATASOURCE_NAME)
end
if params[:code]
validate_auth_code(params[:code])
else
raise AuthError.new('validate_callback: Missing authorization code', DATASOURCE_NAME)
end
end
# Store token
# Triggers generation of a valid access token for the lifetime of this instance
# @param token string
# @throws AuthError
def token=(token)
@refresh_token = token
@client.update_token!(refresh_token: @refresh_token)
@client.fetch_access_token!
rescue Google::Apis::AuthorizationError, Signet::AuthorizationError => ex
raise TokenExpiredOrInvalidError.new("Invalid token: #{ex.message}", DATASOURCE_NAME)
rescue Google::Apis::ClientError, \
Google::Apis::ServerError, Google::Apis::BatchError, Google::Apis::TransmissionError => ex
raise AuthError.new("setting token: #{ex.message}", DATASOURCE_NAME)
end
# Retrieve token
# @return string | nil
def token
@refresh_token
end
# Perform the listing and return results
# @param filter Array : (Optional) filter to specify which resources to retrieve. Leave empty for all supported.
# @return [ { :id, :title, :url, :service } ]
# @throws TokenExpiredOrInvalidError
# @throws DataDownloadError
def get_resources_list(filter=[])
all_results = []
self.filter = filter
@drive.batch do |d|
@formats.each do |mime_type|
d.list_files(q: "mime_type = '#{mime_type}'", fields: FIELDS_TO_RETRIEVE) do |res, err|
if err
case err.status_code
when 200
break
when 403
raise GDriveNoExternalAppsAllowedError.new(result.data['error']['message'], DATASOURCE_NAME)
else
error_msg = "get_resources_list() #{result.data['error']['message']} (#{result.status})"
raise DataDownloadError.new(error_msg, DATASOURCE_NAME)
end
elsif res.items.present?
res.items.each do |item|
all_results.push(format_item_data(item))
end
end
end
end
end
all_results.compact
rescue Google::Apis::AuthorizationError, Signet::AuthorizationError => ex
raise TokenExpiredOrInvalidError.new("Invalid token: #{ex.message}", DATASOURCE_NAME)
rescue Google::Apis::BatchError, Google::Apis::TransmissionError, Google::Apis::ClientError, \
Google::Apis::ServerError => ex
raise DataDownloadError.new("getting resources: #{ex.message}", DATASOURCE_NAME)
end
# Retrieves a resource and returns its contents
# @param id string
# @return mixed
# @throws TokenExpiredOrInvalidError
# @throws DataDownloadError
def get_resource(id)
@drive.get_file(id) do |file, err|
if err
error_msg = "(#{err.status_code}) retrieving file #{id}: #{err}"
raise DataDownloadError.new(error_msg, DATASOURCE_NAME)
end
if file.export_links.present?
@drive.export_file(file.id, 'text/csv', download_dest: StringIO.new) do |content, export_err|
raise export_err if export_err
return content
end
else
@drive.get_file(file.id, download_dest: StringIO.new) do |content, download_err|
raise download_err if download_err
return content
end
end
end
rescue Google::Apis::AuthorizationError, Signet::AuthorizationError => ex
raise TokenExpiredOrInvalidError.new("Invalid token: #{ex.message}", DATASOURCE_NAME)
rescue Google::Apis::BatchError, Google::Apis::TransmissionError, Google::Apis::ClientError, \
Google::Apis::ServerError => ex
raise DataDownloadError.new("downloading file #{id}: #{ex.message}", DATASOURCE_NAME)
end
# @param id string
# @return Hash
# @throws TokenExpiredOrInvalidError
# @throws DataDownloadError
# @throws NotFoundDownloadError
def get_resource_metadata(id)
@drive.get_file(id) do |file, err|
if err
case err.status_code
when 404
error_msg = "(#{err.status_code}) retrieving file #{id} metadata: #{err}, should stop syncing"
raise NotFoundDownloadError.new(error_msg, DATASOURCE_NAME)
else
error_msg = "(#{err.status_code}) retrieving file #{id} metadata: #{err}"
raise DataDownloadError.new(error_msg, DATASOURCE_NAME)
end
else
item_data = format_item_data(file)
return item_data.to_hash
end
end
rescue Google::Apis::AuthorizationError, Signet::AuthorizationError
raise TokenExpiredOrInvalidError.new('Invalid token', DATASOURCE_NAME)
rescue Google::Apis::BatchError, Google::Apis::TransmissionError, Google::Apis::ClientError, \
Google::Apis::ServerError
raise DataDownloadError.new("get_resource_metadata() #{id}", DATASOURCE_NAME)
rescue StandardError => e
CartoDB.notify_exception(e, id: id, user: @user)
raise e
end
# Retrieves current filters
# @return {}
def filter
@formats
end
# Sets current filters
# @param filter_data {}
def filter=(filter_data=[])
@formats = []
FORMATS_TO_MIME_TYPES.each do |id, mime_types|
if filter_data.empty? || filter_data.include?(id)
mime_types.each do |mime_type|
@formats = @formats.push(mime_type)
end
end
end
end
# Just return datasource name
# @return string
def to_s
DATASOURCE_NAME
end
# If this datasource accepts a data import instance
# @return Boolean
def persists_state_via_data_import?
false
end
# Stores the data import item instance to use/manipulate it
# @param value DataImport
def data_import_item=(value)
nil
end
# Checks if token is still valid or has been revoked
# @return bool
# @throws AuthError
def token_valid?
# Any call would do, we just want to see if communicates or refuses the token
result = @drive.get_about
!result.nil?
rescue Google::Apis::AuthorizationError, Signet::AuthorizationError
false
rescue Google::Apis::BatchError, Google::Apis::TransmissionError, Google::Apis::ClientError, \
Google::Apis::ServerError => ex
raise AuthError.new("token_valid?() #{id}: #{ex.message}", DATASOURCE_NAME)
end
# Revokes current set token
def revoke_token
http_client = Carto::Http::Client.get('gdrive',
connecttimeout: 60,
timeout: 600)
response = http_client.get("https://accounts.google.com/o/oauth2/revoke?token=#{token}")
if response.code == 200
true
end
rescue StandardError => ex
raise AuthError.new("revoke_token: #{ex.message}", DATASOURCE_NAME)
end
private
# Formats all data to comply with our desired format
# @param item_data Hash : Single item returned from GDrive API
# @return { :id, :title, :url, :service, :checksum, :size }
def format_item_data(item_data)
data =
{
id: item_data.id,
title: item_data.title,
service: DATASOURCE_NAME,
checksum: checksum_of(item_data.modified_date.to_s)
}
if item_data.export_links.present?
# Native spreadsheets have no format nor direct download links
data[:url] = item_data.export_links.first.last
data[:url] = data[:url][0..data[:url].rindex('=')] + 'csv'
data[:filename] = clean_filename(item_data.title) + '.csv'
data[:size] = NO_CONTENT_SIZE_PROVIDED
elsif item_data.download_url.present?
data[:url] = item_data.download_url
# For Drive files, title == filename + extension
data[:filename] = item_data.title
data[:size] = item_data.file_size.to_i
else
# Downloads from files shared by other people can be disabled, ignore them
return nil
end
data
end
def clean_filename(name)
clean_name = ''
name.gsub(' ','_').scan(/([a-zA-Z0-9_]+)/).flatten.map { |match|
clean_name << match
}
clean_name = name if clean_name.size == 0
clean_name
end
end
end
end
end
@@ -0,0 +1,287 @@
require "instagram"
module CartoDB
module Datasources
module Url
class InstagramOAuth < BaseOAuth
# Required for all datasources
DATASOURCE_NAME = 'instagram'
FORMAT_ALL_MEDIA = 'all_media'
# Constructor
# @param config Array
# [
# 'app_key'
# 'app_secret'
# 'callback_url'
# ]
# @param user ::User
# @throws UninitializedError
# @throws MissingConfigurationError
def initialize(config, user)
super(config, user, %w{ app_key app_secret callback_url }, DATASOURCE_NAME)
@user = user
@app_key = config.fetch('app_key')
@app_secret = config.fetch('app_secret')
raise ServiceDisabledError.new(DATASOURCE_NAME, @user.username) unless @user.has_feature_flag?('instagram_import')
service_name = service_name_for_user(DATASOURCE_NAME, @user)
placeholder = CALLBACK_STATE_DATA_PLACEHOLDER.sub('user', @user.username).sub('service', service_name)
@callback_url = "#{config.fetch('callback_url')}?state=#{placeholder}"
self.filter = []
@access_token = nil
@auth_flow = nil
@client = nil
end
# Factory method
# @param config : {}
# @param user : ::User
# @return CartoDB::Datasources::Url::InstagramOAuth
def self.get_new(config, user)
return new(config, user)
end
# If will provide a url to download the resource, or requires calling get_resource()
# @return bool
def providers_download_url?
false
end
# Return the url to be displayed or sent the user to to authenticate and get authorization code
# @param use_callback_flow : bool
# @throws AuthError
def get_auth_url(use_callback_flow=true)
# TODO: Add CSRF here (http://instagram.com/developer/authentication/)
Instagram.authorize_url({
client_id: @app_key,
response_type: 'code',
redirect_uri: @callback_url
})
rescue => ex
raise AuthError.new("get_auth_url(#{use_callback_flow}): #{ex.message}", DATASOURCE_NAME)
end
# Validates the authorization callback
# @param params : mixed
def validate_callback(params)
response = Instagram.get_access_token(params[:code], {
client_id: @app_key,
client_secret: @app_secret,
redirect_uri: @callback_url
})
@access_token = response.access_token
@client = Instagram.client(access_token: @access_token)
@access_token
rescue => ex
raise AuthError.new("validate_callback(#{params.inspect}): #{ex.message}", DATASOURCE_NAME)
end
# Set the token
# @param token string
# @throws TokenExpiredOrInvalidError
# @throws AuthError
def token=(token)
@access_token = token
@client = Instagram.client(access_token: @access_token)
rescue => ex
handle_error(ex, "token= : #{ex.message}")
end
# Retrieve set token
# @return string | nil
def token
@access_token
end
# Perform the listing and return results
# @param filter Array : (Optional) filter to specify which resources to retrieve. Leave empty for all supported.
# @return [ { :id, :title, :url, :service } ]
# @throws TokenExpiredOrInvalidError
# @throws AuthError
# @throws DataDownloadError
def get_resources_list(filter=[])
[
{
id: FORMAT_ALL_MEDIA,
title: 'All your photos and videos',
url: 'All your photos and videos',
service: DATASOURCE_NAME,
checksum: '',
size: NO_CONTENT_SIZE_PROVIDED
}
]
end
# Retrieves a resource and returns its contents
# @param id string
# @return mixed
# @throws TokenExpiredOrInvalidError
# @throws AuthError
# @throws DataDownloadError
def get_resource(id)
contents = [
field_to_csv('thumbnail'),
field_to_csv('image'),
field_to_csv('link'),
field_to_csv('type'),
field_to_csv('lat'),
field_to_csv('lon'),
field_to_csv('location_id'),
field_to_csv('location_name'),
field_to_csv('caption'),
field_to_csv('comments_count'),
field_to_csv('likes_count'),
field_to_csv('tags'),
field_to_csv('created_time')
].join(',') << "\n"
max_id = nil
begin
batch_contents, max_id = get_resource_page(id, max_id)
contents << batch_contents
end while !max_id.nil?
contents
end
# @param id string
# @return Hash
# @throws TokenExpiredOrInvalidError
# @throws AuthError
# @throws DataDownloadError
def get_resource_metadata(id)
{
id: FORMAT_ALL_MEDIA,
filename: "#{DATASOURCE_NAME}_#{@client.user.username}.csv",
size: NO_CONTENT_SIZE_PROVIDED
}
rescue => ex
handle_error(ex, "get_resource_metadata() #{id}: #{ex.message}")
end
# Retrieves current filters
# @return {}
def filter
{}
end
# Sets current filters
# @param filter_data {}
def filter=(filter_data=[])
nil
end
# Just return datasource name
# @return string
def to_s
DATASOURCE_NAME
end
# If this datasource accepts a data import instance
# @return Boolean
def persists_state_via_data_import?
false
end
# Stores the data import item instance to use/manipulate it
# @param value DataImport
def data_import_item=(value)
nil
end
# Checks if token is still valid or has been revoked
# @return bool
# @throws AuthError
def token_valid?
# checking if metadata is returned, if so token
# is valid, if not it is invalid
response = get_resource_metadata(DATASOURCE_NAME)
if response[:id]
true
end
rescue => ex
false
end
# Revokes current set token
def revoke_token
# TODO: See how to check this
true
rescue => ex
raise AuthError.new("revoke_token: #{ex.message}", DATASOURCE_NAME)
end
private
def field_to_csv(field)
'"' + field.to_s.gsub('"', '""').gsub("\\n", ' ').gsub("\x0D", ' ').gsub("\x0A", ' ').gsub("\0", '')
.gsub("\\", ' ') + '"'
end
# @param resource_id String
# @para max_id Integer|nil Max media id retrieved (used to paginate)
def get_resource_page(resource_id, max_id=nil)
contents = ''
data = { count: 30 }
data[:max_id] = max_id unless max_id.nil?
items = @client.user_recent_media(data)
new_max_id = items.pagination.next_max_id
items.each do |item|
if item.location.nil?
lat = lon = location_id = location_name = nil
else
lat = item.location.latitude
lon = item.location.longitude
location_id = item.location.id
location_name = item.location.name
end
caption = item.caption.nil? ? '' : item.caption.text
contents << [
field_to_csv(item.images.thumbnail.url),
field_to_csv(item.images.standard_resolution.url),
field_to_csv(item.link),
field_to_csv(item.type),
field_to_csv(lat),
field_to_csv(lon),
field_to_csv(location_id),
field_to_csv(location_name),
field_to_csv(caption),
field_to_csv(item.comments['count']),
field_to_csv(item.likes['count']),
field_to_csv(item.tags.join(',')),
field_to_csv(item.created_time)
].join(',') << "\n"
end
[ contents, new_max_id ]
rescue => ex
handle_error(ex, "get_resource() #{resource_id}: #{ex.message}")
end
# Handles
# @param original_exception mixed
# @param message string
# @throws TokenExpiredOrInvalidError
# @throws AuthError
# @throws mixed
def handle_error(original_exception, message)
# TODO: Implement
raise original_exception
end
end
end
end
end
@@ -0,0 +1,397 @@
require 'json'
require 'gibbon'
require 'addressable/uri'
require_relative '../base_oauth'
require_relative '../../../../../lib/carto/http/client'
module CartoDB
module Datasources
module Url
# Note:
# - MailChimp access tokens don't expire, no need to handle that logic
class MailChimp < BaseOAuth
# Required for all datasources
DATASOURCE_NAME = 'mailchimp'
AUTHORIZE_URI = 'https://login.mailchimp.com/oauth2/authorize?response_type=code&client_id=%s&redirect_uri=%s'
ACCESS_TOKEN_URI = 'https://login.mailchimp.com/oauth2/token'
MAILCHIMP_METADATA_URI = 'https://login.mailchimp.com/oauth2/metadata'
API_TIMEOUT_SECS = 60
# Constructor
# @param config Array
# [
# 'api_key'
# 'timeout_minutes'
# ]
# @param user ::User
# @throws UninitializedError
# @throws MissingConfigurationError
def initialize(config, user)
super(config, user, %w{ app_key app_secret callback_url }, DATASOURCE_NAME)
@user = user
@app_key = config.fetch('app_key')
@app_secret = config.fetch('app_secret')
@http_timeout = config.fetch(:http_timeout, 600)
@http_connect_timeout = config.fetch(:http_connect_timeout, 60)
service_name = service_name_for_user(DATASOURCE_NAME, @user)
placeholder = CALLBACK_STATE_DATA_PLACEHOLDER.sub('user', @user.username).sub('service', service_name)
@callback_url = "#{config.fetch('callback_url')}?state=#{placeholder}"
Gibbon::API.timeout = API_TIMEOUT_SECS
Gibbon::API.throws_exceptions = true
Gibbon::Export.timeout = API_TIMEOUT_SECS
Gibbon::Export.throws_exceptions = false
@access_token = nil
@api_client = nil
end
# Factory method
# @param config : {}
# @param user : ::User
# @return CartoDB::Datasources::Url::MailChimpLists
def self.get_new(config, user)
return new(config, user)
end
# If will provide a url to download the resource, or requires calling get_resource()
# @return bool
def providers_download_url?
false
end
# Return the url to be displayed or sent the user to to authenticate and get authorization code
# @param use_callback_flow : bool
# @return string : URL to navigate to for the authorization flow
# @throws ExternalServiceError
def get_auth_url(use_callback_flow=true)
if use_callback_flow
AUTHORIZE_URI % [@app_key, Addressable::URI.encode(@callback_url)]
else
raise ExternalServiceError.new("This datasource doesn't allows non-callback flows", DATASOURCE_NAME)
end
end
# Validate authorization code and store token
# @param auth_code : string
# @return string : Access token
# @throws ExternalServiceError
def validate_auth_code(auth_code)
raise ExternalServiceError.new("This datasource doesn't allows non-callback flows", DATASOURCE_NAME)
end
# Validates the authorization callback
# @param params : mixed
# @throws AuthError
# @throws DataDownloadTimeoutError
def validate_callback(params)
code = params.fetch('code')
if code.nil? || code == ''
raise "Empty callback code"
end
token_call_params = {
grant_type: 'authorization_code',
client_id: @app_key,
client_secret: @app_secret,
code: code,
redirect_uri: @callback_url
}
token_response = http_client.post(ACCESS_TOKEN_URI, http_options(token_call_params, :post))
raise DataDownloadTimeoutError.new(DATASOURCE_NAME) if token_response.timed_out?
unless token_response.code == 200
raise "Bad token response: #{token_response.body.inspect} (#{token_response.code})"
end
token_data = ::JSON.parse(token_response.body)
partial_access_token = token_data['access_token']
# Afterwards, must do another call to metadata endpoint to retrieve API details
# @see https://apidocs.mailchimp.com/oauth2/
metadata_response = http_client.get(MAILCHIMP_METADATA_URI,http_options({}, :get, {
'Authorization' => "OAuth #{partial_access_token}"}))
raise DataDownloadTimeoutError.new(DATASOURCE_NAME) if metadata_response.timed_out?
unless metadata_response.code == 200
raise "Bad metadata response: #{metadata_response.body.inspect} (#{metadata_response.code})"
end
metadata_data = ::JSON.parse(metadata_response.body)
# This specially formed token behaves as an API Key for client calls using API
@access_token = "#{partial_access_token}-#{metadata_data['dc']}"
rescue => ex
raise AuthError.new("validate_callback(#{params.inspect}): #{ex.message}", DATASOURCE_NAME)
end
# Set the token
# @param token string
# @throws TokenExpiredOrInvalidError
def token=(token)
@access_token = token
@api_client = Gibbon::API.new(@access_token)
rescue Gibbon::MailChimpError => exception
raise TokenExpiredOrInvalidError.new("token=() : #{exception.message} (API code: #{exception.code})",
DATASOURCE_NAME)
rescue => exception
raise TokenExpiredOrInvalidError.new("token=() : #{exception.inspect}", DATASOURCE_NAME)
end
# Retrieve set token
# @return string | nil
def token
@access_token
end
# Perform the listing and return results
# @param filter Array : (Optional) filter to specify which resources to retrieve. Leave empty for all supported.
# @return [ { :id, :title, :url, :service } ]
# @throws UninitializedError
# @throws DataDownloadError
def get_resources_list(filter=[])
raise UninitializedError.new('No API client instantiated', DATASOURCE_NAME) unless @api_client.present?
all_results = []
offset = 0
limit = 100
total = nil
begin
response = @api_client.campaigns.list({
start: offset,
limit: limit
})
errors = response.fetch('errors', [])
unless errors.empty?
raise DataDownloadError.new("get_resources_list(): #{errors.inspect}", DATASOURCE_NAME)
end
total = response.fetch('total', 0).to_i if total.nil?
response_data = response.fetch('data', [])
response_data.each do |item|
# Skip items without tracking
all_results.push(format_activity_item_data(item)) if item['tracking']['opens']
end
offset += limit
end while offset < total
all_results
rescue Gibbon::MailChimpError => exception
raise DataDownloadError.new("get_resources_list(): #{exception.message} (API code: #{exception.code}",
DATASOURCE_NAME)
rescue => exception
raise DataDownloadError.new("get_resources_list(): #{exception.inspect}", DATASOURCE_NAME)
end
# Retrieves a resource and returns its contents
# @param id string
# @return mixed
# @throws UninitializedError
# @throws DataDownloadError
def get_resource(id)
raise UninitializedError.new('No API client instantiated', DATASOURCE_NAME) unless @api_client.present?
subscribers = {}
contents = StringIO.new
export_api = @api_client.get_exporter
# 1) Retrieve campaign details
campaign = get_resource_metadata(id)
campaign_details = export_api.list({id: campaign[:list_id]})
campaign = nil
# 2) Retrieve subscriber activity
# https://apidocs.mailchimp.com/export/1.0/campaignsubscriberactivity.func.php
subscribers_activity = export_api.campaign_subscriber_activity({id: id})
subscribers_activity.each { |line|
store_subscriber_if_opened(line, subscribers)
}
subscribers_activity = nil
# 3) Update campaign details with subscriber activity results
# 4) anonymize data (inside list_json_to_csv)
campaign_details.each_with_index { |line, index|
contents.write list_json_to_csv(line, subscribers, index == 0)
}
contents.string
rescue Gibbon::MailChimpError => exception
raise DataDownloadError.new("get_resource(): #{exception.message} (API code: #{exception.code}",
DATASOURCE_NAME)
rescue => exception
raise DataDownloadError.new("get_resource(): #{exception.inspect}", DATASOURCE_NAME)
end
# @param id string
# @return Hash
# @throws UninitializedError
# @throws DataDownloadError
def get_resource_metadata(id)
raise UninitializedError.new('No API client instantiated', DATASOURCE_NAME) unless @api_client.present?
item_data = {}
# No metadata call at API, so just retrieve same info but from specific campaign id
# https://apidocs.mailchimp.com/api/2.0/campaigns/list.php
response = @api_client.campaigns.list({ filters: { campaign_id: id } })
errors = response.fetch('errors', [])
unless errors.empty?
raise DataDownloadError.new("get_resources_list(): #{errors.inspect}", DATASOURCE_NAME)
end
response_data = response.fetch('data', [])
response_data.each do |item|
if item.fetch('id') == id
item_data = format_activity_item_data(item)
end
end
item_data
rescue Gibbon::MailChimpError => exception
raise DataDownloadError.new("get_resource_metadata(): #{exception.message} (API code: #{exception.code}",
DATASOURCE_NAME)
rescue => exception
raise DataDownloadError.new("get_resource_metadata(): #{exception.inspect}", DATASOURCE_NAME)
end
# Retrieves current filters
# @return {}
def filter
[]
end
# Sets current filters
# @param filter_data {}
def filter=(filter_data=[])
end
# Just return datasource name
# @return string
def to_s
DATASOURCE_NAME
end
# If this datasource accepts a data import instance
# @return Boolean
def persists_state_via_data_import?
false
end
# Stores the data import item instance to use/manipulate it
# @param value DataImport
def data_import_item=(value)
nil
end
# Checks if token is still valid or has been revoked
# @return bool
# @throws AuthError
def token_valid?
raise UninitializedError.new('No API client instantiated', DATASOURCE_NAME) unless @api_client.present?
# Any call would do, we just want to see if communicates or refuses the token
# This call is available to all roles
response = @api_client.users.profile
# 'errors' only appears in failure scenarios, while 'username' only if went ok
response.fetch('errors', nil).nil? && !response.fetch('username', nil).nil?
rescue => ex
CartoDB.notify_exception(ex)
false
end
# Revokes current set token
def revoke_token
# not supported
end
private
def http_client
@http_client ||= Carto::Http::Client.get('mailchimp')
end
def http_options(params={}, method=:get, extra_headers={})
{
method: method,
params: method == :get ? params : {},
body: method == :post ? params : {},
followlocation: true,
ssl_verifypeer: false,
headers: {
'Accept' => 'application/json'
}.merge(extra_headers),
ssl_verifyhost: 0,
connecttimeout: @http_connect_timeout,
timeout: @http_timeout
}
end
# Formats all data to comply with our desired format
# @param item_data Hash : Single item returned from MailChimp API
# @return { :id, :title, :url, :service, :size }
def format_activity_item_data(item_data)
filename = item_data.fetch('title').gsub(' ', '_')
{
id: item_data.fetch('id'),
list_id: item_data.fetch('list_id'),
title: "#{item_data.fetch('title')}",
filename: "#{filename}.csv",
service: DATASOURCE_NAME,
checksum: '',
member_count: item_data.fetch('emails_sent'),
size: NO_CONTENT_SIZE_PROVIDED
}
end
def store_subscriber_if_opened(input_fields='[]', subscribers)
contents = ::JSON.parse(input_fields)
contents.each { |subject, actions|
unless actions.length == 0
actions.each { |action|
if action["action"] == "open"
subscribers[subject] = true
end
opened_action = true
}
end
}
end
# @param contents String containing a JSON array of fields (data of campaign user/target)
# @param subscribers Hash { subject => opened_email }
# @param header_row Boolean
# @return String Containing a CSV ready to dump to a file
def list_json_to_csv(contents='[]', subscribers={}, header_row=false)
# shorcut: Remove newlines and Anonymize email addresses before parsing to speed up
contents = ::JSON.parse(contents.gsub("\n", ' ').gsub(/(\w|\.|\-)+@/, ""))
opened_mail = !subscribers[contents[0]].nil?
cleaned_contents = []
#Once parsed, each row contains data like account code, company name, email, first name...
contents.each_with_index { |field, index|
# Remove double quotes to avoid CSV errors
cleaned_contents[index] = "\"#{field.to_s.gsub('"', '""')}\""
}
cleaned_contents.push("\"#{header_row ? 'Opened' : opened_mail.to_s}\"")
data = cleaned_contents.join(',')
data << "\n"
end
end
end
end
end
@@ -0,0 +1,181 @@
require_relative '../../../../../lib/carto/http/client'
module CartoDB
module Datasources
module Url
class PublicUrl < Base
# Required for all datasources
DATASOURCE_NAME = 'public_url'
URL_REGEXP = %r{://}
# Constructor (hidden)
# @param config
# [ ]
def initialize(config)
super
@http_timeout = config.fetch(:http_timeout, 3200)
@http_connect_timeout = config.fetch(:http_connect_timeout, 60)
@service_name = DATASOURCE_NAME
@headers = nil
@response = nil
end
# Factory method
# @param config {}
# @return CartoDB::Datasources::Url::PublicUrl
def self.get_new(config={})
return new(config)
end
# If will provide a url to download the resource, or requires calling get_resource()
# @return bool
def providers_download_url?
true
end
def get_http_response_code
@response.code if !@response.nil? && !@response.code.nil?
end
# Perform the listing and return results
# @param filter Array : (Optional) filter to specify which resources to retrieve. Leave empty for all supported.
# @return [ { :id, :title, :url, :service } ]
def get_resources_list(filter=[])
nil
end
# Retrieves a resource and returns its contents
# @param id string
# @return mixed
# @throws DataDownloadTimeoutError
# @throws DataDownloadError
def get_resource(id)
response = http_client.get(id, http_options)
while response.headers['location']
response = http_client.get(id, http_options)
end
raise DataDownloadTimeoutError.new(DATASOURCE_NAME) if response.timed_out?
raise DataDownloadError.new("get_resource() #{id}", DATASOURCE_NAME) unless response.code.to_s =~ /\A[23]\d+/
# To be used in when try to retrieve the http response code
@response = response
response.response_body
end
# @param id string
# @return Hash
def get_resource_metadata(id)
fetch_headers(id)
{
id: id,
title: id,
url: id,
service: DATASOURCE_NAME,
checksum: checksum_of(id, etag_header, last_modified_header),
size: content_length_header
# No need to use :filename nor file
}
end
# Fetches the headers for a given url
# @throws DataDownloadError
def fetch_headers(url)
if url =~ URL_REGEXP
response = http_client.head(url, http_options)
raise DataDownloadTimeoutError.new(DATASOURCE_NAME) if response.timed_out?
# For example S3 only allows one verb per signed url (we use GET) so won't allow HEAD, but it's ok
@headers = (response.code.to_s =~ /\A[23]\d+/) ? response.headers : {}
else
@headers = {}
end
end
# Get the etag header if present
# @return string
# @throws UninitializedError
def etag_header
raise UninitializedError.new('headers not fetched', DATASOURCE_NAME) if @headers.nil?
etag = @headers.fetch('ETag', nil)
etag ||= @headers.fetch('Etag', nil)
etag ||= @headers.fetch('etag', '')
etag = etag.delete('"').delete("'") unless etag.empty?
etag
end
# Get the last modified header if present
# @return string
# @throws UninitializedError
def last_modified_header
raise UninitializedError.new('headers not fetched', DATASOURCE_NAME) if @headers.nil?
last_modified = @headers.fetch('Last-Modified', nil)
last_modified ||= @headers.fetch('Last-modified', nil)
last_modified ||= @headers.fetch('last-modified', '')
last_modified = last_modified.delete('"').delete("'") unless last_modified.empty?
last_modified
end
# Just return datasource name
# @return string
def to_s
DATASOURCE_NAME
end
# If this datasource accepts a data import instance
# @return Boolean
def persists_state_via_data_import?
false
end
# Stores the data import item instance to use/manipulate it
# @param value DataImport
def data_import_item=(value)
nil
end
private
def http_client
@http_client ||= Carto::Http::Client.get('public_url')
end
# Get the file size if present
# @return Integer
# @throws UninitializedError
def content_length_header
raise UninitializedError.new('headers not fetched', DATASOURCE_NAME) if @headers.nil?
content_length = @headers.fetch('Content-Length', nil)
content_length ||= @headers.fetch('Content-length', nil)
content_length ||= @headers.fetch('content-length', NO_CONTENT_SIZE_PROVIDED)
content_length.to_i
end
# Calculates a checksum of given url
# @return string
def checksum_of(url, etag, last_modified)
#noinspection RubyArgCount
Zlib::crc32(url + etag + last_modified).to_s
end
# HTTP (Typhoeus) options
def http_options
{
followlocation: true,
ssl_verifypeer: false,
ssl_verifyhost: 0,
timeout: @http_timeout,
connecttimeout: @http_connect_timeout
}
end
end
end
end
end
@@ -0,0 +1,169 @@
require 'tempfile'
require 'fileutils'
require 'json'
module CartoDB
module Datasources
# TODO: Error handling, now assumes all done ok
class CSVFileDumper
ORIGINAL_FILE_EXTENSION = '.json'
CONVERTED_FILE_EXTENSION = '.csv'
HEADERS_FILE_EXTENSION = '_headers.csv'
FILE_DUMPER_TMP_SUBFOLDER = '/tmp/csv_file_dumper/'
OUTPUT_ENCODING = 'utf-8'
def initialize(json_to_csv_conversor, debug_mode = false)
@debug_mode = debug_mode
@json2csv_conversor = json_to_csv_conversor
@temporary_directory = nil
@temporary_folder = Time.now.strftime("%Y%m%d_%H%M%S_") + rand(1000).to_s
@additional_fields = {}
@files = {}
@original_files = {}
@headers_file = nil
@buffer_size = 8192
end
def buffer_size=(value)
@buffer_size = value.to_i if value.to_i > 0
end
def additional_fields=(data = {})
@additional_fields = data
end
# This class uses a temporal CSV file per name
# optionally dumping also the source JSON in another file if in debug mode
# @param name String
def begin_dump(name)
# Create temp file & open
@files[name] = temporary_file(name)
@original_files[name] = temporary_file(name, ORIGINAL_FILE_EXTENSION) if @debug_mode
@headers_file = temporary_file('', HEADERS_FILE_EXTENSION) if @debug_mode
end
# @param name String
# @param data Array
# @return Integer number of items dumped
def dump(name, data = [])
processed_data = @json2csv_conversor.process(data, false, @additional_fields[name]) + "\n"
processed_data.encode!(OUTPUT_ENCODING, replace: '')
@files[name].write(processed_data)
@original_files[name].write(::JSON.dump(data) + "\n") if @debug_mode
data.count
end
# @param name String
def end_dump(name)
if @files[name]
@files[name].close
end
if @original_files[name]
@original_files[name].close
end
end
# @param names_list Array
# @param stream IO
def merge_dumps_into_stream(names_list, stream)
headers = @json2csv_conversor.generate_headers(@additional_fields[names_list.first]) + "\n"
streamed_size = headers.length
stream.write(headers)
names_list.each do |name|
input_stream = File.open(@files[name].path)
begin
buffer = input_stream.read(@buffer_size)
if buffer
stream.write(buffer)
streamed_size += buffer.length
end
end while buffer
input_stream.close
@files[name].unlink unless @debug_mode
end
if @debug_mode && !@headers_file.nil?
@headers_file.write(headers)
@headers_file.close
end
streamed_size
end
# @param names_list Array
# @return String
def merge_dumps(names_list = [])
headers = @json2csv_conversor.generate_headers(@additional_fields[names_list.first]) + "\n"
return_data = headers
return_data.encode!(OUTPUT_ENCODING, replace: '')
if @debug_mode && !@headers_file.nil?
@headers_file.write(headers)
@headers_file.close
end
names_list.each do |name|
return_data << File.read(@files[name].path)
@files[name].unlink unless @debug_mode
end
# Remove final trailing newline before returning
return_data.sub(/\n$/, '')
end
# Return a new temporary file contained inside a tmp subfolder
# @param base_name String|nil (optional)
def temporary_file(base_name = '', extension = CONVERTED_FILE_EXTENSION)
FileUtils.mkdir_p(FILE_DUMPER_TMP_SUBFOLDER) unless File.directory?(FILE_DUMPER_TMP_SUBFOLDER)
temps_full_path = FILE_DUMPER_TMP_SUBFOLDER + @temporary_folder + '/'
FileUtils.mkdir_p(temps_full_path)
# For the default scenario force encoding, for original files don't touch anything
if extension == CONVERTED_FILE_EXTENSION
Tempfile.new([base_name.gsub(' ', '_'), extension], temps_full_path, encoding: OUTPUT_ENCODING)
else
Tempfile.new([base_name.gsub(' ', '_'), extension], temps_full_path)
end
end
def file_paths
@files.values.map(&:path)
end
def original_file_paths
@original_files.values.map(&:path)
end
def headers_path
@headers_file.path unless @headers_file.nil?
end
def clean_string(contents)
@json2csv_conversor.clean_string(contents)
end
private
# Intended for tests
def destroy_files
@files.keys.each { |key| @files[key].close! }
@original_files.keys.each { |key| @original_files[key].close! }
@headers_file.close! unless @headers_file.nil?
end
end
end
end
@@ -0,0 +1,79 @@
require 'yaml'
require_relative '../../../../spec/rspec_configuration'
require_relative '../../lib/datasources'
require_relative '../doubles/user'
require 'spec_helper_min'
include CartoDB::Datasources
describe DatasourcesFactory do
def get_config
@config ||= YAML.load_file("#{File.dirname(__FILE__)}/../../../../config/app_config.yml")['defaults']
end
describe '#provider_instantiations' do
it 'tests all available provider instantiations' do
user = FactoryGirl.build(:user)
user.stubs('has_feature_flag?').with('gnip_v2').returns(false)
DatasourcesFactory.set_config(get_config)
dropbox_provider = DatasourcesFactory.get_datasource(Url::Dropbox::DATASOURCE_NAME, user)
dropbox_provider.is_a?(Url::Dropbox).should eq true
dropbox_provider = DatasourcesFactory.get_datasource(Url::Box::DATASOURCE_NAME, user)
dropbox_provider.is_a?(Url::Box).should eq true
# Stubs Google Drive client for connectionless testing
Google::Apis::DriveV2::DriveService.any_instance.stubs(:get_file)
Google::Apis::DriveV2::DriveService.any_instance.stubs(:export_file)
Google::Apis::DriveV2::DriveService.any_instance.stubs(:list_files)
gdrive_provider = DatasourcesFactory.get_datasource(Url::GDrive::DATASOURCE_NAME, user)
gdrive_provider.is_a?(Url::GDrive).should eq true
url_provider = DatasourcesFactory.get_datasource(Url::PublicUrl::DATASOURCE_NAME, user)
url_provider.is_a?(Url::PublicUrl).should eq true
twitter_provider = DatasourcesFactory.get_datasource(Search::Twitter::DATASOURCE_NAME, user)
twitter_provider.is_a?(Search::Twitter).should eq true
nil_provider = DatasourcesFactory.get_datasource(nil, user)
nil_provider.nil?.should eq true
expect {
DatasourcesFactory.get_datasource('blablabla...', user)
}.to raise_exception MissingConfigurationError
end
end
describe '#customized_config?' do
let(:twitter_datasource) { CartoDB::Datasources::Search::Twitter::DATASOURCE_NAME }
before(:each) do
@config = get_config
end
it 'returns false for a random user' do
user = FactoryGirl.build(:carto_user, username: 'wadus')
DatasourcesFactory.customized_config?(twitter_datasource, user).should be_false
end
it 'returns true for a user with custom config' do
user = FactoryGirl.build(:carto_user, username: 'wadus')
@config['datasource_search']['twitter_search']['customized_user_list'] = [user.username]
DatasourcesFactory.set_config(@config)
DatasourcesFactory.customized_config?(twitter_datasource, user).should be_true
end
it 'returns true for a user in an organization with custom config' do
organization = Carto::Organization.new(name: 'wadus-org')
user = FactoryGirl.build(:carto_user, username: 'nowadus', organization: organization)
@config['datasource_search']['twitter_search']['customized_orgs_list'] = [organization.name]
DatasourcesFactory.set_config(@config)
DatasourcesFactory.customized_config?(twitter_datasource, user).should be_true
end
end
end
@@ -0,0 +1,35 @@
require 'yaml'
require_relative '../../lib/datasources'
require_relative '../doubles/user'
include CartoDB::Datasources
describe Url::Dropbox do
def get_config
@config ||= YAML.load_file("#{File.dirname(__FILE__)}/../../../../config/app_config.yml")['defaults']['oauth']['dropbox']
end #get_config
describe '#manual_test' do
it 'with user interaction, tests the full oauth flow and lists files of an account' do
user_mock = CartoDB::Datasources::Doubles::User.new
config = get_config
dropbox_datasource = Url::Dropbox.get_new(config, user_mock)
if config.include?(:access_token)
dropbox_datasource.token = config[:access_token]
else
pending('This test requires manual run, opening the url in a browser, grabbing the code and setting "input" to it')
puts dropbox_datasource.get_auth_url
input = ''
dropbox_datasource.validate_auth_code(input)
puts dropbox_datasource.token
end
data = dropbox_datasource.get_resources_list
puts data
end
end
end
@@ -0,0 +1,36 @@
require 'yaml'
require_relative '../../lib/datasources'
require_relative '../doubles/user'
include CartoDB::Datasources
describe Url::GDrive do
def get_config
@config ||= YAML.load_file("#{File.dirname(__FILE__)}/../../../../config/app_config.yml")['defaults']['oauth']['gdrive']
end
describe '#manual_test' do
it 'with user interaction, tests the full oauth flow and lists files of an account' do
config = get_config
if !config.include?(:refresh_token)
pending('If config unset, this test requires manual running. Check its source code to see what to do')
end
user_mock = CartoDB::Datasources::Doubles::User.new
gdrive_datasource = Url::GDrive.get_new(config, user_mock)
if config.include?(:refresh_token)
gdrive_datasource.token = config[:refresh_token]
else
# Manual testing
puts gdrive_datasource.get_auth_url
input = ''
gdrive_datasource.validate_auth_code(input)
puts gdrive_datasource.token
end
data = gdrive_datasource.get_resources_list
puts data
end
end
end
@@ -0,0 +1,36 @@
require_relative '../../lib/datasources'
require_relative '../../../../spec/helpers/file_server_helper'
include CartoDB::Datasources
include FileServerHelper
describe Url::PublicUrl do
describe '#basic_tests' do
it 'Some basic download flows of this file provider, including error handling' do
url_provider = Url::PublicUrl.get_new
serve_file 'spec/support/data/cartofante_blue.png' do |url|
invalid_url = url + 'invalid'
data = url_provider.get_resource(url)
data.empty?.should eq false
expect {
url_provider.get_resource(invalid_url)
}.to raise_exception DataDownloadError
url_provider.fetch_headers(url)
url_provider.etag_header.empty?.should eq false
url_provider.fetch_headers(invalid_url).should == {}
url_provider.etag_header.should be_empty
url_provider.last_modified_header.should be_empty
# puts data
end
end
end
end
@@ -0,0 +1,20 @@
module CartoDB
module Datasources
module Doubles
class DataImport
attr_accessor :id,
:service_item_id
def initialize(attrs = {})
@id = attrs.fetch(:id, '123456')
@service_item_id = attrs.fetch(:service_item_id, '67890')
end
def save
self
end
end
end
end
end
@@ -0,0 +1,23 @@
module CartoDB
module TwitterSearch
module Doubles
class JSONToCSVConverter
def initialize(attrs = {})
end
def process(input_data = [], add_headers = false, additional_fields = {})
input_data.join("\n")
end
def generate_headers(additional_fields = {})
if additional_fields.nil? || additional_fields.empty?
''
else
additional_fields.join(',')
end
end
end
end
end
end
@@ -0,0 +1,18 @@
module CartoDB
module Datasources
module Doubles
class Organization
attr_accessor :twitter_datasource_enabled
def initialize(attrs = {})
@twitter_datasource_enabled = attrs.fetch(:twitter_datasource_enabled, true)
end
def save
self
end
end
end
end
end
@@ -0,0 +1,26 @@
module CartoDB
module Datasources
module Doubles
class SearchTweet
attr_accessor :user_id,
:data_import_id,
:service_item_id,
:retrieved_items,
:state
def set_importing_state
@state = 'importing'
end
def set_complete_state
@state = 'complete'
end
def save
self
end
end
end
end
end
+45
View File
@@ -0,0 +1,45 @@
module CartoDB
module Datasources
module Doubles
class User
attr_accessor :twitter_datasource_enabled,
:soft_twitter_datasource_limit,
:twitter_datasource_quota,
:username,
:id
def initialize(attrs = {})
@twitter_datasource_enabled = attrs.fetch(:twitter_datasource_enabled, true)
@soft_twitter_datasource_limit = attrs.fetch(:soft_twitter_datasource_limit, false)
@twitter_datasource_quota = attrs.fetch(:twitter_datasource_quota, 123)
@username = attrs.fetch(:username, 'wadus')
@id = attrs.fetch(:id, '000-000')
@organization = attrs.fetch(:has_org, false) \
? Organization.new({
twitter_datasource_enabled: attrs.fetch(:org_twitter_datasource_enabled, true),
twitter_datasource_quota: attrs.fetch(:org_twitter_datasource_quota, 123)
}) \
: nil
end
def organization
@organization
end
def save
self
end
def remaining_twitter_quota
if @organization.nil?
@twitter_datasource_quota
else
@organization.twitter_datasource_quota
end
end
end
end
end
end
+63
View File
@@ -0,0 +1,63 @@
{
"displayFieldName": "NAME",
"fieldAliases": {
"OBJECTID": "OBJECTID",
"WDPAID": "WDPAID",
"NAME": "NAME"
},
"geometryType": "esriGeometryPolygon",
"spatialReference": {
"wkid": 4326,
"latestWkid": 4326
},
"fields": [
{
"name": "OBJECTID",
"type": "esriFieldTypeOID",
"alias": "OBJECTID"
},
{
"name": "WDPAID",
"type": "esriFieldTypeInteger",
"alias": "WDPAID"
},
{
"name": "NAME",
"type": "esriFieldTypeString",
"alias": "NAME",
"length": 254
}
],
"features": [
{
"attributes": {
"OBJECTID": 1,
"WDPAID": 991,
"NAME": "Name of object 1"
},
"geometry": {
"fake": "geom"
}
},
{
"attributes": {
"OBJECTID": 2,
"WDPAID": 992,
"NAME": "Name of object 2"
},
"geometry": {
"fake": "geom"
}
},
{
"attributes": {
"OBJECTID": 3,
"WDPAID": 993,
"NAME": "Name of object 3"
},
"geometry": {
"fake": "geom"
}
}
]
}
@@ -0,0 +1 @@
{"objectIdFieldName":"OBJECTID","objectIds":[1,2,3,4,5,6,7,8,9,10]}
@@ -0,0 +1 @@
{"objectIdFieldName":"OBJECTID","objectIds":[1,2,3]}
+3
View File
@@ -0,0 +1,3 @@
{"layers":[
{"id":0, "name":"first layer", "type":"Feature Layer"}
]}
@@ -0,0 +1,22 @@
{"currentVersion":10.22,
"id":0,
"name":"Test Feature",
"type":"Feature Layer",
"description":"Sample metadata payload",
"geometryType":"esriGeometryPolygon",
"copyrightText":"CartoDB",
"fields":[
{"name":"OBJECTID",
"type":"esriFieldTypeOID",
"alias":"OBJECTID",
"domain":null},
{"name":"NAME",
"type":"esriFieldTypeString",
"alias":"NAME",
"length":254,
"domain":null}
],
"maxRecordCount":1000,
"supportsAdvancedQueries":true,
"supportedQueryFormats":"JSON,AMF",
"useStandardizedQueries":true}
@@ -0,0 +1 @@
{"objectIdFieldName":"OBJECTID","objectIds":[7,6,10,4,5,2,1,8,9,3]}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
"link","body","objectType","postedTime","favoritesCount","twitter_lang","retweetCount","actor_id","actor_displayName","actor_image","actor_summary","actor_postedTime","actor_location","actor_utcOffset","actor_preferredUsername","actor_friendsCount","actor_followersCount","actor_listedCount","actor_statusesCount","actor_verified","inReplyTo_link","geo","twitter_entities","location_geo","location_name","the_geom","category_name","category_terms"
"http://twitter.com/charley_glynn/statuses/834008296361172992","RT @uSIG_CCHS_CSIC: Carto tips: Using blend modes and opacity levels-https://t.co/eGDWGkfWCv via @OrdnanceSurvey https://t.co/qEfFQpLGvL","activity","2017-02-21T11:54:07.000Z","0","en","6","id:twitter.com:492926788","Charley Glynn","https://pbs.twimg.com/profile_images/740123846951460864/gi1ee2lJ_normal.jpg","happy husband, doting dad, Everton fan, music, film & design fan, pushing pixels and joining dots @ordnancesurvey working on #GeoDataViz","2012-02-15T08:23:02.000Z","{""objectType"":""place"",""displayName"":""Southampton, England""}","0","charley_glynn","2482","1415","168","7536","false",,,"{""hashtags"":[],""urls"":[{""url"":""https://t.co/eGDWGkfWCv"",""expanded_url"":""https://www.ordnancesurvey.co.uk/blog/2017/02/carto-tips-using-blend-modes-opacity-levels/"",""display_url"":""ordnancesurvey.co.uk/blog/2017/02/c…"",""indices"":[69,92]}],""user_mentions"":[{""screen_name"":""uSIG_CCHS_CSIC"",""name"":""uSIG (CCHS-CSIC)"",""id"":704613732614344704,""id_str"":""704613732614344704"",""indices"":[3,18]},{""screen_name"":""OrdnanceSurvey"",""name"":""Ordnance Survey"",""id"":22614266,""id_str"":""22614266"",""indices"":[97,112]}],""symbols"":[],""media"":[{""id"":833943995302760449,""id_str"":""833943995302760449"",""indices"":[113,136],""media_url"":""http://pbs.twimg.com/tweet_video_thumb/C5LDpTKXUAE1xex.jpg"",""media_url_https"":""https://pbs.twimg.com/tweet_video_thumb/C5LDpTKXUAE1xex.jpg"",""url"":""https://t.co/qEfFQpLGvL"",""display_url"":""pic.twitter.com/qEfFQpLGvL"",""expanded_url"":""https://twitter.com/uSIG_CCHS_CSIC/status/833972033960620032/photo/1"",""type"":""photo"",""sizes"":{""medium"":{""w"":600,""h"":415,""resize"":""fit""},""large"":{""w"":650,""h"":450,""resize"":""fit""},""thumb"":{""w"":150,""h"":150,""resize"":""crop""},""small"":{""w"":340,""h"":235,""resize"":""fit""}},""source_status_id"":833972033960620032,""source_status_id_str"":""833972033960620032"",""source_user_id"":704613732614344704,""source_user_id_str"":""704613732614344704""}]}",,,"{""coordinates"":[-1.40428,50.90395],""type"":""point""}","1","carto"
1 link body objectType postedTime favoritesCount twitter_lang retweetCount actor_id actor_displayName actor_image actor_summary actor_postedTime actor_location actor_utcOffset actor_preferredUsername actor_friendsCount actor_followersCount actor_listedCount actor_statusesCount actor_verified inReplyTo_link geo twitter_entities location_geo location_name the_geom category_name category_terms
2 http://twitter.com/charley_glynn/statuses/834008296361172992 RT @uSIG_CCHS_CSIC: Carto tips: Using blend modes and opacity levels-https://t.co/eGDWGkfWCv via @OrdnanceSurvey https://t.co/qEfFQpLGvL activity 2017-02-21T11:54:07.000Z 0 en 6 id:twitter.com:492926788 Charley Glynn https://pbs.twimg.com/profile_images/740123846951460864/gi1ee2lJ_normal.jpg happy husband, doting dad, Everton fan, music, film & design fan, pushing pixels and joining dots @ordnancesurvey working on #GeoDataViz 2012-02-15T08:23:02.000Z {"objectType":"place","displayName":"Southampton, England"} 0 charley_glynn 2482 1415 168 7536 false {"hashtags":[],"urls":[{"url":"https://t.co/eGDWGkfWCv","expanded_url":"https://www.ordnancesurvey.co.uk/blog/2017/02/carto-tips-using-blend-modes-opacity-levels/","display_url":"ordnancesurvey.co.uk/blog/2017/02/c…","indices":[69,92]}],"user_mentions":[{"screen_name":"uSIG_CCHS_CSIC","name":"uSIG (CCHS-CSIC)","id":704613732614344704,"id_str":"704613732614344704","indices":[3,18]},{"screen_name":"OrdnanceSurvey","name":"Ordnance Survey","id":22614266,"id_str":"22614266","indices":[97,112]}],"symbols":[],"media":[{"id":833943995302760449,"id_str":"833943995302760449","indices":[113,136],"media_url":"http://pbs.twimg.com/tweet_video_thumb/C5LDpTKXUAE1xex.jpg","media_url_https":"https://pbs.twimg.com/tweet_video_thumb/C5LDpTKXUAE1xex.jpg","url":"https://t.co/qEfFQpLGvL","display_url":"pic.twitter.com/qEfFQpLGvL","expanded_url":"https://twitter.com/uSIG_CCHS_CSIC/status/833972033960620032/photo/1","type":"photo","sizes":{"medium":{"w":600,"h":415,"resize":"fit"},"large":{"w":650,"h":450,"resize":"fit"},"thumb":{"w":150,"h":150,"resize":"crop"},"small":{"w":340,"h":235,"resize":"fit"}},"source_status_id":833972033960620032,"source_status_id_str":"833972033960620032","source_user_id":704613732614344704,"source_user_id_str":"704613732614344704"}]} {"coordinates":[-1.40428,50.90395],"type":"point"} 1 carto
+409
View File
@@ -0,0 +1,409 @@
{
"results": [
{
"id": "tag:search.twitter.com,2005:834008296361172992",
"objectType": "activity",
"verb": "share",
"postedTime": "2017-02-21T11:54:07.000Z",
"generator": {
"displayName": "Twitter Web Client",
"link": "http://twitter.com"
},
"provider": {
"objectType": "service",
"displayName": "Twitter",
"link": "http://www.twitter.com"
},
"link": "http://twitter.com/charley_glynn/statuses/834008296361172992",
"body": "RT @uSIG_CCHS_CSIC: Carto tips: Using blend modes and opacity levels-https://t.co/eGDWGkfWCv via @OrdnanceSurvey https://t.co/qEfFQpLGvL",
"actor": {
"objectType": "person",
"id": "id:twitter.com:492926788",
"link": "http://www.twitter.com/charley_glynn",
"displayName": "Charley Glynn",
"postedTime": "2012-02-15T08:23:02.000Z",
"image": "https://pbs.twimg.com/profile_images/740123846951460864/gi1ee2lJ_normal.jpg",
"summary": "happy husband, doting dad, Everton fan, music, film & design fan, pushing pixels and joining dots @ordnancesurvey working on #GeoDataViz",
"friendsCount": 2482,
"followersCount": 1415,
"listedCount": 168,
"statusesCount": 7536,
"twitterTimeZone": "London",
"verified": false,
"utcOffset": "0",
"preferredUsername": "charley_glynn",
"languages": [
"en"
],
"links": [
{
"href": "http://www.cartoblography.wordpress.com",
"rel": "me"
}
],
"location": {
"objectType": "place",
"displayName": "Southampton, England"
},
"favoritesCount": 7414
},
"object": {
"id": "tag:search.twitter.com,2005:833972033960620032",
"objectType": "activity",
"verb": "post",
"postedTime": "2017-02-21T09:30:02.000Z",
"generator": {
"displayName": "TweetDeck",
"link": "https://about.twitter.com/products/tweetdeck"
},
"provider": {
"objectType": "service",
"displayName": "Twitter",
"link": "http://www.twitter.com"
},
"link": "http://twitter.com/uSIG_CCHS_CSIC/statuses/833972033960620032",
"body": "Carto tips: Using blend modes and opacity levels-https://t.co/eGDWGkfWCv via @OrdnanceSurvey https://t.co/qEfFQpLGvL",
"display_text_range": [
0,
92
],
"actor": {
"objectType": "person",
"id": "id:twitter.com:704613732614344704",
"link": "http://www.twitter.com/uSIG_CCHS_CSIC",
"displayName": "uSIG (CCHS-CSIC)",
"postedTime": "2016-03-01T10:26:19.759Z",
"image": "https://pbs.twimg.com/profile_images/802141753553854464/4ZSz4PI-_normal.jpg",
"summary": "Unidad SIG. Espacio, tiempo, ciencia y tecnología #SIG #Cartografía y #Teledetección en Ciencias Humanas y Sociales @CSIC. #GIS #Cartography #RemoteSensing",
"friendsCount": 317,
"followersCount": 515,
"listedCount": 41,
"statusesCount": 1462,
"twitterTimeZone": null,
"verified": false,
"utcOffset": null,
"preferredUsername": "uSIG_CCHS_CSIC",
"languages": [
"es"
],
"links": [
{
"href": "http://unidadsig.cchs.csic.es/sig/",
"rel": "me"
}
],
"location": {
"objectType": "place",
"displayName": "Madrid, España"
},
"favoritesCount": 811
},
"object": {
"objectType": "note",
"id": "object:search.twitter.com,2005:833972033960620032",
"summary": "Carto tips: Using blend modes and opacity levels-https://t.co/eGDWGkfWCv via @OrdnanceSurvey https://t.co/qEfFQpLGvL",
"link": "http://twitter.com/uSIG_CCHS_CSIC/statuses/833972033960620032",
"postedTime": "2017-02-21T09:30:02.000Z"
},
"favoritesCount": 11,
"twitter_entities": {
"hashtags": [],
"urls": [
{
"url": "https://t.co/eGDWGkfWCv",
"expanded_url": "https://www.ordnancesurvey.co.uk/blog/2017/02/carto-tips-using-blend-modes-opacity-levels/",
"display_url": "ordnancesurvey.co.uk/blog/2017/02/c…",
"indices": [
49,
72
]
}
],
"user_mentions": [
{
"screen_name": "OrdnanceSurvey",
"name": "Ordnance Survey",
"id": 22614266,
"id_str": "22614266",
"indices": [
77,
92
]
}
],
"symbols": [],
"media": [
{
"id": 833943995302760449,
"id_str": "833943995302760449",
"indices": [
93,
116
],
"media_url": "http://pbs.twimg.com/tweet_video_thumb/C5LDpTKXUAE1xex.jpg",
"media_url_https": "https://pbs.twimg.com/tweet_video_thumb/C5LDpTKXUAE1xex.jpg",
"url": "https://t.co/qEfFQpLGvL",
"display_url": "pic.twitter.com/qEfFQpLGvL",
"expanded_url": "https://twitter.com/uSIG_CCHS_CSIC/status/833972033960620032/photo/1",
"type": "photo",
"sizes": {
"medium": {
"w": 600,
"h": 415,
"resize": "fit"
},
"large": {
"w": 650,
"h": 450,
"resize": "fit"
},
"thumb": {
"w": 150,
"h": 150,
"resize": "crop"
},
"small": {
"w": 340,
"h": 235,
"resize": "fit"
}
}
}
]
},
"twitter_extended_entities": {
"media": [
{
"id": 833943995302760449,
"id_str": "833943995302760449",
"indices": [
93,
116
],
"media_url": "http://pbs.twimg.com/tweet_video_thumb/C5LDpTKXUAE1xex.jpg",
"media_url_https": "https://pbs.twimg.com/tweet_video_thumb/C5LDpTKXUAE1xex.jpg",
"url": "https://t.co/qEfFQpLGvL",
"display_url": "pic.twitter.com/qEfFQpLGvL",
"expanded_url": "https://twitter.com/uSIG_CCHS_CSIC/status/833972033960620032/photo/1",
"type": "animated_gif",
"sizes": {
"medium": {
"w": 600,
"h": 415,
"resize": "fit"
},
"large": {
"w": 650,
"h": 450,
"resize": "fit"
},
"thumb": {
"w": 150,
"h": 150,
"resize": "crop"
},
"small": {
"w": 340,
"h": 235,
"resize": "fit"
}
},
"video_info": {
"aspect_ratio": [
13,
9
],
"variants": [
{
"bitrate": 0,
"content_type": "video/mp4",
"url": "https://video.twimg.com/tweet_video/C5LDpTKXUAE1xex.mp4"
}
]
}
}
]
},
"twitter_lang": "en",
"twitter_filter_level": "low"
},
"favoritesCount": 0,
"twitter_entities": {
"hashtags": [],
"urls": [
{
"url": "https://t.co/eGDWGkfWCv",
"expanded_url": "https://www.ordnancesurvey.co.uk/blog/2017/02/carto-tips-using-blend-modes-opacity-levels/",
"display_url": "ordnancesurvey.co.uk/blog/2017/02/c…",
"indices": [
69,
92
]
}
],
"user_mentions": [
{
"screen_name": "uSIG_CCHS_CSIC",
"name": "uSIG (CCHS-CSIC)",
"id": 704613732614344704,
"id_str": "704613732614344704",
"indices": [
3,
18
]
},
{
"screen_name": "OrdnanceSurvey",
"name": "Ordnance Survey",
"id": 22614266,
"id_str": "22614266",
"indices": [
97,
112
]
}
],
"symbols": [],
"media": [
{
"id": 833943995302760449,
"id_str": "833943995302760449",
"indices": [
113,
136
],
"media_url": "http://pbs.twimg.com/tweet_video_thumb/C5LDpTKXUAE1xex.jpg",
"media_url_https": "https://pbs.twimg.com/tweet_video_thumb/C5LDpTKXUAE1xex.jpg",
"url": "https://t.co/qEfFQpLGvL",
"display_url": "pic.twitter.com/qEfFQpLGvL",
"expanded_url": "https://twitter.com/uSIG_CCHS_CSIC/status/833972033960620032/photo/1",
"type": "photo",
"sizes": {
"medium": {
"w": 600,
"h": 415,
"resize": "fit"
},
"large": {
"w": 650,
"h": 450,
"resize": "fit"
},
"thumb": {
"w": 150,
"h": 150,
"resize": "crop"
},
"small": {
"w": 340,
"h": 235,
"resize": "fit"
}
},
"source_status_id": 833972033960620032,
"source_status_id_str": "833972033960620032",
"source_user_id": 704613732614344704,
"source_user_id_str": "704613732614344704"
}
]
},
"twitter_extended_entities": {
"media": [
{
"id": 833943995302760449,
"id_str": "833943995302760449",
"indices": [
113,
136
],
"media_url": "http://pbs.twimg.com/tweet_video_thumb/C5LDpTKXUAE1xex.jpg",
"media_url_https": "https://pbs.twimg.com/tweet_video_thumb/C5LDpTKXUAE1xex.jpg",
"url": "https://t.co/qEfFQpLGvL",
"display_url": "pic.twitter.com/qEfFQpLGvL",
"expanded_url": "https://twitter.com/uSIG_CCHS_CSIC/status/833972033960620032/photo/1",
"type": "animated_gif",
"sizes": {
"medium": {
"w": 600,
"h": 415,
"resize": "fit"
},
"large": {
"w": 650,
"h": 450,
"resize": "fit"
},
"thumb": {
"w": 150,
"h": 150,
"resize": "crop"
},
"small": {
"w": 340,
"h": 235,
"resize": "fit"
}
},
"source_status_id": 833972033960620032,
"source_status_id_str": "833972033960620032",
"source_user_id": 704613732614344704,
"source_user_id_str": "704613732614344704",
"video_info": {
"aspect_ratio": [
13,
9
],
"variants": [
{
"bitrate": 0,
"content_type": "video/mp4",
"url": "https://video.twimg.com/tweet_video/C5LDpTKXUAE1xex.mp4"
}
]
}
}
]
},
"twitter_lang": "en",
"retweetCount": 6,
"gnip": {
"profileLocations": [
{
"address": {
"country": "United Kingdom",
"countryCode": "GB",
"locality": "Southampton",
"region": "England",
"subRegion": "City of Southampton"
},
"displayName": "Southampton, England, United Kingdom",
"geo": {
"coordinates": [
-1.40428,
50.90395
],
"type": "point"
},
"objectType": "place"
}
],
"matching_rules": [
{
"value": "(carto) (has:geo OR has:profile_geo)",
"tag": null
}
],
"urls": [
{
"url": "https://t.co/eGDWGkfWCv",
"expanded_url": "https://www.ordnancesurvey.co.uk/blog/2017/02/carto-tips-using-blend-modes-opacity-levels/",
"expanded_status": 200,
"expanded_url_title": "Carto tips: Using blend modes and opacity levels - Ordnance Survey Blog",
"expanded_url_description": "Colour is one of the main graphic elements that a cartographer uses to make their map clear to read. Amongst other things we use colour to create familiarity, to differentiate features and to create a clear visual hierarchy. There are many things we can do to the features on our maps to change their appearance... Read More"
}
]
},
"twitter_filter_level": "low"
}
]
}
+1
View File
@@ -0,0 +1 @@
stream_input_1
+1
View File
@@ -0,0 +1 @@
stream_input_2
@@ -0,0 +1,40 @@
"id","verb","link","body","objectType","postedTime","favoritesCount","twitter_filter_level","twitter_lang","retweetCount","actor_objectType","actor_id","actor_link","actor_displayName","actor_image","actor_summary","actor_postedTime","actor_links","actor_location","actor_utcOffset","actor_preferredUsername","actor_languages","actor_twitterTimeZone","actor_friendsCount","actor_followersCount","actor_listedCount","actor_statusesCount","actor_verified","generator_displayName","generator_link","provider_objectType","provider_displayName","provider_link","inReplyTo_link","geo","twitter_entities","object_objectType","object_id","object_summary","object_postedTime","object_link","location_objectType","location_displayName","location_link","location_geo","location_streetAddress","location_name","gnip","the_geom","category_name","category_terms"
"tag:search.twitter.com,2005:496274441584668672","post","http://twitter.com/erictheise/statuses/496274441584668672","sad to be the guy that knocked ""locahost"" out of @cartoDB's docs for #Odyssey.js; i've certainly had that sentiment towards my dev machine.","activity","2014-08-04T12:40:21.000Z","0","medium","en","0","person","id:twitter.com:94817737","http://www.twitter.com/erictheise","Eric Theise","https://pbs.twimg.com/profile_images/1994194116/Theise_normal.jpg","Software engineer; photographer, experimental filmmaker, cartographer, geographer, vocalizer, yoga student, reader & writer, eater & drinker.","2009-12-05T16:06:12.000Z","[{""href"":null,""rel"":""me""}]","{""objectType"":""place"",""displayName"":""San Francisco""}","-25200","erictheise","[""en""]","Pacific Time (US & Canada)","698","306","25","1322","false","Twitter Web Client","http://twitter.com","service","Twitter","http://www.twitter.com",,,"{""hashtags"":[{""text"":""Odyssey"",""indices"":[69,77]}],""trends"":[],""urls"":[],""user_mentions"":[{""screen_name"":""cartoDB"",""name"":""CartoDB"",""id"":241079136,""id_str"":""241079136"",""indices"":[49,57]}],""symbols"":[]}","note","object:search.twitter.com,2005:496274441584668672","sad to be the guy that knocked ""locahost"" out of @cartoDB's docs for #Odyssey.js; i've certainly had that sentiment towards my dev machine.","2014-08-04T12:40:21.000Z","http://twitter.com/erictheise/statuses/496274441584668672","place","San Francisco, CA","https://api.twitter.com/1.1/geo/id/5a110d312052166f.json","{""type"":""Polygon"",""coordinates"":[[[-122.514926,37.708075],[-122.514926,37.833238],[-122.328001,37.833238],[-122.328001,37.708075]]]}",,"San Francisco","{""klout_score"":41,""language"":{""value"":""en""}}","{""type"":""Polygon"",""coordinates"":[[[-122.514926,37.708075],[-122.514926,37.833238],[-122.328001,37.833238],[-122.328001,37.708075]]]}","Category 1","uno, @dos, #tres"
"tag:search.twitter.com,2005:495565956908064769","post","http://twitter.com/Jmholleran/statuses/495565956908064769","FrackMap #map http://t.co/BLWl3zYKTn","activity","2014-08-02T13:45:05.000Z","0","medium","en","0","person","id:twitter.com:27128139","http://www.twitter.com/Jmholleran","John Munro Holleran","https://pbs.twimg.com/profile_images/460662090819051520/lzLtBzUb_normal.png","Modest! intelligent, articulate & generally just amazing! Trade Unionist, learning facilitator 'n that","2009-03-27T23:36:39.000Z","[{""href"":null,""rel"":""me""}]","{""objectType"":""place"",""displayName"":""Glasgow/Scotland""}",,"Jmholleran","[""en""]",,"411","192","1","1643","false","Twitter for Android","http://twitter.com/download/android","service","Twitter","http://www.twitter.com",,"{""type"":""Point"",""coordinates"":[55.8641,-4.282299]}","{""hashtags"":[{""text"":""map"",""indices"":[9,13]}],""trends"":[],""urls"":[{""url"":""http://t.co/BLWl3zYKTn"",""expanded_url"":""http://energydesk.cartodb.com/viz/18162194-ecbd-11e3-aba1-0e230854a1cb/embed_map?title=true&description=true&search=false&shareable=true&cartodb_logo=true&layer_selector=false&legends=true&scrollwheel=true&fullscreen=true&sublayer_options=1%7C1%7C1%7C1&sql=&sw_lat=49.97948776108648&sw_lon=-12.7001953125&ne_lat=56.80087831233043&ne_lon=8.96484375"",""display_url"":""energydesk.cartodb.com/viz/18162194-e…"",""indices"":[14,36]}],""user_mentions"":[],""symbols"":[]}","note","object:search.twitter.com,2005:495565956908064769","FrackMap #map http://t.co/BLWl3zYKTn","2014-08-02T13:45:05.000Z","http://twitter.com/Jmholleran/statuses/495565956908064769","place","Glasgow","https://api.twitter.com/1.1/geo/id/791e00bcadc4615f.json","{""type"":""Polygon"",""coordinates"":[[[-4.3932845,55.796184],[-4.3932845,55.9204214],[-4.0902182,55.9204214],[-4.0902182,55.796184]]]}",,"Glasgow","{""urls"":[{""url"":""http://t.co/BLWl3zYKTn"",""expanded_url"":""http://energydesk.cartodb.com/viz/18162194-ecbd-11e3-aba1-0e230854a1cb/embed_map?title=true&description=true&search=false&shareable=true&cartodb_logo=true&layer_selector=false&legends=true&scrollwheel=true&fullscreen=true&sublayer_options=1%7C1%7C1%7C1&sql&sw_lat=49.97948776108648&sw_lon=-12.7001953125&ne_lat=56.80087831233043&ne_lon=8.96484375"",""expanded_status"":200}],""klout_score"":23}","{""type"":""Point"",""coordinates"":[-4.282299,55.8641]}","Category 1","uno, @dos, #tres"
"tag:search.twitter.com,2005:494521462682685440","post","http://twitter.com/SonOfJorEl/statuses/494521462682685440","@iriberri1 @cartoDB you are saying it is not supported, but you have an entire blog post about said feature : http://t.co/gvuUWv9sge","activity","2014-07-30T16:34:39.000Z","0","medium","en","0","person","id:twitter.com:15628310","http://www.twitter.com/SonOfJorEl","Joey Schluchter","https://pbs.twimg.com/profile_images/466697750013562880/ngwKpO1j_normal.jpeg","Recovering golfer. Helping feed the world to software. Still love persimmon.","2008-07-28T05:34:03.000Z","[{""href"":""http://www.exzeo.com"",""rel"":""me""}]","{""objectType"":""place"",""displayName"":""Clearwater, FL""}","-14400","SonOfJorEl","[""en""]","Eastern Time (US & Canada)","368","2053","5","2143","false","Tweetbot for Mac","http://tapbots.com/software/tweetbot/mac","service","Twitter","http://www.twitter.com","http://twitter.com/iriberri1/statuses/494488277765091328",,"{""hashtags"":[],""symbols"":[],""urls"":[{""url"":""http://t.co/gvuUWv9sge"",""expanded_url"":""http://blog.cartodb.com/got-files-weve-got-a-import-api/"",""display_url"":""blog.cartodb.com/got-files-weve…"",""indices"":[110,132]}],""user_mentions"":[{""screen_name"":""iriberri1"",""name"":""Carla"",""id"":102197411,""id_str"":""102197411"",""indices"":[0,10]},{""screen_name"":""cartoDB"",""name"":""CartoDB"",""id"":241079136,""id_str"":""241079136"",""indices"":[11,19]}]}","note","object:search.twitter.com,2005:494521462682685440","@iriberri1 @cartoDB you are saying it is not supported, but you have an entire blog post about said feature : http://t.co/gvuUWv9sge","2014-07-30T16:34:39.000Z","http://twitter.com/SonOfJorEl/statuses/494521462682685440","place","Tampa, FL","https://api.twitter.com/1.1/geo/id/dc62519fda13b4ec.json","{""type"":""Polygon"",""coordinates"":[[[-82.648358,27.821353],[-82.648358,28.171245],[-82.289105,28.171245],[-82.289105,27.821353]]]}",,"Tampa","{""urls"":[{""url"":""http://t.co/gvuUWv9sge"",""expanded_url"":""http://blog.cartodb.com/got-files-weve-got-a-import-api/"",""expanded_status"":200}],""klout_score"":42,""language"":{""value"":""en""}}","{""type"":""Polygon"",""coordinates"":[[[-82.648358,27.821353],[-82.648358,28.171245],[-82.289105,28.171245],[-82.289105,27.821353]]]}","Category 1","uno, @dos, #tres"
"tag:search.twitter.com,2005:494511938563342336","post","http://twitter.com/xavijam/statuses/494511938563342336",". @Javier is giving birth last @cartodb feature. All I can say is … GIF over maps! http://t.co/oC3DEK5cP6","activity","2014-07-30T15:56:48.000Z","0","medium","en","0","person","id:twitter.com:26775253","http://www.twitter.com/xavijam","Javier Álvarez","https://pbs.twimg.com/profile_images/3025038956/a2919d353eb2b1a22756d7ac79847480_normal.jpeg","I love gentoo penguins","2009-03-26T15:36:52.000Z","[{""href"":""http://xavij.am"",""rel"":""me""}]","{""objectType"":""place"",""displayName"":""Antarctic Peninsula""}","7200","xavijam","[""en""]","Madrid","250","395","26","4981","false","Tweetbot for iΟS","http://tapbots.com/tweetbot","service","Twitter","http://www.twitter.com",,,"{""hashtags"":[],""trends"":[],""urls"":[],""user_mentions"":[{""screen_name"":""javier"",""name"":""Javier Arce"",""id"":39083,""id_str"":""39083"",""indices"":[2,9]},{""screen_name"":""cartoDB"",""name"":""CartoDB"",""id"":241079136,""id_str"":""241079136"",""indices"":[31,39]}],""symbols"":[],""media"":[{""id"":494511934629093378,""id_str"":""494511934629093378"",""indices"":[83,105],""media_url"":""http://pbs.twimg.com/media/Btzb_AjCIAI0cLr.jpg"",""media_url_https"":""https://pbs.twimg.com/media/Btzb_AjCIAI0cLr.jpg"",""url"":""http://t.co/oC3DEK5cP6"",""display_url"":""pic.twitter.com/oC3DEK5cP6"",""expanded_url"":""http://twitter.com/xavijam/status/494511938563342336/photo/1"",""type"":""photo"",""sizes"":{""small"":{""w"":340,""h"":255,""resize"":""fit""},""medium"":{""w"":600,""h"":450,""resize"":""fit""},""large"":{""w"":1024,""h"":768,""resize"":""fit""},""thumb"":{""w"":150,""h"":150,""resize"":""crop""}}}]}","note","object:search.twitter.com,2005:494511938563342336",". @Javier is giving birth last @cartodb feature. All I can say is … GIF over maps! http://t.co/oC3DEK5cP6","2014-07-30T15:56:48.000Z","http://twitter.com/xavijam/statuses/494511938563342336","place","Trafalgar","https://api.twitter.com/1.1/geo/id/0144b1172069289c.json","{""type"":""Polygon"",""coordinates"":[[[-3.70582,40.4275708],[-3.70582,40.4386747],[-3.6956326,40.4386747],[-3.6956326,40.4275708]]]}",,"Trafalgar","{""urls"":[{""url"":""http://t.co/oC3DEK5cP6"",""expanded_url"":""http://twitter.com/xavijam/status/494511938563342336/photo/1"",""expanded_status"":200}],""klout_score"":39,""language"":{""value"":""en""}}","{""type"":""Polygon"",""coordinates"":[[[-3.70582,40.4275708],[-3.70582,40.4386747],[-3.6956326,40.4386747],[-3.6956326,40.4275708]]]}","Category 1","uno, @dos, #tres"
"tag:search.twitter.com,2005:494485754848894978","post","http://twitter.com/SonOfJorEl/statuses/494485754848894978","@cartoDB I’ve emailed support (which you claim has a 24hr turnaround) with minimal response. I am a paying customer ($149 mo). For how long?","activity","2014-07-30T14:12:45.000Z","0","medium","en","0","person","id:twitter.com:15628310","http://www.twitter.com/SonOfJorEl","Joey Schluchter","https://pbs.twimg.com/profile_images/466697750013562880/ngwKpO1j_normal.jpeg","Recovering golfer. Helping feed the world to software. Still love persimmon.","2008-07-28T05:34:03.000Z","[{""href"":""http://www.exzeo.com"",""rel"":""me""}]","{""objectType"":""place"",""displayName"":""Clearwater, FL""}","-14400","SonOfJorEl","[""en""]","Eastern Time (US & Canada)","368","2053","5","2142","false","Tweetbot for Mac","http://tapbots.com/software/tweetbot/mac","service","Twitter","http://www.twitter.com",,,"{""hashtags"":[],""trends"":[],""urls"":[],""user_mentions"":[{""screen_name"":""cartoDB"",""name"":""CartoDB"",""id"":241079136,""id_str"":""241079136"",""indices"":[0,8]}],""symbols"":[]}","note","object:search.twitter.com,2005:494485754848894978","@cartoDB I’ve emailed support (which you claim has a 24hr turnaround) with minimal response. I am a paying customer ($149 mo). For how long?","2014-07-30T14:12:45.000Z","http://twitter.com/SonOfJorEl/statuses/494485754848894978","place","Tampa, FL","https://api.twitter.com/1.1/geo/id/dc62519fda13b4ec.json","{""type"":""Polygon"",""coordinates"":[[[-82.648358,27.821353],[-82.648358,28.171245],[-82.289105,28.171245],[-82.289105,27.821353]]]}",,"Tampa","{""klout_score"":42,""language"":{""value"":""en""}}","{""type"":""Polygon"",""coordinates"":[[[-82.648358,27.821353],[-82.648358,28.171245],[-82.289105,28.171245],[-82.289105,27.821353]]]}","Category 1","uno, @dos, #tres"
"tag:search.twitter.com,2005:494276921765543936","post","http://twitter.com/httsan/statuses/494276921765543936","""@gisuser: Online Mapping for Beginners – The Map Academy from CartoDB: TweetSomething innovative from… http://t.co/Zhh882TV2F""","activity","2014-07-30T00:22:56.000Z","0","medium","en","0","person","id:twitter.com:28471178","http://www.twitter.com/httsan","hartanto","https://pbs.twimg.com/profile_images/2217327231/IMG0033_normal.jpg","Lahir di Indonesia | Nguli di @NEOnetBPPT @BPPTeknologi | Ngobrol di @RSGISForum @PadangZaitun | Nyantri di @MichiganStateU","2009-04-03T01:32:35.000Z","[{""href"":""http://about.me/httsan"",""rel"":""me""}]","{""objectType"":""place"",""displayName"":""East Lansing""}","-14400","httsan","[""en""]","Eastern Time (US & Canada)","976","873","12","25725","false","Twitter for Android","http://twitter.com/download/android","service","Twitter","http://www.twitter.com",,"{""type"":""Point"",""coordinates"":[42.7539688,-84.4221123]}","{""hashtags"":[],""symbols"":[],""urls"":[{""url"":""http://t.co/Zhh882TV2F"",""expanded_url"":""http://goo.gl/fb/xVB5rr"",""display_url"":""goo.gl/fb/xVB5rr"",""indices"":[104,126]}],""user_mentions"":[{""screen_name"":""gisuser"",""name"":""GISuser GeoTech News"",""id"":16958615,""id_str"":""16958615"",""indices"":[1,9]}]}","note","object:search.twitter.com,2005:494276921765543936","""@gisuser: Online Mapping for Beginners – The Map Academy from CartoDB: TweetSomething innovative from… http://t.co/Zhh882TV2F""","2014-07-30T00:22:56.000Z","http://twitter.com/httsan/statuses/494276921765543936","place","Haslett, MI","https://api.twitter.com/1.1/geo/id/4399b5004a3b4d9a.json","{""type"":""Polygon"",""coordinates"":[[[-84.447506,42.731229],[-84.447506,42.769688],[-84.363432,42.769688],[-84.363432,42.731229]]]}",,"Haslett","{""urls"":[{""url"":""http://t.co/Zhh882TV2F"",""expanded_url"":""http://blog.gisuser.com/2014/07/29/online-mapping-for-beginners-the-map-academy-from-cartodb/?utm_source=feedburner&utm_medium=twitter&utm_campaign=Feed:%20gisuser%20(GISUser.com%20-%20GIS,%20Mapping,%20Geospatial,%20and%20location%20technology%20news)"",""expanded_status"":200}],""klout_score"":51,""language"":{""value"":""en""}}","{""type"":""Point"",""coordinates"":[-84.4221123,42.7539688]}","Category 1","uno, @dos, #tres"
"tag:search.twitter.com,2005:494274906528288769","post","http://twitter.com/carygeo/statuses/494274906528288769","Attention mappers: iframe, #javascript, app development examples from the #CartoDB API/team! Well done @andrewxhill. http://t.co/cJM5fzgoyL","activity","2014-07-30T00:14:55.000Z","0","medium","en","0","person","id:twitter.com:2641688154","http://www.twitter.com/carygeo","Cary Greenwood","https://pbs.twimg.com/profile_images/488501325697544192/gFMxHuLV_normal.jpeg","geography, maps, data visualization, ui/ux design","2014-07-14T01:35:22.000Z","[{""href"":null,""rel"":""me""}]",,,"carygeo","[""en""]",,"109","46","0","47","false","Twitter for iPhone","http://twitter.com/download/iphone","service","Twitter","http://www.twitter.com",,"{""type"":""Point"",""coordinates"":[34.13846767,-118.3620715]}","{""hashtags"":[{""text"":""javascript"",""indices"":[27,38]},{""text"":""CartoDB"",""indices"":[74,82]}],""symbols"":[],""urls"":[{""url"":""http://t.co/cJM5fzgoyL"",""expanded_url"":""http://vimeo.com/m/101936210"",""display_url"":""vimeo.com/m/101936210"",""indices"":[117,139]}],""user_mentions"":[{""screen_name"":""andrewxhill"",""name"":""Andrew W Hill"",""id"":19893224,""id_str"":""19893224"",""indices"":[103,115]}]}","note","object:search.twitter.com,2005:494274906528288769","Attention mappers: iframe, #javascript, app development examples from the #CartoDB API/team! Well done @andrewxhill. http://t.co/cJM5fzgoyL","2014-07-30T00:14:55.000Z","http://twitter.com/carygeo/statuses/494274906528288769","place","Los Angeles, CA","https://api.twitter.com/1.1/geo/id/3b77caf94bfc81fe.json","{""type"":""Polygon"",""coordinates"":[[[-118.668404,33.704538],[-118.668404,34.330724],[-118.155409,34.330724],[-118.155409,33.704538]]]}",,"Los Angeles","{""urls"":[{""url"":""http://t.co/cJM5fzgoyL"",""expanded_url"":""http://vimeo.com/101936210"",""expanded_status"":200}],""klout_score"":31,""language"":{""value"":""en""}}","{""type"":""Point"",""coordinates"":[-118.3620715,34.13846767]}","Category 1","uno, @dos, #tres"
"tag:search.twitter.com,2005:494194150133481472","post","http://twitter.com/juanjeojeda/statuses/494194150133481472","@dig_geo_com Your welcome :-) Keep in mind that some parts of @cartoDB still doesn't work fine. We're waiting for update from our provider.","activity","2014-07-29T18:54:01.000Z","0","medium","en","0","person","id:twitter.com:12679112","http://www.twitter.com/juanjeojeda","Juanje Ojeda ","https://pbs.twimg.com/profile_images/46025702/juanje_normal.jpg","Canarian guy who likes the open knowledge, the freedom and learn things every day. #OpenSource #DevOps #PaaS #Canarias","2008-01-25T11:49:15.000Z","[{""href"":""http://about.me/juanje"",""rel"":""me""}]","{""objectType"":""place"",""displayName"":""Las Palmas de Gran Canaria""}","3600","juanjeojeda","[""es""]","London","416","1006","86","17700","false","Twitter Web Client","http://twitter.com","service","Twitter","http://www.twitter.com","http://twitter.com/dig_geo_com/statuses/494191759078211584",,"{""hashtags"":[],""trends"":[],""urls"":[],""user_mentions"":[{""screen_name"":""dig_geo_com"",""name"":""digital-geography"",""id"":231016902,""id_str"":""231016902"",""indices"":[0,12]},{""screen_name"":""cartoDB"",""name"":""CartoDB"",""id"":241079136,""id_str"":""241079136"",""indices"":[62,70]}],""symbols"":[]}","note","object:search.twitter.com,2005:494194150133481472","@dig_geo_com Your welcome :-) Keep in mind that some parts of @cartoDB still doesn't work fine. We're waiting for update from our provider.","2014-07-29T18:54:01.000Z","http://twitter.com/juanjeojeda/statuses/494194150133481472","place","Las Palmas de Gran Canaria, Las Palmas","https://api.twitter.com/1.1/geo/id/00ab08eef1b62e92.json","{""type"":""Polygon"",""coordinates"":[[[-15.5255036,28.0248125],[-15.5255036,28.1812125],[-15.3941943,28.1812125],[-15.3941943,28.0248125]]]}",,"Las Palmas de Gran Canaria","{""klout_score"":56,""language"":{""value"":""en""}}","{""type"":""Polygon"",""coordinates"":[[[-15.5255036,28.0248125],[-15.5255036,28.1812125],[-15.3941943,28.1812125],[-15.3941943,28.0248125]]]}","Category 1","uno, @dos, #tres"
"tag:search.twitter.com,2005:494191583240388608","post","http://twitter.com/juanjeojeda/statuses/494191583240388608","@dig_geo_com Hi, @cartoDB blog is already back. We had some issues with our hosting provider. Sorry for the inconveniences.","activity","2014-07-29T18:43:49.000Z","0","medium","en","0","person","id:twitter.com:12679112","http://www.twitter.com/juanjeojeda","Juanje Ojeda ","https://pbs.twimg.com/profile_images/46025702/juanje_normal.jpg","Canarian guy who likes the open knowledge, the freedom and learn things every day. #OpenSource #DevOps #PaaS #Canarias","2008-01-25T11:49:15.000Z","[{""href"":""http://about.me/juanje"",""rel"":""me""}]","{""objectType"":""place"",""displayName"":""Las Palmas de Gran Canaria""}","3600","juanjeojeda","[""es""]","London","416","1006","86","17698","false","Twitter Web Client","http://twitter.com","service","Twitter","http://www.twitter.com","http://twitter.com/dig_geo_com/statuses/494181592685117442",,"{""hashtags"":[],""trends"":[],""urls"":[],""user_mentions"":[{""screen_name"":""dig_geo_com"",""name"":""digital-geography"",""id"":231016902,""id_str"":""231016902"",""indices"":[0,12]},{""screen_name"":""cartoDB"",""name"":""CartoDB"",""id"":241079136,""id_str"":""241079136"",""indices"":[17,25]}],""symbols"":[]}","note","object:search.twitter.com,2005:494191583240388608","@dig_geo_com Hi, @cartoDB blog is already back. We had some issues with our hosting provider. Sorry for the inconveniences.","2014-07-29T18:43:49.000Z","http://twitter.com/juanjeojeda/statuses/494191583240388608","place","Las Palmas de Gran Canaria, Las Palmas","https://api.twitter.com/1.1/geo/id/00ab08eef1b62e92.json","{""type"":""Polygon"",""coordinates"":[[[-15.5255036,28.0248125],[-15.5255036,28.1812125],[-15.3941943,28.1812125],[-15.3941943,28.0248125]]]}",,"Las Palmas de Gran Canaria","{""klout_score"":56,""language"":{""value"":""en""}}","{""type"":""Polygon"",""coordinates"":[[[-15.5255036,28.0248125],[-15.5255036,28.1812125],[-15.3941943,28.1812125],[-15.3941943,28.0248125]]]}","Category 1","uno, @dos, #tres"
"tag:search.twitter.com,2005:494141648922611712","post","http://twitter.com/spara/statuses/494141648922611712","@vtcraghead @briantimoney @billdollins CartoDB needs to cut a deal with a cloud provider, they’re a bit too parsimonious on disk space","activity","2014-07-29T15:25:24.000Z","0","medium","en","0","person","id:twitter.com:14629939","http://www.twitter.com/spara","spara","https://pbs.twimg.com/profile_images/469468370413166592/uuOHhLby_normal.jpeg","Ut mitterent eos in faciem.","2008-05-02T19:24:50.000Z","[{""href"":""http://sproke.blogspot.com"",""rel"":""me""}]","{""objectType"":""place"",""displayName"":""San Antonio Francisco""}","-25200","spara","[""en""]","Pacific Time (US & Canada)","1311","1276","128","24585","false","Tweetbot for Mac","http://tapbots.com/software/tweetbot/mac","service","Twitter","http://www.twitter.com","http://twitter.com/vtcraghead/statuses/494140334226833408",,"{""hashtags"":[],""symbols"":[],""urls"":[],""user_mentions"":[{""screen_name"":""vtcraghead"",""name"":""Bill Morris"",""id"":278873782,""id_str"":""278873782"",""indices"":[0,11]},{""screen_name"":""briantimoney"",""name"":""Brian Timoney"",""id"":17546328,""id_str"":""17546328"",""indices"":[12,25]},{""screen_name"":""billdollins"",""name"":""Bill Dollins"",""id"":12405802,""id_str"":""12405802"",""indices"":[26,38]}]}","note","object:search.twitter.com,2005:494141648922611712","@vtcraghead @briantimoney @billdollins CartoDB needs to cut a deal with a cloud provider, they’re a bit too parsimonious on disk space","2014-07-29T15:25:24.000Z","http://twitter.com/spara/statuses/494141648922611712","place","Highland Park, San Antonio","https://api.twitter.com/1.1/geo/id/097c1754d9aa7b39.json","{""type"":""Polygon"",""coordinates"":[[[-98.478227,29.384112],[-98.478227,29.401578],[-98.448015,29.401578],[-98.448015,29.384112]]]}",,"Highland Park","{""language"":{""value"":""en""}}","{""type"":""Polygon"",""coordinates"":[[[-98.478227,29.384112],[-98.478227,29.401578],[-98.448015,29.401578],[-98.448015,29.384112]]]}","Category 1","uno, @dos, #tres"
"tag:search.twitter.com,2005:496274441584668672","post","http://twitter.com/erictheise/statuses/496274441584668672","sad to be the guy that knocked ""locahost"" out of @cartoDB's docs for #Odyssey.js; i've certainly had that sentiment towards my dev machine.","activity","2014-08-04T12:40:21.000Z","0","medium","en","0","person","id:twitter.com:94817737","http://www.twitter.com/erictheise","Eric Theise","https://pbs.twimg.com/profile_images/1994194116/Theise_normal.jpg","Software engineer; photographer, experimental filmmaker, cartographer, geographer, vocalizer, yoga student, reader & writer, eater & drinker.","2009-12-05T16:06:12.000Z","[{""href"":null,""rel"":""me""}]","{""objectType"":""place"",""displayName"":""San Francisco""}","-25200","erictheise","[""en""]","Pacific Time (US & Canada)","698","306","25","1322","false","Twitter Web Client","http://twitter.com","service","Twitter","http://www.twitter.com",,,"{""hashtags"":[{""text"":""Odyssey"",""indices"":[69,77]}],""trends"":[],""urls"":[],""user_mentions"":[{""screen_name"":""cartoDB"",""name"":""CartoDB"",""id"":241079136,""id_str"":""241079136"",""indices"":[49,57]}],""symbols"":[]}","note","object:search.twitter.com,2005:496274441584668672","sad to be the guy that knocked ""locahost"" out of @cartoDB's docs for #Odyssey.js; i've certainly had that sentiment towards my dev machine.","2014-08-04T12:40:21.000Z","http://twitter.com/erictheise/statuses/496274441584668672","place","San Francisco, CA","https://api.twitter.com/1.1/geo/id/5a110d312052166f.json","{""type"":""Polygon"",""coordinates"":[[[-122.514926,37.708075],[-122.514926,37.833238],[-122.328001,37.833238],[-122.328001,37.708075]]]}",,"San Francisco","{""klout_score"":41,""language"":{""value"":""en""}}","{""type"":""Polygon"",""coordinates"":[[[-122.514926,37.708075],[-122.514926,37.833238],[-122.328001,37.833238],[-122.328001,37.708075]]]}","Category 1","uno, @dos, #tres"
"tag:search.twitter.com,2005:495565956908064769","post","http://twitter.com/Jmholleran/statuses/495565956908064769","FrackMap #map http://t.co/BLWl3zYKTn","activity","2014-08-02T13:45:05.000Z","0","medium","en","0","person","id:twitter.com:27128139","http://www.twitter.com/Jmholleran","John Munro Holleran","https://pbs.twimg.com/profile_images/460662090819051520/lzLtBzUb_normal.png","Modest! intelligent, articulate & generally just amazing! Trade Unionist, learning facilitator 'n that","2009-03-27T23:36:39.000Z","[{""href"":null,""rel"":""me""}]","{""objectType"":""place"",""displayName"":""Glasgow/Scotland""}",,"Jmholleran","[""en""]",,"411","192","1","1643","false","Twitter for Android","http://twitter.com/download/android","service","Twitter","http://www.twitter.com",,"{""type"":""Point"",""coordinates"":[55.8641,-4.282299]}","{""hashtags"":[{""text"":""map"",""indices"":[9,13]}],""trends"":[],""urls"":[{""url"":""http://t.co/BLWl3zYKTn"",""expanded_url"":""http://energydesk.cartodb.com/viz/18162194-ecbd-11e3-aba1-0e230854a1cb/embed_map?title=true&description=true&search=false&shareable=true&cartodb_logo=true&layer_selector=false&legends=true&scrollwheel=true&fullscreen=true&sublayer_options=1%7C1%7C1%7C1&sql=&sw_lat=49.97948776108648&sw_lon=-12.7001953125&ne_lat=56.80087831233043&ne_lon=8.96484375"",""display_url"":""energydesk.cartodb.com/viz/18162194-e…"",""indices"":[14,36]}],""user_mentions"":[],""symbols"":[]}","note","object:search.twitter.com,2005:495565956908064769","FrackMap #map http://t.co/BLWl3zYKTn","2014-08-02T13:45:05.000Z","http://twitter.com/Jmholleran/statuses/495565956908064769","place","Glasgow","https://api.twitter.com/1.1/geo/id/791e00bcadc4615f.json","{""type"":""Polygon"",""coordinates"":[[[-4.3932845,55.796184],[-4.3932845,55.9204214],[-4.0902182,55.9204214],[-4.0902182,55.796184]]]}",,"Glasgow","{""urls"":[{""url"":""http://t.co/BLWl3zYKTn"",""expanded_url"":""http://energydesk.cartodb.com/viz/18162194-ecbd-11e3-aba1-0e230854a1cb/embed_map?title=true&description=true&search=false&shareable=true&cartodb_logo=true&layer_selector=false&legends=true&scrollwheel=true&fullscreen=true&sublayer_options=1%7C1%7C1%7C1&sql&sw_lat=49.97948776108648&sw_lon=-12.7001953125&ne_lat=56.80087831233043&ne_lon=8.96484375"",""expanded_status"":200}],""klout_score"":23}","{""type"":""Point"",""coordinates"":[-4.282299,55.8641]}","Category 1","uno, @dos, #tres"
"tag:search.twitter.com,2005:494521462682685440","post","http://twitter.com/SonOfJorEl/statuses/494521462682685440","@iriberri1 @cartoDB you are saying it is not supported, but you have an entire blog post about said feature : http://t.co/gvuUWv9sge","activity","2014-07-30T16:34:39.000Z","0","medium","en","0","person","id:twitter.com:15628310","http://www.twitter.com/SonOfJorEl","Joey Schluchter","https://pbs.twimg.com/profile_images/466697750013562880/ngwKpO1j_normal.jpeg","Recovering golfer. Helping feed the world to software. Still love persimmon.","2008-07-28T05:34:03.000Z","[{""href"":""http://www.exzeo.com"",""rel"":""me""}]","{""objectType"":""place"",""displayName"":""Clearwater, FL""}","-14400","SonOfJorEl","[""en""]","Eastern Time (US & Canada)","368","2053","5","2143","false","Tweetbot for Mac","http://tapbots.com/software/tweetbot/mac","service","Twitter","http://www.twitter.com","http://twitter.com/iriberri1/statuses/494488277765091328",,"{""hashtags"":[],""symbols"":[],""urls"":[{""url"":""http://t.co/gvuUWv9sge"",""expanded_url"":""http://blog.cartodb.com/got-files-weve-got-a-import-api/"",""display_url"":""blog.cartodb.com/got-files-weve…"",""indices"":[110,132]}],""user_mentions"":[{""screen_name"":""iriberri1"",""name"":""Carla"",""id"":102197411,""id_str"":""102197411"",""indices"":[0,10]},{""screen_name"":""cartoDB"",""name"":""CartoDB"",""id"":241079136,""id_str"":""241079136"",""indices"":[11,19]}]}","note","object:search.twitter.com,2005:494521462682685440","@iriberri1 @cartoDB you are saying it is not supported, but you have an entire blog post about said feature : http://t.co/gvuUWv9sge","2014-07-30T16:34:39.000Z","http://twitter.com/SonOfJorEl/statuses/494521462682685440","place","Tampa, FL","https://api.twitter.com/1.1/geo/id/dc62519fda13b4ec.json","{""type"":""Polygon"",""coordinates"":[[[-82.648358,27.821353],[-82.648358,28.171245],[-82.289105,28.171245],[-82.289105,27.821353]]]}",,"Tampa","{""urls"":[{""url"":""http://t.co/gvuUWv9sge"",""expanded_url"":""http://blog.cartodb.com/got-files-weve-got-a-import-api/"",""expanded_status"":200}],""klout_score"":42,""language"":{""value"":""en""}}","{""type"":""Polygon"",""coordinates"":[[[-82.648358,27.821353],[-82.648358,28.171245],[-82.289105,28.171245],[-82.289105,27.821353]]]}","Category 1","uno, @dos, #tres"
"tag:search.twitter.com,2005:494511938563342336","post","http://twitter.com/xavijam/statuses/494511938563342336",". @Javier is giving birth last @cartodb feature. All I can say is … GIF over maps! http://t.co/oC3DEK5cP6","activity","2014-07-30T15:56:48.000Z","0","medium","en","0","person","id:twitter.com:26775253","http://www.twitter.com/xavijam","Javier Álvarez","https://pbs.twimg.com/profile_images/3025038956/a2919d353eb2b1a22756d7ac79847480_normal.jpeg","I love gentoo penguins","2009-03-26T15:36:52.000Z","[{""href"":""http://xavij.am"",""rel"":""me""}]","{""objectType"":""place"",""displayName"":""Antarctic Peninsula""}","7200","xavijam","[""en""]","Madrid","250","395","26","4981","false","Tweetbot for iΟS","http://tapbots.com/tweetbot","service","Twitter","http://www.twitter.com",,,"{""hashtags"":[],""trends"":[],""urls"":[],""user_mentions"":[{""screen_name"":""javier"",""name"":""Javier Arce"",""id"":39083,""id_str"":""39083"",""indices"":[2,9]},{""screen_name"":""cartoDB"",""name"":""CartoDB"",""id"":241079136,""id_str"":""241079136"",""indices"":[31,39]}],""symbols"":[],""media"":[{""id"":494511934629093378,""id_str"":""494511934629093378"",""indices"":[83,105],""media_url"":""http://pbs.twimg.com/media/Btzb_AjCIAI0cLr.jpg"",""media_url_https"":""https://pbs.twimg.com/media/Btzb_AjCIAI0cLr.jpg"",""url"":""http://t.co/oC3DEK5cP6"",""display_url"":""pic.twitter.com/oC3DEK5cP6"",""expanded_url"":""http://twitter.com/xavijam/status/494511938563342336/photo/1"",""type"":""photo"",""sizes"":{""small"":{""w"":340,""h"":255,""resize"":""fit""},""medium"":{""w"":600,""h"":450,""resize"":""fit""},""large"":{""w"":1024,""h"":768,""resize"":""fit""},""thumb"":{""w"":150,""h"":150,""resize"":""crop""}}}]}","note","object:search.twitter.com,2005:494511938563342336",". @Javier is giving birth last @cartodb feature. All I can say is … GIF over maps! http://t.co/oC3DEK5cP6","2014-07-30T15:56:48.000Z","http://twitter.com/xavijam/statuses/494511938563342336","place","Trafalgar","https://api.twitter.com/1.1/geo/id/0144b1172069289c.json","{""type"":""Polygon"",""coordinates"":[[[-3.70582,40.4275708],[-3.70582,40.4386747],[-3.6956326,40.4386747],[-3.6956326,40.4275708]]]}",,"Trafalgar","{""urls"":[{""url"":""http://t.co/oC3DEK5cP6"",""expanded_url"":""http://twitter.com/xavijam/status/494511938563342336/photo/1"",""expanded_status"":200}],""klout_score"":39,""language"":{""value"":""en""}}","{""type"":""Polygon"",""coordinates"":[[[-3.70582,40.4275708],[-3.70582,40.4386747],[-3.6956326,40.4386747],[-3.6956326,40.4275708]]]}","Category 1","uno, @dos, #tres"
"tag:search.twitter.com,2005:494485754848894978","post","http://twitter.com/SonOfJorEl/statuses/494485754848894978","@cartoDB I’ve emailed support (which you claim has a 24hr turnaround) with minimal response. I am a paying customer ($149 mo). For how long?","activity","2014-07-30T14:12:45.000Z","0","medium","en","0","person","id:twitter.com:15628310","http://www.twitter.com/SonOfJorEl","Joey Schluchter","https://pbs.twimg.com/profile_images/466697750013562880/ngwKpO1j_normal.jpeg","Recovering golfer. Helping feed the world to software. Still love persimmon.","2008-07-28T05:34:03.000Z","[{""href"":""http://www.exzeo.com"",""rel"":""me""}]","{""objectType"":""place"",""displayName"":""Clearwater, FL""}","-14400","SonOfJorEl","[""en""]","Eastern Time (US & Canada)","368","2053","5","2142","false","Tweetbot for Mac","http://tapbots.com/software/tweetbot/mac","service","Twitter","http://www.twitter.com",,,"{""hashtags"":[],""trends"":[],""urls"":[],""user_mentions"":[{""screen_name"":""cartoDB"",""name"":""CartoDB"",""id"":241079136,""id_str"":""241079136"",""indices"":[0,8]}],""symbols"":[]}","note","object:search.twitter.com,2005:494485754848894978","@cartoDB I’ve emailed support (which you claim has a 24hr turnaround) with minimal response. I am a paying customer ($149 mo). For how long?","2014-07-30T14:12:45.000Z","http://twitter.com/SonOfJorEl/statuses/494485754848894978","place","Tampa, FL","https://api.twitter.com/1.1/geo/id/dc62519fda13b4ec.json","{""type"":""Polygon"",""coordinates"":[[[-82.648358,27.821353],[-82.648358,28.171245],[-82.289105,28.171245],[-82.289105,27.821353]]]}",,"Tampa","{""klout_score"":42,""language"":{""value"":""en""}}","{""type"":""Polygon"",""coordinates"":[[[-82.648358,27.821353],[-82.648358,28.171245],[-82.289105,28.171245],[-82.289105,27.821353]]]}","Category 1","uno, @dos, #tres"
"tag:search.twitter.com,2005:494276921765543936","post","http://twitter.com/httsan/statuses/494276921765543936","""@gisuser: Online Mapping for Beginners – The Map Academy from CartoDB: TweetSomething innovative from… http://t.co/Zhh882TV2F""","activity","2014-07-30T00:22:56.000Z","0","medium","en","0","person","id:twitter.com:28471178","http://www.twitter.com/httsan","hartanto","https://pbs.twimg.com/profile_images/2217327231/IMG0033_normal.jpg","Lahir di Indonesia | Nguli di @NEOnetBPPT @BPPTeknologi | Ngobrol di @RSGISForum @PadangZaitun | Nyantri di @MichiganStateU","2009-04-03T01:32:35.000Z","[{""href"":""http://about.me/httsan"",""rel"":""me""}]","{""objectType"":""place"",""displayName"":""East Lansing""}","-14400","httsan","[""en""]","Eastern Time (US & Canada)","976","873","12","25725","false","Twitter for Android","http://twitter.com/download/android","service","Twitter","http://www.twitter.com",,"{""type"":""Point"",""coordinates"":[42.7539688,-84.4221123]}","{""hashtags"":[],""symbols"":[],""urls"":[{""url"":""http://t.co/Zhh882TV2F"",""expanded_url"":""http://goo.gl/fb/xVB5rr"",""display_url"":""goo.gl/fb/xVB5rr"",""indices"":[104,126]}],""user_mentions"":[{""screen_name"":""gisuser"",""name"":""GISuser GeoTech News"",""id"":16958615,""id_str"":""16958615"",""indices"":[1,9]}]}","note","object:search.twitter.com,2005:494276921765543936","""@gisuser: Online Mapping for Beginners – The Map Academy from CartoDB: TweetSomething innovative from… http://t.co/Zhh882TV2F""","2014-07-30T00:22:56.000Z","http://twitter.com/httsan/statuses/494276921765543936","place","Haslett, MI","https://api.twitter.com/1.1/geo/id/4399b5004a3b4d9a.json","{""type"":""Polygon"",""coordinates"":[[[-84.447506,42.731229],[-84.447506,42.769688],[-84.363432,42.769688],[-84.363432,42.731229]]]}",,"Haslett","{""urls"":[{""url"":""http://t.co/Zhh882TV2F"",""expanded_url"":""http://blog.gisuser.com/2014/07/29/online-mapping-for-beginners-the-map-academy-from-cartodb/?utm_source=feedburner&utm_medium=twitter&utm_campaign=Feed:%20gisuser%20(GISUser.com%20-%20GIS,%20Mapping,%20Geospatial,%20and%20location%20technology%20news)"",""expanded_status"":200}],""klout_score"":51,""language"":{""value"":""en""}}","{""type"":""Point"",""coordinates"":[-84.4221123,42.7539688]}","Category 1","uno, @dos, #tres"
"tag:search.twitter.com,2005:494274906528288769","post","http://twitter.com/carygeo/statuses/494274906528288769","Attention mappers: iframe, #javascript, app development examples from the #CartoDB API/team! Well done @andrewxhill. http://t.co/cJM5fzgoyL","activity","2014-07-30T00:14:55.000Z","0","medium","en","0","person","id:twitter.com:2641688154","http://www.twitter.com/carygeo","Cary Greenwood","https://pbs.twimg.com/profile_images/488501325697544192/gFMxHuLV_normal.jpeg","geography, maps, data visualization, ui/ux design","2014-07-14T01:35:22.000Z","[{""href"":null,""rel"":""me""}]",,,"carygeo","[""en""]",,"109","46","0","47","false","Twitter for iPhone","http://twitter.com/download/iphone","service","Twitter","http://www.twitter.com",,"{""type"":""Point"",""coordinates"":[34.13846767,-118.3620715]}","{""hashtags"":[{""text"":""javascript"",""indices"":[27,38]},{""text"":""CartoDB"",""indices"":[74,82]}],""symbols"":[],""urls"":[{""url"":""http://t.co/cJM5fzgoyL"",""expanded_url"":""http://vimeo.com/m/101936210"",""display_url"":""vimeo.com/m/101936210"",""indices"":[117,139]}],""user_mentions"":[{""screen_name"":""andrewxhill"",""name"":""Andrew W Hill"",""id"":19893224,""id_str"":""19893224"",""indices"":[103,115]}]}","note","object:search.twitter.com,2005:494274906528288769","Attention mappers: iframe, #javascript, app development examples from the #CartoDB API/team! Well done @andrewxhill. http://t.co/cJM5fzgoyL","2014-07-30T00:14:55.000Z","http://twitter.com/carygeo/statuses/494274906528288769","place","Los Angeles, CA","https://api.twitter.com/1.1/geo/id/3b77caf94bfc81fe.json","{""type"":""Polygon"",""coordinates"":[[[-118.668404,33.704538],[-118.668404,34.330724],[-118.155409,34.330724],[-118.155409,33.704538]]]}",,"Los Angeles","{""urls"":[{""url"":""http://t.co/cJM5fzgoyL"",""expanded_url"":""http://vimeo.com/101936210"",""expanded_status"":200}],""klout_score"":31,""language"":{""value"":""en""}}","{""type"":""Point"",""coordinates"":[-118.3620715,34.13846767]}","Category 1","uno, @dos, #tres"
"tag:search.twitter.com,2005:494194150133481472","post","http://twitter.com/juanjeojeda/statuses/494194150133481472","@dig_geo_com Your welcome :-) Keep in mind that some parts of @cartoDB still doesn't work fine. We're waiting for update from our provider.","activity","2014-07-29T18:54:01.000Z","0","medium","en","0","person","id:twitter.com:12679112","http://www.twitter.com/juanjeojeda","Juanje Ojeda ","https://pbs.twimg.com/profile_images/46025702/juanje_normal.jpg","Canarian guy who likes the open knowledge, the freedom and learn things every day. #OpenSource #DevOps #PaaS #Canarias","2008-01-25T11:49:15.000Z","[{""href"":""http://about.me/juanje"",""rel"":""me""}]","{""objectType"":""place"",""displayName"":""Las Palmas de Gran Canaria""}","3600","juanjeojeda","[""es""]","London","416","1006","86","17700","false","Twitter Web Client","http://twitter.com","service","Twitter","http://www.twitter.com","http://twitter.com/dig_geo_com/statuses/494191759078211584",,"{""hashtags"":[],""trends"":[],""urls"":[],""user_mentions"":[{""screen_name"":""dig_geo_com"",""name"":""digital-geography"",""id"":231016902,""id_str"":""231016902"",""indices"":[0,12]},{""screen_name"":""cartoDB"",""name"":""CartoDB"",""id"":241079136,""id_str"":""241079136"",""indices"":[62,70]}],""symbols"":[]}","note","object:search.twitter.com,2005:494194150133481472","@dig_geo_com Your welcome :-) Keep in mind that some parts of @cartoDB still doesn't work fine. We're waiting for update from our provider.","2014-07-29T18:54:01.000Z","http://twitter.com/juanjeojeda/statuses/494194150133481472","place","Las Palmas de Gran Canaria, Las Palmas","https://api.twitter.com/1.1/geo/id/00ab08eef1b62e92.json","{""type"":""Polygon"",""coordinates"":[[[-15.5255036,28.0248125],[-15.5255036,28.1812125],[-15.3941943,28.1812125],[-15.3941943,28.0248125]]]}",,"Las Palmas de Gran Canaria","{""klout_score"":56,""language"":{""value"":""en""}}","{""type"":""Polygon"",""coordinates"":[[[-15.5255036,28.0248125],[-15.5255036,28.1812125],[-15.3941943,28.1812125],[-15.3941943,28.0248125]]]}","Category 1","uno, @dos, #tres"
"tag:search.twitter.com,2005:494191583240388608","post","http://twitter.com/juanjeojeda/statuses/494191583240388608","@dig_geo_com Hi, @cartoDB blog is already back. We had some issues with our hosting provider. Sorry for the inconveniences.","activity","2014-07-29T18:43:49.000Z","0","medium","en","0","person","id:twitter.com:12679112","http://www.twitter.com/juanjeojeda","Juanje Ojeda ","https://pbs.twimg.com/profile_images/46025702/juanje_normal.jpg","Canarian guy who likes the open knowledge, the freedom and learn things every day. #OpenSource #DevOps #PaaS #Canarias","2008-01-25T11:49:15.000Z","[{""href"":""http://about.me/juanje"",""rel"":""me""}]","{""objectType"":""place"",""displayName"":""Las Palmas de Gran Canaria""}","3600","juanjeojeda","[""es""]","London","416","1006","86","17698","false","Twitter Web Client","http://twitter.com","service","Twitter","http://www.twitter.com","http://twitter.com/dig_geo_com/statuses/494181592685117442",,"{""hashtags"":[],""trends"":[],""urls"":[],""user_mentions"":[{""screen_name"":""dig_geo_com"",""name"":""digital-geography"",""id"":231016902,""id_str"":""231016902"",""indices"":[0,12]},{""screen_name"":""cartoDB"",""name"":""CartoDB"",""id"":241079136,""id_str"":""241079136"",""indices"":[17,25]}],""symbols"":[]}","note","object:search.twitter.com,2005:494191583240388608","@dig_geo_com Hi, @cartoDB blog is already back. We had some issues with our hosting provider. Sorry for the inconveniences.","2014-07-29T18:43:49.000Z","http://twitter.com/juanjeojeda/statuses/494191583240388608","place","Las Palmas de Gran Canaria, Las Palmas","https://api.twitter.com/1.1/geo/id/00ab08eef1b62e92.json","{""type"":""Polygon"",""coordinates"":[[[-15.5255036,28.0248125],[-15.5255036,28.1812125],[-15.3941943,28.1812125],[-15.3941943,28.0248125]]]}",,"Las Palmas de Gran Canaria","{""klout_score"":56,""language"":{""value"":""en""}}","{""type"":""Polygon"",""coordinates"":[[[-15.5255036,28.0248125],[-15.5255036,28.1812125],[-15.3941943,28.1812125],[-15.3941943,28.0248125]]]}","Category 1","uno, @dos, #tres"
"tag:search.twitter.com,2005:494141648922611712","post","http://twitter.com/spara/statuses/494141648922611712","@vtcraghead @briantimoney @billdollins CartoDB needs to cut a deal with a cloud provider, they’re a bit too parsimonious on disk space","activity","2014-07-29T15:25:24.000Z","0","medium","en","0","person","id:twitter.com:14629939","http://www.twitter.com/spara","spara","https://pbs.twimg.com/profile_images/469468370413166592/uuOHhLby_normal.jpeg","Ut mitterent eos in faciem.","2008-05-02T19:24:50.000Z","[{""href"":""http://sproke.blogspot.com"",""rel"":""me""}]","{""objectType"":""place"",""displayName"":""San Antonio Francisco""}","-25200","spara","[""en""]","Pacific Time (US & Canada)","1311","1276","128","24585","false","Tweetbot for Mac","http://tapbots.com/software/tweetbot/mac","service","Twitter","http://www.twitter.com","http://twitter.com/vtcraghead/statuses/494140334226833408",,"{""hashtags"":[],""symbols"":[],""urls"":[],""user_mentions"":[{""screen_name"":""vtcraghead"",""name"":""Bill Morris"",""id"":278873782,""id_str"":""278873782"",""indices"":[0,11]},{""screen_name"":""briantimoney"",""name"":""Brian Timoney"",""id"":17546328,""id_str"":""17546328"",""indices"":[12,25]},{""screen_name"":""billdollins"",""name"":""Bill Dollins"",""id"":12405802,""id_str"":""12405802"",""indices"":[26,38]}]}","note","object:search.twitter.com,2005:494141648922611712","@vtcraghead @briantimoney @billdollins CartoDB needs to cut a deal with a cloud provider, they’re a bit too parsimonious on disk space","2014-07-29T15:25:24.000Z","http://twitter.com/spara/statuses/494141648922611712","place","Highland Park, San Antonio","https://api.twitter.com/1.1/geo/id/097c1754d9aa7b39.json","{""type"":""Polygon"",""coordinates"":[[[-98.478227,29.384112],[-98.478227,29.401578],[-98.448015,29.401578],[-98.448015,29.384112]]]}",,"Highland Park","{""language"":{""value"":""en""}}","{""type"":""Polygon"",""coordinates"":[[[-98.478227,29.384112],[-98.478227,29.401578],[-98.448015,29.401578],[-98.448015,29.384112]]]}","Category 1","uno, @dos, #tres"
"tag:search.twitter.com,2005:496274441584668672","post","http://twitter.com/erictheise/statuses/496274441584668672","sad to be the guy that knocked ""locahost"" out of @cartoDB's docs for #Odyssey.js; i've certainly had that sentiment towards my dev machine.","activity","2014-08-04T12:40:21.000Z","0","medium","en","0","person","id:twitter.com:94817737","http://www.twitter.com/erictheise","Eric Theise","https://pbs.twimg.com/profile_images/1994194116/Theise_normal.jpg","Software engineer; photographer, experimental filmmaker, cartographer, geographer, vocalizer, yoga student, reader & writer, eater & drinker.","2009-12-05T16:06:12.000Z","[{""href"":null,""rel"":""me""}]","{""objectType"":""place"",""displayName"":""San Francisco""}","-25200","erictheise","[""en""]","Pacific Time (US & Canada)","698","306","25","1322","false","Twitter Web Client","http://twitter.com","service","Twitter","http://www.twitter.com",,,"{""hashtags"":[{""text"":""Odyssey"",""indices"":[69,77]}],""trends"":[],""urls"":[],""user_mentions"":[{""screen_name"":""cartoDB"",""name"":""CartoDB"",""id"":241079136,""id_str"":""241079136"",""indices"":[49,57]}],""symbols"":[]}","note","object:search.twitter.com,2005:496274441584668672","sad to be the guy that knocked ""locahost"" out of @cartoDB's docs for #Odyssey.js; i've certainly had that sentiment towards my dev machine.","2014-08-04T12:40:21.000Z","http://twitter.com/erictheise/statuses/496274441584668672","place","San Francisco, CA","https://api.twitter.com/1.1/geo/id/5a110d312052166f.json","{""type"":""Polygon"",""coordinates"":[[[-122.514926,37.708075],[-122.514926,37.833238],[-122.328001,37.833238],[-122.328001,37.708075]]]}",,"San Francisco","{""klout_score"":41,""language"":{""value"":""en""}}","{""type"":""Polygon"",""coordinates"":[[[-122.514926,37.708075],[-122.514926,37.833238],[-122.328001,37.833238],[-122.328001,37.708075]]]}","Category 2","aaa, bbb"
"tag:search.twitter.com,2005:495565956908064769","post","http://twitter.com/Jmholleran/statuses/495565956908064769","FrackMap #map http://t.co/BLWl3zYKTn","activity","2014-08-02T13:45:05.000Z","0","medium","en","0","person","id:twitter.com:27128139","http://www.twitter.com/Jmholleran","John Munro Holleran","https://pbs.twimg.com/profile_images/460662090819051520/lzLtBzUb_normal.png","Modest! intelligent, articulate & generally just amazing! Trade Unionist, learning facilitator 'n that","2009-03-27T23:36:39.000Z","[{""href"":null,""rel"":""me""}]","{""objectType"":""place"",""displayName"":""Glasgow/Scotland""}",,"Jmholleran","[""en""]",,"411","192","1","1643","false","Twitter for Android","http://twitter.com/download/android","service","Twitter","http://www.twitter.com",,"{""type"":""Point"",""coordinates"":[55.8641,-4.282299]}","{""hashtags"":[{""text"":""map"",""indices"":[9,13]}],""trends"":[],""urls"":[{""url"":""http://t.co/BLWl3zYKTn"",""expanded_url"":""http://energydesk.cartodb.com/viz/18162194-ecbd-11e3-aba1-0e230854a1cb/embed_map?title=true&description=true&search=false&shareable=true&cartodb_logo=true&layer_selector=false&legends=true&scrollwheel=true&fullscreen=true&sublayer_options=1%7C1%7C1%7C1&sql=&sw_lat=49.97948776108648&sw_lon=-12.7001953125&ne_lat=56.80087831233043&ne_lon=8.96484375"",""display_url"":""energydesk.cartodb.com/viz/18162194-e…"",""indices"":[14,36]}],""user_mentions"":[],""symbols"":[]}","note","object:search.twitter.com,2005:495565956908064769","FrackMap #map http://t.co/BLWl3zYKTn","2014-08-02T13:45:05.000Z","http://twitter.com/Jmholleran/statuses/495565956908064769","place","Glasgow","https://api.twitter.com/1.1/geo/id/791e00bcadc4615f.json","{""type"":""Polygon"",""coordinates"":[[[-4.3932845,55.796184],[-4.3932845,55.9204214],[-4.0902182,55.9204214],[-4.0902182,55.796184]]]}",,"Glasgow","{""urls"":[{""url"":""http://t.co/BLWl3zYKTn"",""expanded_url"":""http://energydesk.cartodb.com/viz/18162194-ecbd-11e3-aba1-0e230854a1cb/embed_map?title=true&description=true&search=false&shareable=true&cartodb_logo=true&layer_selector=false&legends=true&scrollwheel=true&fullscreen=true&sublayer_options=1%7C1%7C1%7C1&sql&sw_lat=49.97948776108648&sw_lon=-12.7001953125&ne_lat=56.80087831233043&ne_lon=8.96484375"",""expanded_status"":200}],""klout_score"":23}","{""type"":""Point"",""coordinates"":[-4.282299,55.8641]}","Category 2","aaa, bbb"
"tag:search.twitter.com,2005:494521462682685440","post","http://twitter.com/SonOfJorEl/statuses/494521462682685440","@iriberri1 @cartoDB you are saying it is not supported, but you have an entire blog post about said feature : http://t.co/gvuUWv9sge","activity","2014-07-30T16:34:39.000Z","0","medium","en","0","person","id:twitter.com:15628310","http://www.twitter.com/SonOfJorEl","Joey Schluchter","https://pbs.twimg.com/profile_images/466697750013562880/ngwKpO1j_normal.jpeg","Recovering golfer. Helping feed the world to software. Still love persimmon.","2008-07-28T05:34:03.000Z","[{""href"":""http://www.exzeo.com"",""rel"":""me""}]","{""objectType"":""place"",""displayName"":""Clearwater, FL""}","-14400","SonOfJorEl","[""en""]","Eastern Time (US & Canada)","368","2053","5","2143","false","Tweetbot for Mac","http://tapbots.com/software/tweetbot/mac","service","Twitter","http://www.twitter.com","http://twitter.com/iriberri1/statuses/494488277765091328",,"{""hashtags"":[],""symbols"":[],""urls"":[{""url"":""http://t.co/gvuUWv9sge"",""expanded_url"":""http://blog.cartodb.com/got-files-weve-got-a-import-api/"",""display_url"":""blog.cartodb.com/got-files-weve…"",""indices"":[110,132]}],""user_mentions"":[{""screen_name"":""iriberri1"",""name"":""Carla"",""id"":102197411,""id_str"":""102197411"",""indices"":[0,10]},{""screen_name"":""cartoDB"",""name"":""CartoDB"",""id"":241079136,""id_str"":""241079136"",""indices"":[11,19]}]}","note","object:search.twitter.com,2005:494521462682685440","@iriberri1 @cartoDB you are saying it is not supported, but you have an entire blog post about said feature : http://t.co/gvuUWv9sge","2014-07-30T16:34:39.000Z","http://twitter.com/SonOfJorEl/statuses/494521462682685440","place","Tampa, FL","https://api.twitter.com/1.1/geo/id/dc62519fda13b4ec.json","{""type"":""Polygon"",""coordinates"":[[[-82.648358,27.821353],[-82.648358,28.171245],[-82.289105,28.171245],[-82.289105,27.821353]]]}",,"Tampa","{""urls"":[{""url"":""http://t.co/gvuUWv9sge"",""expanded_url"":""http://blog.cartodb.com/got-files-weve-got-a-import-api/"",""expanded_status"":200}],""klout_score"":42,""language"":{""value"":""en""}}","{""type"":""Polygon"",""coordinates"":[[[-82.648358,27.821353],[-82.648358,28.171245],[-82.289105,28.171245],[-82.289105,27.821353]]]}","Category 2","aaa, bbb"
"tag:search.twitter.com,2005:494511938563342336","post","http://twitter.com/xavijam/statuses/494511938563342336",". @Javier is giving birth last @cartodb feature. All I can say is … GIF over maps! http://t.co/oC3DEK5cP6","activity","2014-07-30T15:56:48.000Z","0","medium","en","0","person","id:twitter.com:26775253","http://www.twitter.com/xavijam","Javier Álvarez","https://pbs.twimg.com/profile_images/3025038956/a2919d353eb2b1a22756d7ac79847480_normal.jpeg","I love gentoo penguins","2009-03-26T15:36:52.000Z","[{""href"":""http://xavij.am"",""rel"":""me""}]","{""objectType"":""place"",""displayName"":""Antarctic Peninsula""}","7200","xavijam","[""en""]","Madrid","250","395","26","4981","false","Tweetbot for iΟS","http://tapbots.com/tweetbot","service","Twitter","http://www.twitter.com",,,"{""hashtags"":[],""trends"":[],""urls"":[],""user_mentions"":[{""screen_name"":""javier"",""name"":""Javier Arce"",""id"":39083,""id_str"":""39083"",""indices"":[2,9]},{""screen_name"":""cartoDB"",""name"":""CartoDB"",""id"":241079136,""id_str"":""241079136"",""indices"":[31,39]}],""symbols"":[],""media"":[{""id"":494511934629093378,""id_str"":""494511934629093378"",""indices"":[83,105],""media_url"":""http://pbs.twimg.com/media/Btzb_AjCIAI0cLr.jpg"",""media_url_https"":""https://pbs.twimg.com/media/Btzb_AjCIAI0cLr.jpg"",""url"":""http://t.co/oC3DEK5cP6"",""display_url"":""pic.twitter.com/oC3DEK5cP6"",""expanded_url"":""http://twitter.com/xavijam/status/494511938563342336/photo/1"",""type"":""photo"",""sizes"":{""small"":{""w"":340,""h"":255,""resize"":""fit""},""medium"":{""w"":600,""h"":450,""resize"":""fit""},""large"":{""w"":1024,""h"":768,""resize"":""fit""},""thumb"":{""w"":150,""h"":150,""resize"":""crop""}}}]}","note","object:search.twitter.com,2005:494511938563342336",". @Javier is giving birth last @cartodb feature. All I can say is … GIF over maps! http://t.co/oC3DEK5cP6","2014-07-30T15:56:48.000Z","http://twitter.com/xavijam/statuses/494511938563342336","place","Trafalgar","https://api.twitter.com/1.1/geo/id/0144b1172069289c.json","{""type"":""Polygon"",""coordinates"":[[[-3.70582,40.4275708],[-3.70582,40.4386747],[-3.6956326,40.4386747],[-3.6956326,40.4275708]]]}",,"Trafalgar","{""urls"":[{""url"":""http://t.co/oC3DEK5cP6"",""expanded_url"":""http://twitter.com/xavijam/status/494511938563342336/photo/1"",""expanded_status"":200}],""klout_score"":39,""language"":{""value"":""en""}}","{""type"":""Polygon"",""coordinates"":[[[-3.70582,40.4275708],[-3.70582,40.4386747],[-3.6956326,40.4386747],[-3.6956326,40.4275708]]]}","Category 2","aaa, bbb"
"tag:search.twitter.com,2005:494485754848894978","post","http://twitter.com/SonOfJorEl/statuses/494485754848894978","@cartoDB I’ve emailed support (which you claim has a 24hr turnaround) with minimal response. I am a paying customer ($149 mo). For how long?","activity","2014-07-30T14:12:45.000Z","0","medium","en","0","person","id:twitter.com:15628310","http://www.twitter.com/SonOfJorEl","Joey Schluchter","https://pbs.twimg.com/profile_images/466697750013562880/ngwKpO1j_normal.jpeg","Recovering golfer. Helping feed the world to software. Still love persimmon.","2008-07-28T05:34:03.000Z","[{""href"":""http://www.exzeo.com"",""rel"":""me""}]","{""objectType"":""place"",""displayName"":""Clearwater, FL""}","-14400","SonOfJorEl","[""en""]","Eastern Time (US & Canada)","368","2053","5","2142","false","Tweetbot for Mac","http://tapbots.com/software/tweetbot/mac","service","Twitter","http://www.twitter.com",,,"{""hashtags"":[],""trends"":[],""urls"":[],""user_mentions"":[{""screen_name"":""cartoDB"",""name"":""CartoDB"",""id"":241079136,""id_str"":""241079136"",""indices"":[0,8]}],""symbols"":[]}","note","object:search.twitter.com,2005:494485754848894978","@cartoDB I’ve emailed support (which you claim has a 24hr turnaround) with minimal response. I am a paying customer ($149 mo). For how long?","2014-07-30T14:12:45.000Z","http://twitter.com/SonOfJorEl/statuses/494485754848894978","place","Tampa, FL","https://api.twitter.com/1.1/geo/id/dc62519fda13b4ec.json","{""type"":""Polygon"",""coordinates"":[[[-82.648358,27.821353],[-82.648358,28.171245],[-82.289105,28.171245],[-82.289105,27.821353]]]}",,"Tampa","{""klout_score"":42,""language"":{""value"":""en""}}","{""type"":""Polygon"",""coordinates"":[[[-82.648358,27.821353],[-82.648358,28.171245],[-82.289105,28.171245],[-82.289105,27.821353]]]}","Category 2","aaa, bbb"
"tag:search.twitter.com,2005:494276921765543936","post","http://twitter.com/httsan/statuses/494276921765543936","""@gisuser: Online Mapping for Beginners – The Map Academy from CartoDB: TweetSomething innovative from… http://t.co/Zhh882TV2F""","activity","2014-07-30T00:22:56.000Z","0","medium","en","0","person","id:twitter.com:28471178","http://www.twitter.com/httsan","hartanto","https://pbs.twimg.com/profile_images/2217327231/IMG0033_normal.jpg","Lahir di Indonesia | Nguli di @NEOnetBPPT @BPPTeknologi | Ngobrol di @RSGISForum @PadangZaitun | Nyantri di @MichiganStateU","2009-04-03T01:32:35.000Z","[{""href"":""http://about.me/httsan"",""rel"":""me""}]","{""objectType"":""place"",""displayName"":""East Lansing""}","-14400","httsan","[""en""]","Eastern Time (US & Canada)","976","873","12","25725","false","Twitter for Android","http://twitter.com/download/android","service","Twitter","http://www.twitter.com",,"{""type"":""Point"",""coordinates"":[42.7539688,-84.4221123]}","{""hashtags"":[],""symbols"":[],""urls"":[{""url"":""http://t.co/Zhh882TV2F"",""expanded_url"":""http://goo.gl/fb/xVB5rr"",""display_url"":""goo.gl/fb/xVB5rr"",""indices"":[104,126]}],""user_mentions"":[{""screen_name"":""gisuser"",""name"":""GISuser GeoTech News"",""id"":16958615,""id_str"":""16958615"",""indices"":[1,9]}]}","note","object:search.twitter.com,2005:494276921765543936","""@gisuser: Online Mapping for Beginners – The Map Academy from CartoDB: TweetSomething innovative from… http://t.co/Zhh882TV2F""","2014-07-30T00:22:56.000Z","http://twitter.com/httsan/statuses/494276921765543936","place","Haslett, MI","https://api.twitter.com/1.1/geo/id/4399b5004a3b4d9a.json","{""type"":""Polygon"",""coordinates"":[[[-84.447506,42.731229],[-84.447506,42.769688],[-84.363432,42.769688],[-84.363432,42.731229]]]}",,"Haslett","{""urls"":[{""url"":""http://t.co/Zhh882TV2F"",""expanded_url"":""http://blog.gisuser.com/2014/07/29/online-mapping-for-beginners-the-map-academy-from-cartodb/?utm_source=feedburner&utm_medium=twitter&utm_campaign=Feed:%20gisuser%20(GISUser.com%20-%20GIS,%20Mapping,%20Geospatial,%20and%20location%20technology%20news)"",""expanded_status"":200}],""klout_score"":51,""language"":{""value"":""en""}}","{""type"":""Point"",""coordinates"":[-84.4221123,42.7539688]}","Category 2","aaa, bbb"
"tag:search.twitter.com,2005:494274906528288769","post","http://twitter.com/carygeo/statuses/494274906528288769","Attention mappers: iframe, #javascript, app development examples from the #CartoDB API/team! Well done @andrewxhill. http://t.co/cJM5fzgoyL","activity","2014-07-30T00:14:55.000Z","0","medium","en","0","person","id:twitter.com:2641688154","http://www.twitter.com/carygeo","Cary Greenwood","https://pbs.twimg.com/profile_images/488501325697544192/gFMxHuLV_normal.jpeg","geography, maps, data visualization, ui/ux design","2014-07-14T01:35:22.000Z","[{""href"":null,""rel"":""me""}]",,,"carygeo","[""en""]",,"109","46","0","47","false","Twitter for iPhone","http://twitter.com/download/iphone","service","Twitter","http://www.twitter.com",,"{""type"":""Point"",""coordinates"":[34.13846767,-118.3620715]}","{""hashtags"":[{""text"":""javascript"",""indices"":[27,38]},{""text"":""CartoDB"",""indices"":[74,82]}],""symbols"":[],""urls"":[{""url"":""http://t.co/cJM5fzgoyL"",""expanded_url"":""http://vimeo.com/m/101936210"",""display_url"":""vimeo.com/m/101936210"",""indices"":[117,139]}],""user_mentions"":[{""screen_name"":""andrewxhill"",""name"":""Andrew W Hill"",""id"":19893224,""id_str"":""19893224"",""indices"":[103,115]}]}","note","object:search.twitter.com,2005:494274906528288769","Attention mappers: iframe, #javascript, app development examples from the #CartoDB API/team! Well done @andrewxhill. http://t.co/cJM5fzgoyL","2014-07-30T00:14:55.000Z","http://twitter.com/carygeo/statuses/494274906528288769","place","Los Angeles, CA","https://api.twitter.com/1.1/geo/id/3b77caf94bfc81fe.json","{""type"":""Polygon"",""coordinates"":[[[-118.668404,33.704538],[-118.668404,34.330724],[-118.155409,34.330724],[-118.155409,33.704538]]]}",,"Los Angeles","{""urls"":[{""url"":""http://t.co/cJM5fzgoyL"",""expanded_url"":""http://vimeo.com/101936210"",""expanded_status"":200}],""klout_score"":31,""language"":{""value"":""en""}}","{""type"":""Point"",""coordinates"":[-118.3620715,34.13846767]}","Category 2","aaa, bbb"
"tag:search.twitter.com,2005:494194150133481472","post","http://twitter.com/juanjeojeda/statuses/494194150133481472","@dig_geo_com Your welcome :-) Keep in mind that some parts of @cartoDB still doesn't work fine. We're waiting for update from our provider.","activity","2014-07-29T18:54:01.000Z","0","medium","en","0","person","id:twitter.com:12679112","http://www.twitter.com/juanjeojeda","Juanje Ojeda ","https://pbs.twimg.com/profile_images/46025702/juanje_normal.jpg","Canarian guy who likes the open knowledge, the freedom and learn things every day. #OpenSource #DevOps #PaaS #Canarias","2008-01-25T11:49:15.000Z","[{""href"":""http://about.me/juanje"",""rel"":""me""}]","{""objectType"":""place"",""displayName"":""Las Palmas de Gran Canaria""}","3600","juanjeojeda","[""es""]","London","416","1006","86","17700","false","Twitter Web Client","http://twitter.com","service","Twitter","http://www.twitter.com","http://twitter.com/dig_geo_com/statuses/494191759078211584",,"{""hashtags"":[],""trends"":[],""urls"":[],""user_mentions"":[{""screen_name"":""dig_geo_com"",""name"":""digital-geography"",""id"":231016902,""id_str"":""231016902"",""indices"":[0,12]},{""screen_name"":""cartoDB"",""name"":""CartoDB"",""id"":241079136,""id_str"":""241079136"",""indices"":[62,70]}],""symbols"":[]}","note","object:search.twitter.com,2005:494194150133481472","@dig_geo_com Your welcome :-) Keep in mind that some parts of @cartoDB still doesn't work fine. We're waiting for update from our provider.","2014-07-29T18:54:01.000Z","http://twitter.com/juanjeojeda/statuses/494194150133481472","place","Las Palmas de Gran Canaria, Las Palmas","https://api.twitter.com/1.1/geo/id/00ab08eef1b62e92.json","{""type"":""Polygon"",""coordinates"":[[[-15.5255036,28.0248125],[-15.5255036,28.1812125],[-15.3941943,28.1812125],[-15.3941943,28.0248125]]]}",,"Las Palmas de Gran Canaria","{""klout_score"":56,""language"":{""value"":""en""}}","{""type"":""Polygon"",""coordinates"":[[[-15.5255036,28.0248125],[-15.5255036,28.1812125],[-15.3941943,28.1812125],[-15.3941943,28.0248125]]]}","Category 2","aaa, bbb"
"tag:search.twitter.com,2005:494191583240388608","post","http://twitter.com/juanjeojeda/statuses/494191583240388608","@dig_geo_com Hi, @cartoDB blog is already back. We had some issues with our hosting provider. Sorry for the inconveniences.","activity","2014-07-29T18:43:49.000Z","0","medium","en","0","person","id:twitter.com:12679112","http://www.twitter.com/juanjeojeda","Juanje Ojeda ","https://pbs.twimg.com/profile_images/46025702/juanje_normal.jpg","Canarian guy who likes the open knowledge, the freedom and learn things every day. #OpenSource #DevOps #PaaS #Canarias","2008-01-25T11:49:15.000Z","[{""href"":""http://about.me/juanje"",""rel"":""me""}]","{""objectType"":""place"",""displayName"":""Las Palmas de Gran Canaria""}","3600","juanjeojeda","[""es""]","London","416","1006","86","17698","false","Twitter Web Client","http://twitter.com","service","Twitter","http://www.twitter.com","http://twitter.com/dig_geo_com/statuses/494181592685117442",,"{""hashtags"":[],""trends"":[],""urls"":[],""user_mentions"":[{""screen_name"":""dig_geo_com"",""name"":""digital-geography"",""id"":231016902,""id_str"":""231016902"",""indices"":[0,12]},{""screen_name"":""cartoDB"",""name"":""CartoDB"",""id"":241079136,""id_str"":""241079136"",""indices"":[17,25]}],""symbols"":[]}","note","object:search.twitter.com,2005:494191583240388608","@dig_geo_com Hi, @cartoDB blog is already back. We had some issues with our hosting provider. Sorry for the inconveniences.","2014-07-29T18:43:49.000Z","http://twitter.com/juanjeojeda/statuses/494191583240388608","place","Las Palmas de Gran Canaria, Las Palmas","https://api.twitter.com/1.1/geo/id/00ab08eef1b62e92.json","{""type"":""Polygon"",""coordinates"":[[[-15.5255036,28.0248125],[-15.5255036,28.1812125],[-15.3941943,28.1812125],[-15.3941943,28.0248125]]]}",,"Las Palmas de Gran Canaria","{""klout_score"":56,""language"":{""value"":""en""}}","{""type"":""Polygon"",""coordinates"":[[[-15.5255036,28.0248125],[-15.5255036,28.1812125],[-15.3941943,28.1812125],[-15.3941943,28.0248125]]]}","Category 2","aaa, bbb"
"tag:search.twitter.com,2005:494141648922611712","post","http://twitter.com/spara/statuses/494141648922611712","@vtcraghead @briantimoney @billdollins CartoDB needs to cut a deal with a cloud provider, they’re a bit too parsimonious on disk space","activity","2014-07-29T15:25:24.000Z","0","medium","en","0","person","id:twitter.com:14629939","http://www.twitter.com/spara","spara","https://pbs.twimg.com/profile_images/469468370413166592/uuOHhLby_normal.jpeg","Ut mitterent eos in faciem.","2008-05-02T19:24:50.000Z","[{""href"":""http://sproke.blogspot.com"",""rel"":""me""}]","{""objectType"":""place"",""displayName"":""San Antonio Francisco""}","-25200","spara","[""en""]","Pacific Time (US & Canada)","1311","1276","128","24585","false","Tweetbot for Mac","http://tapbots.com/software/tweetbot/mac","service","Twitter","http://www.twitter.com","http://twitter.com/vtcraghead/statuses/494140334226833408",,"{""hashtags"":[],""symbols"":[],""urls"":[],""user_mentions"":[{""screen_name"":""vtcraghead"",""name"":""Bill Morris"",""id"":278873782,""id_str"":""278873782"",""indices"":[0,11]},{""screen_name"":""briantimoney"",""name"":""Brian Timoney"",""id"":17546328,""id_str"":""17546328"",""indices"":[12,25]},{""screen_name"":""billdollins"",""name"":""Bill Dollins"",""id"":12405802,""id_str"":""12405802"",""indices"":[26,38]}]}","note","object:search.twitter.com,2005:494141648922611712","@vtcraghead @briantimoney @billdollins CartoDB needs to cut a deal with a cloud provider, they’re a bit too parsimonious on disk space","2014-07-29T15:25:24.000Z","http://twitter.com/spara/statuses/494141648922611712","place","Highland Park, San Antonio","https://api.twitter.com/1.1/geo/id/097c1754d9aa7b39.json","{""type"":""Polygon"",""coordinates"":[[[-98.478227,29.384112],[-98.478227,29.401578],[-98.448015,29.401578],[-98.448015,29.384112]]]}",,"Highland Park","{""language"":{""value"":""en""}}","{""type"":""Polygon"",""coordinates"":[[[-98.478227,29.384112],[-98.478227,29.401578],[-98.448015,29.401578],[-98.448015,29.384112]]]}","Category 2","aaa, bbb"
"tag:search.twitter.com,2005:496274441584668672","post","http://twitter.com/erictheise/statuses/496274441584668672","sad to be the guy that knocked ""locahost"" out of @cartoDB's docs for #Odyssey.js; i've certainly had that sentiment towards my dev machine.","activity","2014-08-04T12:40:21.000Z","0","medium","en","0","person","id:twitter.com:94817737","http://www.twitter.com/erictheise","Eric Theise","https://pbs.twimg.com/profile_images/1994194116/Theise_normal.jpg","Software engineer; photographer, experimental filmmaker, cartographer, geographer, vocalizer, yoga student, reader & writer, eater & drinker.","2009-12-05T16:06:12.000Z","[{""href"":null,""rel"":""me""}]","{""objectType"":""place"",""displayName"":""San Francisco""}","-25200","erictheise","[""en""]","Pacific Time (US & Canada)","698","306","25","1322","false","Twitter Web Client","http://twitter.com","service","Twitter","http://www.twitter.com",,,"{""hashtags"":[{""text"":""Odyssey"",""indices"":[69,77]}],""trends"":[],""urls"":[],""user_mentions"":[{""screen_name"":""cartoDB"",""name"":""CartoDB"",""id"":241079136,""id_str"":""241079136"",""indices"":[49,57]}],""symbols"":[]}","note","object:search.twitter.com,2005:496274441584668672","sad to be the guy that knocked ""locahost"" out of @cartoDB's docs for #Odyssey.js; i've certainly had that sentiment towards my dev machine.","2014-08-04T12:40:21.000Z","http://twitter.com/erictheise/statuses/496274441584668672","place","San Francisco, CA","https://api.twitter.com/1.1/geo/id/5a110d312052166f.json","{""type"":""Polygon"",""coordinates"":[[[-122.514926,37.708075],[-122.514926,37.833238],[-122.328001,37.833238],[-122.328001,37.708075]]]}",,"San Francisco","{""klout_score"":41,""language"":{""value"":""en""}}","{""type"":""Polygon"",""coordinates"":[[[-122.514926,37.708075],[-122.514926,37.833238],[-122.328001,37.833238],[-122.328001,37.708075]]]}","Category 2","aaa, bbb"
"tag:search.twitter.com,2005:495565956908064769","post","http://twitter.com/Jmholleran/statuses/495565956908064769","FrackMap #map http://t.co/BLWl3zYKTn","activity","2014-08-02T13:45:05.000Z","0","medium","en","0","person","id:twitter.com:27128139","http://www.twitter.com/Jmholleran","John Munro Holleran","https://pbs.twimg.com/profile_images/460662090819051520/lzLtBzUb_normal.png","Modest! intelligent, articulate & generally just amazing! Trade Unionist, learning facilitator 'n that","2009-03-27T23:36:39.000Z","[{""href"":null,""rel"":""me""}]","{""objectType"":""place"",""displayName"":""Glasgow/Scotland""}",,"Jmholleran","[""en""]",,"411","192","1","1643","false","Twitter for Android","http://twitter.com/download/android","service","Twitter","http://www.twitter.com",,"{""type"":""Point"",""coordinates"":[55.8641,-4.282299]}","{""hashtags"":[{""text"":""map"",""indices"":[9,13]}],""trends"":[],""urls"":[{""url"":""http://t.co/BLWl3zYKTn"",""expanded_url"":""http://energydesk.cartodb.com/viz/18162194-ecbd-11e3-aba1-0e230854a1cb/embed_map?title=true&description=true&search=false&shareable=true&cartodb_logo=true&layer_selector=false&legends=true&scrollwheel=true&fullscreen=true&sublayer_options=1%7C1%7C1%7C1&sql=&sw_lat=49.97948776108648&sw_lon=-12.7001953125&ne_lat=56.80087831233043&ne_lon=8.96484375"",""display_url"":""energydesk.cartodb.com/viz/18162194-e…"",""indices"":[14,36]}],""user_mentions"":[],""symbols"":[]}","note","object:search.twitter.com,2005:495565956908064769","FrackMap #map http://t.co/BLWl3zYKTn","2014-08-02T13:45:05.000Z","http://twitter.com/Jmholleran/statuses/495565956908064769","place","Glasgow","https://api.twitter.com/1.1/geo/id/791e00bcadc4615f.json","{""type"":""Polygon"",""coordinates"":[[[-4.3932845,55.796184],[-4.3932845,55.9204214],[-4.0902182,55.9204214],[-4.0902182,55.796184]]]}",,"Glasgow","{""urls"":[{""url"":""http://t.co/BLWl3zYKTn"",""expanded_url"":""http://energydesk.cartodb.com/viz/18162194-ecbd-11e3-aba1-0e230854a1cb/embed_map?title=true&description=true&search=false&shareable=true&cartodb_logo=true&layer_selector=false&legends=true&scrollwheel=true&fullscreen=true&sublayer_options=1%7C1%7C1%7C1&sql&sw_lat=49.97948776108648&sw_lon=-12.7001953125&ne_lat=56.80087831233043&ne_lon=8.96484375"",""expanded_status"":200}],""klout_score"":23}","{""type"":""Point"",""coordinates"":[-4.282299,55.8641]}","Category 2","aaa, bbb"
"tag:search.twitter.com,2005:494521462682685440","post","http://twitter.com/SonOfJorEl/statuses/494521462682685440","@iriberri1 @cartoDB you are saying it is not supported, but you have an entire blog post about said feature : http://t.co/gvuUWv9sge","activity","2014-07-30T16:34:39.000Z","0","medium","en","0","person","id:twitter.com:15628310","http://www.twitter.com/SonOfJorEl","Joey Schluchter","https://pbs.twimg.com/profile_images/466697750013562880/ngwKpO1j_normal.jpeg","Recovering golfer. Helping feed the world to software. Still love persimmon.","2008-07-28T05:34:03.000Z","[{""href"":""http://www.exzeo.com"",""rel"":""me""}]","{""objectType"":""place"",""displayName"":""Clearwater, FL""}","-14400","SonOfJorEl","[""en""]","Eastern Time (US & Canada)","368","2053","5","2143","false","Tweetbot for Mac","http://tapbots.com/software/tweetbot/mac","service","Twitter","http://www.twitter.com","http://twitter.com/iriberri1/statuses/494488277765091328",,"{""hashtags"":[],""symbols"":[],""urls"":[{""url"":""http://t.co/gvuUWv9sge"",""expanded_url"":""http://blog.cartodb.com/got-files-weve-got-a-import-api/"",""display_url"":""blog.cartodb.com/got-files-weve…"",""indices"":[110,132]}],""user_mentions"":[{""screen_name"":""iriberri1"",""name"":""Carla"",""id"":102197411,""id_str"":""102197411"",""indices"":[0,10]},{""screen_name"":""cartoDB"",""name"":""CartoDB"",""id"":241079136,""id_str"":""241079136"",""indices"":[11,19]}]}","note","object:search.twitter.com,2005:494521462682685440","@iriberri1 @cartoDB you are saying it is not supported, but you have an entire blog post about said feature : http://t.co/gvuUWv9sge","2014-07-30T16:34:39.000Z","http://twitter.com/SonOfJorEl/statuses/494521462682685440","place","Tampa, FL","https://api.twitter.com/1.1/geo/id/dc62519fda13b4ec.json","{""type"":""Polygon"",""coordinates"":[[[-82.648358,27.821353],[-82.648358,28.171245],[-82.289105,28.171245],[-82.289105,27.821353]]]}",,"Tampa","{""urls"":[{""url"":""http://t.co/gvuUWv9sge"",""expanded_url"":""http://blog.cartodb.com/got-files-weve-got-a-import-api/"",""expanded_status"":200}],""klout_score"":42,""language"":{""value"":""en""}}","{""type"":""Polygon"",""coordinates"":[[[-82.648358,27.821353],[-82.648358,28.171245],[-82.289105,28.171245],[-82.289105,27.821353]]]}","Category 2","aaa, bbb"
"tag:search.twitter.com,2005:494511938563342336","post","http://twitter.com/xavijam/statuses/494511938563342336",". @Javier is giving birth last @cartodb feature. All I can say is … GIF over maps! http://t.co/oC3DEK5cP6","activity","2014-07-30T15:56:48.000Z","0","medium","en","0","person","id:twitter.com:26775253","http://www.twitter.com/xavijam","Javier Álvarez","https://pbs.twimg.com/profile_images/3025038956/a2919d353eb2b1a22756d7ac79847480_normal.jpeg","I love gentoo penguins","2009-03-26T15:36:52.000Z","[{""href"":""http://xavij.am"",""rel"":""me""}]","{""objectType"":""place"",""displayName"":""Antarctic Peninsula""}","7200","xavijam","[""en""]","Madrid","250","395","26","4981","false","Tweetbot for iΟS","http://tapbots.com/tweetbot","service","Twitter","http://www.twitter.com",,,"{""hashtags"":[],""trends"":[],""urls"":[],""user_mentions"":[{""screen_name"":""javier"",""name"":""Javier Arce"",""id"":39083,""id_str"":""39083"",""indices"":[2,9]},{""screen_name"":""cartoDB"",""name"":""CartoDB"",""id"":241079136,""id_str"":""241079136"",""indices"":[31,39]}],""symbols"":[],""media"":[{""id"":494511934629093378,""id_str"":""494511934629093378"",""indices"":[83,105],""media_url"":""http://pbs.twimg.com/media/Btzb_AjCIAI0cLr.jpg"",""media_url_https"":""https://pbs.twimg.com/media/Btzb_AjCIAI0cLr.jpg"",""url"":""http://t.co/oC3DEK5cP6"",""display_url"":""pic.twitter.com/oC3DEK5cP6"",""expanded_url"":""http://twitter.com/xavijam/status/494511938563342336/photo/1"",""type"":""photo"",""sizes"":{""small"":{""w"":340,""h"":255,""resize"":""fit""},""medium"":{""w"":600,""h"":450,""resize"":""fit""},""large"":{""w"":1024,""h"":768,""resize"":""fit""},""thumb"":{""w"":150,""h"":150,""resize"":""crop""}}}]}","note","object:search.twitter.com,2005:494511938563342336",". @Javier is giving birth last @cartodb feature. All I can say is … GIF over maps! http://t.co/oC3DEK5cP6","2014-07-30T15:56:48.000Z","http://twitter.com/xavijam/statuses/494511938563342336","place","Trafalgar","https://api.twitter.com/1.1/geo/id/0144b1172069289c.json","{""type"":""Polygon"",""coordinates"":[[[-3.70582,40.4275708],[-3.70582,40.4386747],[-3.6956326,40.4386747],[-3.6956326,40.4275708]]]}",,"Trafalgar","{""urls"":[{""url"":""http://t.co/oC3DEK5cP6"",""expanded_url"":""http://twitter.com/xavijam/status/494511938563342336/photo/1"",""expanded_status"":200}],""klout_score"":39,""language"":{""value"":""en""}}","{""type"":""Polygon"",""coordinates"":[[[-3.70582,40.4275708],[-3.70582,40.4386747],[-3.6956326,40.4386747],[-3.6956326,40.4275708]]]}","Category 2","aaa, bbb"
"tag:search.twitter.com,2005:494485754848894978","post","http://twitter.com/SonOfJorEl/statuses/494485754848894978","@cartoDB I’ve emailed support (which you claim has a 24hr turnaround) with minimal response. I am a paying customer ($149 mo). For how long?","activity","2014-07-30T14:12:45.000Z","0","medium","en","0","person","id:twitter.com:15628310","http://www.twitter.com/SonOfJorEl","Joey Schluchter","https://pbs.twimg.com/profile_images/466697750013562880/ngwKpO1j_normal.jpeg","Recovering golfer. Helping feed the world to software. Still love persimmon.","2008-07-28T05:34:03.000Z","[{""href"":""http://www.exzeo.com"",""rel"":""me""}]","{""objectType"":""place"",""displayName"":""Clearwater, FL""}","-14400","SonOfJorEl","[""en""]","Eastern Time (US & Canada)","368","2053","5","2142","false","Tweetbot for Mac","http://tapbots.com/software/tweetbot/mac","service","Twitter","http://www.twitter.com",,,"{""hashtags"":[],""trends"":[],""urls"":[],""user_mentions"":[{""screen_name"":""cartoDB"",""name"":""CartoDB"",""id"":241079136,""id_str"":""241079136"",""indices"":[0,8]}],""symbols"":[]}","note","object:search.twitter.com,2005:494485754848894978","@cartoDB I’ve emailed support (which you claim has a 24hr turnaround) with minimal response. I am a paying customer ($149 mo). For how long?","2014-07-30T14:12:45.000Z","http://twitter.com/SonOfJorEl/statuses/494485754848894978","place","Tampa, FL","https://api.twitter.com/1.1/geo/id/dc62519fda13b4ec.json","{""type"":""Polygon"",""coordinates"":[[[-82.648358,27.821353],[-82.648358,28.171245],[-82.289105,28.171245],[-82.289105,27.821353]]]}",,"Tampa","{""klout_score"":42,""language"":{""value"":""en""}}","{""type"":""Polygon"",""coordinates"":[[[-82.648358,27.821353],[-82.648358,28.171245],[-82.289105,28.171245],[-82.289105,27.821353]]]}","Category 2","aaa, bbb"
"tag:search.twitter.com,2005:494276921765543936","post","http://twitter.com/httsan/statuses/494276921765543936","""@gisuser: Online Mapping for Beginners – The Map Academy from CartoDB: TweetSomething innovative from… http://t.co/Zhh882TV2F""","activity","2014-07-30T00:22:56.000Z","0","medium","en","0","person","id:twitter.com:28471178","http://www.twitter.com/httsan","hartanto","https://pbs.twimg.com/profile_images/2217327231/IMG0033_normal.jpg","Lahir di Indonesia | Nguli di @NEOnetBPPT @BPPTeknologi | Ngobrol di @RSGISForum @PadangZaitun | Nyantri di @MichiganStateU","2009-04-03T01:32:35.000Z","[{""href"":""http://about.me/httsan"",""rel"":""me""}]","{""objectType"":""place"",""displayName"":""East Lansing""}","-14400","httsan","[""en""]","Eastern Time (US & Canada)","976","873","12","25725","false","Twitter for Android","http://twitter.com/download/android","service","Twitter","http://www.twitter.com",,"{""type"":""Point"",""coordinates"":[42.7539688,-84.4221123]}","{""hashtags"":[],""symbols"":[],""urls"":[{""url"":""http://t.co/Zhh882TV2F"",""expanded_url"":""http://goo.gl/fb/xVB5rr"",""display_url"":""goo.gl/fb/xVB5rr"",""indices"":[104,126]}],""user_mentions"":[{""screen_name"":""gisuser"",""name"":""GISuser GeoTech News"",""id"":16958615,""id_str"":""16958615"",""indices"":[1,9]}]}","note","object:search.twitter.com,2005:494276921765543936","""@gisuser: Online Mapping for Beginners – The Map Academy from CartoDB: TweetSomething innovative from… http://t.co/Zhh882TV2F""","2014-07-30T00:22:56.000Z","http://twitter.com/httsan/statuses/494276921765543936","place","Haslett, MI","https://api.twitter.com/1.1/geo/id/4399b5004a3b4d9a.json","{""type"":""Polygon"",""coordinates"":[[[-84.447506,42.731229],[-84.447506,42.769688],[-84.363432,42.769688],[-84.363432,42.731229]]]}",,"Haslett","{""urls"":[{""url"":""http://t.co/Zhh882TV2F"",""expanded_url"":""http://blog.gisuser.com/2014/07/29/online-mapping-for-beginners-the-map-academy-from-cartodb/?utm_source=feedburner&utm_medium=twitter&utm_campaign=Feed:%20gisuser%20(GISUser.com%20-%20GIS,%20Mapping,%20Geospatial,%20and%20location%20technology%20news)"",""expanded_status"":200}],""klout_score"":51,""language"":{""value"":""en""}}","{""type"":""Point"",""coordinates"":[-84.4221123,42.7539688]}","Category 2","aaa, bbb"
"tag:search.twitter.com,2005:494274906528288769","post","http://twitter.com/carygeo/statuses/494274906528288769","Attention mappers: iframe, #javascript, app development examples from the #CartoDB API/team! Well done @andrewxhill. http://t.co/cJM5fzgoyL","activity","2014-07-30T00:14:55.000Z","0","medium","en","0","person","id:twitter.com:2641688154","http://www.twitter.com/carygeo","Cary Greenwood","https://pbs.twimg.com/profile_images/488501325697544192/gFMxHuLV_normal.jpeg","geography, maps, data visualization, ui/ux design","2014-07-14T01:35:22.000Z","[{""href"":null,""rel"":""me""}]",,,"carygeo","[""en""]",,"109","46","0","47","false","Twitter for iPhone","http://twitter.com/download/iphone","service","Twitter","http://www.twitter.com",,"{""type"":""Point"",""coordinates"":[34.13846767,-118.3620715]}","{""hashtags"":[{""text"":""javascript"",""indices"":[27,38]},{""text"":""CartoDB"",""indices"":[74,82]}],""symbols"":[],""urls"":[{""url"":""http://t.co/cJM5fzgoyL"",""expanded_url"":""http://vimeo.com/m/101936210"",""display_url"":""vimeo.com/m/101936210"",""indices"":[117,139]}],""user_mentions"":[{""screen_name"":""andrewxhill"",""name"":""Andrew W Hill"",""id"":19893224,""id_str"":""19893224"",""indices"":[103,115]}]}","note","object:search.twitter.com,2005:494274906528288769","Attention mappers: iframe, #javascript, app development examples from the #CartoDB API/team! Well done @andrewxhill. http://t.co/cJM5fzgoyL","2014-07-30T00:14:55.000Z","http://twitter.com/carygeo/statuses/494274906528288769","place","Los Angeles, CA","https://api.twitter.com/1.1/geo/id/3b77caf94bfc81fe.json","{""type"":""Polygon"",""coordinates"":[[[-118.668404,33.704538],[-118.668404,34.330724],[-118.155409,34.330724],[-118.155409,33.704538]]]}",,"Los Angeles","{""urls"":[{""url"":""http://t.co/cJM5fzgoyL"",""expanded_url"":""http://vimeo.com/101936210"",""expanded_status"":200}],""klout_score"":31,""language"":{""value"":""en""}}","{""type"":""Point"",""coordinates"":[-118.3620715,34.13846767]}","Category 2","aaa, bbb"
"tag:search.twitter.com,2005:494194150133481472","post","http://twitter.com/juanjeojeda/statuses/494194150133481472","@dig_geo_com Your welcome :-) Keep in mind that some parts of @cartoDB still doesn't work fine. We're waiting for update from our provider.","activity","2014-07-29T18:54:01.000Z","0","medium","en","0","person","id:twitter.com:12679112","http://www.twitter.com/juanjeojeda","Juanje Ojeda ","https://pbs.twimg.com/profile_images/46025702/juanje_normal.jpg","Canarian guy who likes the open knowledge, the freedom and learn things every day. #OpenSource #DevOps #PaaS #Canarias","2008-01-25T11:49:15.000Z","[{""href"":""http://about.me/juanje"",""rel"":""me""}]","{""objectType"":""place"",""displayName"":""Las Palmas de Gran Canaria""}","3600","juanjeojeda","[""es""]","London","416","1006","86","17700","false","Twitter Web Client","http://twitter.com","service","Twitter","http://www.twitter.com","http://twitter.com/dig_geo_com/statuses/494191759078211584",,"{""hashtags"":[],""trends"":[],""urls"":[],""user_mentions"":[{""screen_name"":""dig_geo_com"",""name"":""digital-geography"",""id"":231016902,""id_str"":""231016902"",""indices"":[0,12]},{""screen_name"":""cartoDB"",""name"":""CartoDB"",""id"":241079136,""id_str"":""241079136"",""indices"":[62,70]}],""symbols"":[]}","note","object:search.twitter.com,2005:494194150133481472","@dig_geo_com Your welcome :-) Keep in mind that some parts of @cartoDB still doesn't work fine. We're waiting for update from our provider.","2014-07-29T18:54:01.000Z","http://twitter.com/juanjeojeda/statuses/494194150133481472","place","Las Palmas de Gran Canaria, Las Palmas","https://api.twitter.com/1.1/geo/id/00ab08eef1b62e92.json","{""type"":""Polygon"",""coordinates"":[[[-15.5255036,28.0248125],[-15.5255036,28.1812125],[-15.3941943,28.1812125],[-15.3941943,28.0248125]]]}",,"Las Palmas de Gran Canaria","{""klout_score"":56,""language"":{""value"":""en""}}","{""type"":""Polygon"",""coordinates"":[[[-15.5255036,28.0248125],[-15.5255036,28.1812125],[-15.3941943,28.1812125],[-15.3941943,28.0248125]]]}","Category 2","aaa, bbb"
"tag:search.twitter.com,2005:494191583240388608","post","http://twitter.com/juanjeojeda/statuses/494191583240388608","@dig_geo_com Hi, @cartoDB blog is already back. We had some issues with our hosting provider. Sorry for the inconveniences.","activity","2014-07-29T18:43:49.000Z","0","medium","en","0","person","id:twitter.com:12679112","http://www.twitter.com/juanjeojeda","Juanje Ojeda ","https://pbs.twimg.com/profile_images/46025702/juanje_normal.jpg","Canarian guy who likes the open knowledge, the freedom and learn things every day. #OpenSource #DevOps #PaaS #Canarias","2008-01-25T11:49:15.000Z","[{""href"":""http://about.me/juanje"",""rel"":""me""}]","{""objectType"":""place"",""displayName"":""Las Palmas de Gran Canaria""}","3600","juanjeojeda","[""es""]","London","416","1006","86","17698","false","Twitter Web Client","http://twitter.com","service","Twitter","http://www.twitter.com","http://twitter.com/dig_geo_com/statuses/494181592685117442",,"{""hashtags"":[],""trends"":[],""urls"":[],""user_mentions"":[{""screen_name"":""dig_geo_com"",""name"":""digital-geography"",""id"":231016902,""id_str"":""231016902"",""indices"":[0,12]},{""screen_name"":""cartoDB"",""name"":""CartoDB"",""id"":241079136,""id_str"":""241079136"",""indices"":[17,25]}],""symbols"":[]}","note","object:search.twitter.com,2005:494191583240388608","@dig_geo_com Hi, @cartoDB blog is already back. We had some issues with our hosting provider. Sorry for the inconveniences.","2014-07-29T18:43:49.000Z","http://twitter.com/juanjeojeda/statuses/494191583240388608","place","Las Palmas de Gran Canaria, Las Palmas","https://api.twitter.com/1.1/geo/id/00ab08eef1b62e92.json","{""type"":""Polygon"",""coordinates"":[[[-15.5255036,28.0248125],[-15.5255036,28.1812125],[-15.3941943,28.1812125],[-15.3941943,28.0248125]]]}
Can't render this file because it contains an unexpected character in line 40 and column 1807.
@@ -0,0 +1,51 @@
require_relative '../../lib/datasources'
require_relative '../doubles/json_to_csv_converter'
include CartoDB::Datasources
describe CSVFileDumper do
before(:each) do
end
describe '#dumping data' do
it 'tests streaming dump' do
output_stream_name ='/tmp/csv_file_dumper_test.csv'
File.unlink(output_stream_name) if File.exists?(output_stream_name)
converter_mock = CartoDB::TwitterSearch::Doubles::JSONToCSVConverter.new
dumper = CSVFileDumper.new(converter_mock, false)
dumper.buffer_size=2
input_filename_1 = 'stream_input_1'
input_filename_2 = 'stream_input_2'
input_data1 = File.read(File.join(File.dirname(__FILE__), "../fixtures/#{input_filename_1}"))
input_data2 = File.read(File.join(File.dirname(__FILE__), "../fixtures/#{input_filename_2}"))
names_list = [ input_filename_1, input_filename_2 ]
stream = File.open(output_stream_name, 'wb')
dumper.begin_dump(input_filename_1)
dumper.dump(input_filename_1, [input_data1])
dumper.end_dump(input_filename_1)
dumper.begin_dump(input_filename_2)
dumper.dump(input_filename_2, [input_data2])
dumper.end_dump(input_filename_2)
dumper.merge_dumps_into_stream(names_list, stream)
stream.close
data = File.read(output_stream_name)
data.should eq "\n#{input_data1}\n#{input_data2}\n"
File.unlink(output_stream_name)
end
end
end
@@ -0,0 +1,103 @@
require_relative '../../lib/datasources'
require_relative '../doubles/organization'
require_relative '../doubles/user'
require_relative '../doubles/search_tweet'
require_relative '../doubles/data_import'
require_relative '../../../../lib/cartodb/logger'
include CartoDB::Datasources
describe Search::Twitter do
def get_config
{
'auth_required' => false,
'username' => '',
'password' => '',
'search_url' => 'http://fakeurl.carto',
}
end
before(:each) do
Typhoeus::Expectation.clear
end
describe '#search' do
it 'tests basic full search flow with streaming' do
user_quota = 100
user_mock = CartoDB::Datasources::Doubles::User.new({twitter_datasource_quota: user_quota})
data_import_mock = CartoDB::Datasources::Doubles::DataImport.new(id: '123456789', service_item_id: '987654321')
twitter_datasource = Search::Twitter.get_new(get_config, user_mock)
input_terms = terms_fixture
input_dates = dates_fixture
Typhoeus.stub(/fakeurl\.carto/) do |request|
accept = (request.options[:headers]||{})['Accept'] || 'application/json'
format = accept.split(',').first
request.base_url.should eq 'http://fakeurl.carto'
request.options[:params].key?(:pusblisher).should eq false
body = data_from_file('sample_tweets_v2.json')
Typhoeus::Response.new(
code: 200,
headers: { 'Content-Type' => format },
body: body
)
end
twitter_datasource.send :audit_entry, CartoDB::Datasources::Doubles::SearchTweet
twitter_datasource.data_import_item = data_import_mock
stream_location = '/tmp/sample_tweets_v2.csv'
File.unlink(stream_location) if File.exists?(stream_location)
stream = File.open(stream_location, 'wb')
twitter_datasource.stream_resource(::JSON.dump(
{
categories: input_terms[:categories],
dates: input_dates[:dates]
}
), stream)
stream.close
stored_data = data_from_file(stream_location, true)
stored_data.should eq data_from_file('sample_tweets_v2.csv')
File.unlink(stream_location)
end
end
protected
def terms_fixture
{
categories: [
{
category: '1',
terms: ['carto']
}
]
}
end
def dates_fixture
{
dates: {
fromDate: '2017-02-21',
fromHour: '11',
fromMin: '45',
toDate: '2017-02-21',
toHour: '12',
toMin: '00'
}
}
end
def data_from_file(filename, fullpath=false)
if fullpath
File.read(filename)
else
File.read(File.join(File.dirname(__FILE__), "../fixtures/#{filename}"))
end
end
end
@@ -0,0 +1,521 @@
require 'active_support/core_ext'
require_relative '../../lib/datasources'
require_relative '../doubles/user'
include CartoDB::Datasources
describe Url::ArcGIS do
before(:all) do
@url = 'http://myserver/arcgis/rest/services/MyFakeService/featurename'
@invalid_url = 'http://myserver/mysite/rest/myfakefolder/MyFakeService/featurename'
@user = CartoDB::Datasources::Doubles::User.new
end
before(:each) do
Typhoeus::Expectation.clear
end
describe '#set_data_from' do
it 'tests preparing the correct url from the one given from the UI' do
invalid_1 = 'http://myserver/services/MyFakeService/featurename/MapServer'
invalid_2 = 'myserver/services/MyFakeService/featurename/MapServer'
test1 = 'http://myserver/arcgis/rest/services/MyFakeService/featurename/MapServer'
test2 = 'http://myserver/arcgis/rest/services/MyFakeService/featurename/MapServer/'
test3 = 'http://myserver/arcgis/rest/services/MyFakeService/featurename/MapServer/0'
test4 = 'http://myserver/arcgis/rest/services/MyFakeService/featurename/MapServer/0?'
test5 = 'http://myserver/arcgis/rest/services/MyFakeService/featurename/MapServer/0?q=blablabla'
test6 = 'http://myserver/arcgis/rest/services/MyFakeService/featurename/MapServer?q=blablabla'
valid_map = 'http://myserver/arcgis/rest/services/MyFakeService/featurename/MapServer'
valid_map_trailing_slash = 'http://myserver/arcgis/rest/services/MyFakeService/featurename/MapServer/'
valid_layer = 'http://myserver/arcgis/rest/services/MyFakeService/featurename/MapServer/0'
# Should be treated as ok
test7 = 'http://myserver/arcgis/rest/services/MyFakeService/featurename/2314/'
valid_7 = 'http://myserver/arcgis/rest/services/MyFakeService/featurename/2314/'
arcgis = Url::ArcGIS.get_new(@user)
expect {
arcgis.send(:sanitize_id, invalid_1)
}.to raise_error InvalidInputDataError
expect {
arcgis.send(:sanitize_id, invalid_2)
}.to raise_error InvalidInputDataError
arcgis.send(:sanitize_id, test1).should eq valid_map
arcgis.send(:sanitize_id, test2).should eq valid_map_trailing_slash
arcgis.send(:sanitize_id, test3).should eq valid_layer
arcgis.send(:sanitize_id, test4).should eq valid_layer
arcgis.send(:sanitize_id, test5).should eq valid_layer
arcgis.send(:sanitize_id, test6).should eq valid_map
arcgis.send(:sanitize_id, test7).should eq valid_7
end
end
describe '#get_resource_metadata' do
it 'tests error scenarios' do
arcgis = Url::ArcGIS.get_new(@user)
sub_id = '0'
# 'general http error (non-200)'
Typhoeus.stub(/\/arcgis\/rest\/services\/MyFakeService\/featurename\/layers/) do
Typhoeus::Response.new(
code: 400,
headers: { 'Content-Type' => 'application/json' },
body: ''
)
end
expect {
arcgis.get_resource_metadata(@url)
}.to raise_error DataDownloadError
# Stub layers request (so now works)
Typhoeus.stub(/\/arcgis\/rest\/services\/MyFakeService\/featurename\/layers/) do |request|
body = File.read(File.join(File.dirname(__FILE__), "../fixtures/arcgis_layers.json"))
Typhoeus::Response.new(
code: 200,
headers: { 'Content-Type' => 'application/json' },
body: ::JSON.dump(::JSON.parse(body))
)
end
layers_data = arcgis.get_resource_metadata(@url)
layers_data_expected = {
id: @url,
subresources: [{
id: "#{@url}/#{sub_id}",
title: 'first layer'
}]
}
layers_data.should eq layers_data_expected
# 'fields' part
Typhoeus.stub(/\/arcgis\/rest\/services\/MyFakeService\/featurename\/0/) do |request|
body = File.read(File.join(File.dirname(__FILE__), "../fixtures/arcgis_metadata_minimal.json"))
body = ::JSON.parse(body)
body.delete('fields')
Typhoeus::Response.new(
code: 200,
headers: { 'Content-Type' => 'application/json' },
body: ::JSON.dump(body)
)
end
expect {
arcgis.send(:get_subresource_metadata, @url, sub_id)
}.to raise_error ResponseError
# Another required field
Typhoeus.stub(/\/arcgis\/rest\/services\/MyFakeService\/featurename\/0/) do |request|
body = File.read(File.join(File.dirname(__FILE__), "../fixtures/arcgis_metadata_minimal.json"))
body = ::JSON.parse(body)
body.delete('name')
Typhoeus::Response.new(
code: 200,
headers: { 'Content-Type' => 'application/json' },
body: ::JSON.dump(body)
)
end
expect {
arcgis.send(:get_subresource_metadata, @url, sub_id)
}.to raise_error ResponseError
# Invalid ArcGIS version
Typhoeus.stub(/\/arcgis\/rest\/services\/MyFakeService\/featurename\/0/) do |request|
body = File.read(File.join(File.dirname(__FILE__), "../fixtures/arcgis_metadata_minimal.json"))
body = ::JSON.parse(body)
body['currentVersion'] = 9.0
Typhoeus::Response.new(
code: 200,
headers: { 'Content-Type' => 'application/json' },
body: ::JSON.dump(body)
)
end
expect {
arcgis.send(:get_subresource_metadata, @url, sub_id)
}.to raise_error InvalidServiceError
# Invalid ArcGIS URL
expect {
arcgis.send(:get_resource_metadata, @invalid_url)
}.to raise_error InvalidInputDataError
end
it 'tests metadata retrieval' do
arcgis = Url::ArcGIS.get_new(@user)
# Stub layers request (so now works)
Typhoeus.stub(/\/arcgis\/rest\/services\/MyFakeService\/featurename\/layers/) do |request|
body = File.read(File.join(File.dirname(__FILE__), "../fixtures/arcgis_layers.json"))
Typhoeus::Response.new(
code: 200,
headers: { 'Content-Type' => 'application/json' },
body: ::JSON.dump(::JSON.parse(body))
)
end
Typhoeus.stub(/\/arcgis\/rest\/services\/MyFakeService\/featurename\/0/) do |request|
accept = (request.options[:headers]||{})['Accept'] || 'application/json'
format = accept.split(',').first
body = File.read(File.join(File.dirname(__FILE__), "../fixtures/arcgis_metadata_minimal.json"))
Typhoeus::Response.new(
code: 200,
headers: { 'Content-Type' => format },
body: body
)
end
expected_metadata = {
:arcgis_version=>10.22,
:name=>"Test Feature",
:description=>"Sample metadata payload",
:type=>"Feature Layer",
:geometry_type=>"esriGeometryPolygon",
:copyright=>"CartoDB",
:fields=>[
{
:name => "OBJECTID",
:type => "esriFieldTypeOID"
},
{
:name => "NAME",
:type => "esriFieldTypeString"
}
],
:max_records_per_query=>1000,
:supported_formats=>["JSON", "AMF"],
:advanced_queries_supported=>true
}
expected_metadata_response = {
id: @url + '/0',
title: 'Test Feature',
url: nil,
service: Url::ArcGIS::DATASOURCE_NAME,
checksum: nil,
size: 0,
filename: 'test_feature.json'
}
# Multi-resource scenario already tested above
response = arcgis.get_resource_metadata(@url + '/0')
response.nil?.should be false
arcgis.metadata.should eq expected_metadata
response.should eq expected_metadata_response
end
end
describe '#get_resource' do
it 'tests the get_ids_list() private method with error scenarios' do
arcgis = Url::ArcGIS.get_new(@user)
id = arcgis.send(:sanitize_id, @url)
# 'general http error (non-200)'
Typhoeus.stub(/\/arcgis\/rest\//) do
Typhoeus::Response.new(
code: 400,
headers: { 'Content-Type' => 'application/json' },
body: ''
)
end
expect {
arcgis.send(:get_ids_list, id)
}.to raise_error DataDownloadError
# 'objectIds' not present
Typhoeus::Expectation.clear
Typhoeus.stub(/\/arcgis\/rest\//) do
body = File.read(File.join(File.dirname(__FILE__), "../fixtures/arcgis_ids_list.json"))
body = ::JSON.parse(body)
body.delete('objectIds')
Typhoeus::Response.new(
code: 200,
headers: { 'Content-Type' => 'application/json' },
body: ::JSON.dump(body)
)
end
expect {
arcgis.send(:get_ids_list, id)
}.to raise_error ResponseError
# 'objectIds' empty
Typhoeus::Expectation.clear
Typhoeus.stub(/\/arcgis\/rest\//) do
body = File.read(File.join(File.dirname(__FILE__), "../fixtures/arcgis_ids_list.json"))
body = ::JSON.parse(body)
body['objectIds'] = []
Typhoeus::Response.new(
code: 200,
headers: { 'Content-Type' => 'application/json' },
body: ::JSON.dump(body)
)
end
expect {
arcgis.send(:get_ids_list, id)
}.to raise_error ResponseError
end
it 'tests the get_ids_list() private method' do
arcgis = Url::ArcGIS.get_new(@user)
id = arcgis.send(:sanitize_id, @url)
Typhoeus.stub(/\/arcgis\/rest\//) do |request|
body = File.read(File.join(File.dirname(__FILE__), "../fixtures/arcgis_ids_list.json"))
Typhoeus::Response.new(
code: 200,
headers: { 'Content-Type' => 'application/json' },
body: body
)
end
expected_ids = [1,2,3,4,5,6,7,8,9,10]
respose_ids = arcgis.send(:get_ids_list, id)
respose_ids.nil?.should be false
respose_ids.should eq expected_ids
end
it 'tests the get_ids_list() private method on out-of-order ids' do
arcgis = Url::ArcGIS.get_new(@user)
id = arcgis.send(:sanitize_id, @url)
Typhoeus.stub(/\/arcgis\/rest\//) do |request|
body = File.read(File.join(File.dirname(__FILE__), "../fixtures/arcgis_unordered_ids_list.json"))
Typhoeus::Response.new(
code: 200,
headers: { 'Content-Type' => 'application/json' },
body: body
)
end
expected_ids = [1,2,3,4,5,6,7,8,9,10]
respose_ids = arcgis.send(:get_ids_list, id)
respose_ids.nil?.should be false
respose_ids.should eq expected_ids
end
it 'tests the get_by_ids() private method with error scenarios' do
arcgis = Url::ArcGIS.get_new(@user)
id = arcgis.send(:sanitize_id, @url)
# Empty ids
expect {
arcgis.send(:get_by_ids, id, [], [{ key: 'value' }])
}.to raise_error InvalidInputDataError
# Empty fields
expect {
arcgis.send(:get_by_ids, id, [1], [])
}.to raise_error InvalidInputDataError
# 'general http error (non-200)'
Typhoeus.stub(/\/arcgis\/rest\//) do
Typhoeus::Response.new(
code: 400,
headers: { 'Content-Type' => 'application/json' },
body: ''
)
end
expect {
arcgis.send(:get_by_ids, id, [1], [{ key: 'value' }])
}.to raise_error DataDownloadError
end
it 'tests the get_by_ids() private method' do
arcgis = Url::ArcGIS.get_new(@user)
id = arcgis.send(:sanitize_id, @url)
Typhoeus.stub(/\/arcgis\/rest\//) do
body = File.read(File.join(File.dirname(__FILE__), "../fixtures/arcgis_data_01.json"))
Typhoeus::Response.new(
code: 200,
headers: { 'Content-Type' => 'application/json' },
body: body
)
end
expected_response_data = {
geometryType: "esriGeometryPolygon",
spatialReference: {
"wkid" => 4326,
"latestWkid" => 4326
},
fields: [
{
"name" => "OBJECTID",
"type" => "esriFieldTypeOID",
"alias" => "OBJECTID"
},
{
"name" => "WDPAID",
"type" => "esriFieldTypeInteger",
"alias" => "WDPAID"
},
{
"name" => "NAME",
"type" => "esriFieldTypeString",
"alias" => "NAME",
"length" => 254
}
],
features: [
{"attributes"=>{"OBJECTID"=>1, "NAME"=>"Name of object 1"}, "geometry"=>{"fake"=>"geom"}},
{"attributes"=>{"OBJECTID"=>2, "NAME"=>"Name of object 2"}, "geometry"=>{"fake"=>"geom"}},
{"attributes"=>{"OBJECTID"=>3, "NAME"=>"Name of object 3"}, "geometry"=>{"fake"=>"geom"}}
]
}
ids_to_retrieve = [1,2,3]
# WDPAID also present, but left on purpose untouched
fields_to_retrieve = [{
name: 'OBJECTID',
type: 'esriFieldTypeOID'
},
{
name: 'NAME',
type: 'esriFieldTypeString'
}]
response_data = arcgis.send(:get_by_ids, id, ids_to_retrieve, fields_to_retrieve)
response_data.nil?.should eq false
response_data[:features].length.should eq 3
response_data.should eq expected_response_data
end
it 'tests retrieval of data' do
arcgis = Url::ArcGIS.get_new(@user)
feature_names = []
# Layers request
Typhoeus.stub(/\/arcgis\/rest\/services\/MyFakeService\/featurename\/layers/) do |request|
body = File.read(File.join(File.dirname(__FILE__), "../fixtures/arcgis_layers.json"))
Typhoeus::Response.new(
code: 200,
headers: { 'Content-Type' => 'application/json' },
body: ::JSON.dump(::JSON.parse(body))
)
end
# Metadata of a layer
Typhoeus.stub(/\/arcgis\/rest\/services\/MyFakeService\/featurename\/0\?f=json/) do
body = File.read(File.join(File.dirname(__FILE__), "../fixtures/arcgis_metadata_minimal.json"))
Typhoeus::Response.new(
code: 200,
headers: { 'Content-Type' => 'application/json' },
body: body
)
end
# IDs list of a layer
Typhoeus.stub(/\/arcgis\/rest\/(.*)query\?where=/) do
body = File.read(File.join(File.dirname(__FILE__), "../fixtures/arcgis_ids_list_01.json"))
Typhoeus::Response.new(
code: 200,
headers: { 'Content-Type' => 'application/json' },
body: body
)
end
Typhoeus.stub(/\/arcgis\/rest\/(.*)query$/) do |response|
if response.options[:body][:objectIds].to_i == 1
# First item fetch of a layer
body = File.read(File.join(File.dirname(__FILE__), "../fixtures/arcgis_data_01.json"))
body = ::JSON.parse(body)
feature_names.push body['features'][0]['attributes']['NAME']
body['features'] = [ body['features'][0] ]
else
# Remaining items fetch of a layer, will not use :objectIds
body = File.read(File.join(File.dirname(__FILE__), "../fixtures/arcgis_data_01.json"))
body = ::JSON.parse(body)
feature_names.push body['features'][1]['attributes']['NAME']
feature_names.push body['features'][2]['attributes']['NAME']
body['features'] = [ body['features'][1], body['features'][2] ]
end
Typhoeus::Response.new(
code: 200,
headers: { 'Content-Type' => 'application/json' },
body: ::JSON.dump(body)
)
end
# 1) Retrieve lists of layers
metadata = arcgis.get_resource_metadata(@url)
# 2) Retrieve metadata of a specific layer (doesn't adds much, but to replicate flow)
item_metadata = arcgis.get_resource_metadata(metadata[:subresources].first[:id])
item_metadata.nil?.should eq false
# No more checks as will fail later if missing something
spatial_ref_expectation = {"wkid"=>4326, "latestWkid"=>4326}
initial_stream_data = arcgis.initial_stream(item_metadata[:id])
initial_stream_data.nil?.should eq false
initial_stream_data = ::JSON.parse(initial_stream_data)
initial_stream_data['geometryType'].should eq 'esriGeometryPolygon'
initial_stream_data['spatialReference'].should eq spatial_ref_expectation
initial_stream_data['fields'].count.should eq 3
initial_stream_data['fields'][0]['name'].should eq 'OBJECTID'
initial_stream_data['fields'][1]['name'].should eq 'WDPAID'
initial_stream_data['fields'][2]['name'].should eq 'NAME'
initial_stream_data['features'].count.should eq 1
initial_stream_data['features'][0]['attributes'].nil?.should eq false
initial_stream_data['features'][0]['attributes']['NAME'].should eq feature_names[0]
initial_stream_data['features'][0]['geometry'].nil?.should eq false
streamed_data = arcgis.stream_resource(item_metadata[:id])
streamed_data.nil?.should eq false
streamed_data = ::JSON.parse(streamed_data)
streamed_data['geometryType'].should eq 'esriGeometryPolygon'
streamed_data['spatialReference'].should eq spatial_ref_expectation
streamed_data['fields'].count.should eq 3
streamed_data['fields'][0]['name'].should eq 'OBJECTID'
streamed_data['fields'][1]['name'].should eq 'WDPAID'
streamed_data['fields'][2]['name'].should eq 'NAME'
streamed_data['features'].count.should eq 2
streamed_data['features'][0]['attributes']['NAME'].should eq feature_names[1]
streamed_data['features'][1]['attributes']['NAME'].should eq feature_names[2]
end
end
end
@@ -0,0 +1,31 @@
require_relative '../../lib/datasources'
require_relative '../doubles/user'
include CartoDB::Datasources
describe Url::Box do
def get_config
{
'box_host' => '',
'application_name' => '',
'client_id' => '',
'client_secret' => '',
'callback_url' => ''
}
end
describe '#filters' do
it 'test that filter sets correctly' do
user_mock = CartoDB::Datasources::Doubles::User.new
box_provider = Url::Box.get_new(get_config, user_mock)
box_provider.filter.should eq nil
# Filter to 'documents'
formats = ['csv', 'xls']
box_provider.filter = formats
box_provider.filter.should eq formats
end
end
end
@@ -0,0 +1,47 @@
require_relative '../../lib/datasources'
require_relative '../doubles/user'
include CartoDB::Datasources
describe Url::Dropbox do
def get_config
{
'app_key' => '',
'app_secret' => '',
'callback_url' => ''
}
end #get_config
describe '#filters' do
it 'test that filter options work correctly' do
user_mock = CartoDB::Datasources::Doubles::User.new
dropbox_provider = Url::Dropbox.get_new(get_config, user_mock)
# No filter = all formats allowed
filter = []
Url::Dropbox::FORMATS_TO_SEARCH_QUERIES.each do |id, search_queries|
search_queries.each do |search_query|
filter = filter.push(search_query)
end
end
dropbox_provider.filter.should eq filter
# Filter to 'documents'
filter = []
format_ids = [ Url::Dropbox::FORMAT_CSV, Url::Dropbox::FORMAT_EXCEL ]
Url::Dropbox::FORMATS_TO_SEARCH_QUERIES.each do |id, search_queries|
if format_ids.include?(id)
search_queries.each do |search_query|
filter = filter.push(search_query)
end
end
end
dropbox_provider.filter = format_ids
dropbox_provider.filter.should eq filter
end
end #run
end
@@ -0,0 +1,53 @@
require_relative '../../../../spec/rspec_configuration'
require_relative '../../lib/datasources'
require_relative '../doubles/user'
include CartoDB::Datasources
describe Url::GDrive do
def get_config
{
'application_name' => '',
'client_id' => '',
'client_secret' => '',
'callback_url' => 'http://localhost/callback'
}
end #get_config
describe '#filters' do
it 'test that filter options work correctly' do
# Stubs Google Drive client for connectionless testing
Google::Apis::DriveV2::DriveService.any_instance.stubs(:get_file)
Google::Apis::DriveV2::DriveService.any_instance.stubs(:export_file)
Google::Apis::DriveV2::DriveService.any_instance.stubs(:list_files)
user_mock = CartoDB::Datasources::Doubles::User.new
gdrive_provider = Url::GDrive.get_new(get_config, user_mock)
# No filter = all formats allowed
filter = []
Url::GDrive::FORMATS_TO_MIME_TYPES.each do |id, mime_types|
mime_types.each do |mime_type|
filter = filter.push(mime_type)
end
end
gdrive_provider.filter.should eq filter
# Filter to 'documents'
filter = []
format_ids = [ Url::GDrive::FORMAT_CSV, Url::GDrive::FORMAT_EXCEL ]
Url::GDrive::FORMATS_TO_MIME_TYPES.each do |id, mime_types|
if format_ids.include?(id)
mime_types.each do |mime_type|
filter = filter.push(mime_type)
end
end
end
gdrive_provider.filter = format_ids
gdrive_provider.filter.should eq filter
end
end #run
end
@@ -0,0 +1,372 @@
require_relative '../../lib/datasources'
require_relative '../doubles/organization'
require_relative '../doubles/user'
require_relative '../doubles/search_tweet'
require_relative '../doubles/data_import'
require_relative '../../../../lib/cartodb/logger'
include CartoDB::Datasources
describe Search::Twitter do
def get_config
{
'auth_required' => false,
'username' => '',
'password' => '',
'search_url' => 'http://fakeurl.carto',
}
end #get_config
before(:each) do
Typhoeus::Expectation.clear
end
describe '#filters' do
it 'tests max and total results filters' do
big_quota = 123456
user = CartoDB::Datasources::Doubles::User.new({
twitter_datasource_quota: big_quota
})
twitter_datasource = Search::Twitter.get_new(get_config, user)
maxresults_filter = twitter_datasource.send :build_maxresults_field, user
maxresults_filter.should eq CartoDB::TwitterSearch::SearchAPI::MAX_PAGE_RESULTS
totalresults_filter = twitter_datasource.send :build_total_results_field, user
totalresults_filter.should eq big_quota
small_quota = 13
user = CartoDB::Datasources::Doubles::User.new({
twitter_datasource_quota: small_quota,
soft_twitter_datasource_limit: false
})
twitter_datasource = Search::Twitter.get_new(get_config, user)
maxresults_filter = twitter_datasource.send :build_maxresults_field, user
maxresults_filter.should eq small_quota
totalresults_filter = twitter_datasource.send :build_total_results_field, user
totalresults_filter.should eq small_quota
user = CartoDB::Datasources::Doubles::User.new({
twitter_datasource_quota: small_quota,
soft_twitter_datasource_limit: true
})
maxresults_filter = twitter_datasource.send :build_maxresults_field, user
maxresults_filter.should eq CartoDB::TwitterSearch::SearchAPI::MAX_PAGE_RESULTS
totalresults_filter = twitter_datasource.send :build_total_results_field, user
totalresults_filter.should eq Search::Twitter::NO_TOTAL_RESULTS
end
it 'tests category filters' do
user_mock = CartoDB::Datasources::Doubles::User.new
twitter_datasource = Search::Twitter.get_new(get_config, user_mock)
input_terms = terms_fixture
expected_output_terms = [
{
Search::Twitter::CATEGORY_NAME_KEY => 'Category 1',
Search::Twitter::CATEGORY_TERMS_KEY => '(uno OR @dos OR #tres) (has:geo OR has:profile_geo)'
},
{
Search::Twitter::CATEGORY_NAME_KEY => 'Category 2',
Search::Twitter::CATEGORY_TERMS_KEY => '(aaa OR bbb) (has:geo OR has:profile_geo)'
}
]
output = twitter_datasource.send :build_queries_from_fields, input_terms
output.should eq expected_output_terms
end
it 'tests search term cut if too many' do
user_mock = CartoDB::Datasources::Doubles::User.new
twitter_datasource = Search::Twitter.get_new(get_config, user_mock)
input_terms = {
categories: [
{
category: 'Category 1',
terms: Array(1..35)
}
]
}
expected_output_terms = [
{
Search::Twitter::CATEGORY_NAME_KEY => 'Category 1',
Search::Twitter::CATEGORY_TERMS_KEY => '(1 OR 2 OR 3 OR 4 OR 5 OR 6 OR 7 OR 8 OR 9 OR 10 OR 11 OR 12 OR 13 OR 14 OR 15 OR 16 OR 17 OR 18 OR 19 OR 20 OR 21 OR 22 OR 23 OR 24 OR 25 OR 26 OR 27 OR 28 OR 29) (has:geo OR has:profile_geo)'
},
]
output = twitter_datasource.send :build_queries_from_fields, input_terms
output.should eq expected_output_terms
end
it 'tests search term cut if too big (even if amount is ok)' do
user_mock = CartoDB::Datasources::Doubles::User.new
twitter_datasource = Search::Twitter.get_new(get_config, user_mock)
input_terms = {
categories: [
{
category: 'Category 1',
terms: ['wadus1', 'wadus2', 'wadus3' * 500]
}
]
}
expect {
output = twitter_datasource.send :build_queries_from_fields, input_terms
}.to raise_error ParameterError
end
it 'tests date filters' do
user_mock = CartoDB::Datasources::Doubles::User.new
twitter_datasource = Search::Twitter.get_new(get_config, user_mock)
input_dates = dates_fixture
output = twitter_datasource.send :build_date_from_fields, input_dates, 'from'
output.should eq '201403031349'
output = twitter_datasource.send :build_date_from_fields, input_dates, 'to'
output.should eq '201403041159'
expect {
twitter_datasource.send :build_date_from_fields, input_dates, 'wadus'
}.to raise_error ParameterError
current_time = Time.now.utc
output = twitter_datasource.send :build_date_from_fields, {
dates: {
toDate: current_time.strftime("%Y-%m-%d"),
toHour: current_time.hour + 1, # Set into the future
toMin: current_time.min
}
}, 'to'
output.should eq nil
end
it 'tests twitter search integration (without conversion to CSV)' do
# This test bridges lots of internal calls to simulate only up until twitter search call and results
user_mock = CartoDB::Datasources::Doubles::User.new
twitter_datasource = Search::Twitter.get_new(get_config, user_mock)
input_terms = terms_fixture
input_dates = dates_fixture
Typhoeus.stub(/fakeurl\.carto/) do |request|
accept = (request.options[:headers]||{})['Accept'] || 'application/json'
format = accept.split(',').first
if request.options[:params][:next].nil?
body = data_from_file('sample_tweets.json')
else
body = data_from_file('sample_tweets_2.json')
end
Typhoeus::Response.new(
code: 200,
headers: { 'Content-Type' => format },
body: body
)
end
twitter_api_config = twitter_datasource.send :search_api_config
twitter_api = CartoDB::TwitterSearch::SearchAPI.new(twitter_api_config)
fields = {
categories: input_terms[:categories],
dates: input_dates[:dates]
}
filters = {
Search::Twitter::FILTER_CATEGORIES => (twitter_datasource.send :build_queries_from_fields, fields),
Search::Twitter::FILTER_FROMDATE => (twitter_datasource.send :build_date_from_fields, fields, 'from'),
Search::Twitter::FILTER_TODATE => (twitter_datasource.send :build_date_from_fields, fields, 'to'),
Search::Twitter::FILTER_MAXRESULTS => 500,
Search::Twitter::FILTER_TOTAL_RESULTS => Search::Twitter::NO_TOTAL_RESULTS
}
category = {
name: input_terms[:categories].first[:category],
terms: input_terms[:categories].first[:terms],
}
csv_dumper = twitter_datasource.send :csv_dumper
csv_dumper.begin_dump(input_terms[:categories][0][:category])
csv_dumper.begin_dump(input_terms[:categories][1][:category])
csv_dumper.additional_fields = { category[:name] => category }
output = twitter_datasource.send :search_by_category, twitter_api, filters, category
# 2 pages of 10 results per category search
output.should eq 20
csv_dumper.send :destroy_files
end
it 'tests stopping search if runs out of quota' do
# Should equal to sample_tweets_3.json number of results, and always >= 10 (because is Gnip's minimum)
remaining_tweets_quota = 11
user_mock = CartoDB::Datasources::Doubles::User.new(
twitter_datasource_quota: remaining_tweets_quota
)
twitter_datasource = Search::Twitter.get_new(get_config, user_mock)
input_terms = terms_fixture
input_dates = dates_fixture
Typhoeus.stub(/fakeurl\.carto/) do |request|
accept = (request.options[:headers]||{})['Accept'] || 'application/json'
format = accept.split(',').first
if request.options[:params][:next].nil?
# This dataset has 11 items and a "next"
body = data_from_file('sample_tweets_3.json')
else
body = data_from_file('sample_tweets_2.json')
end
Typhoeus::Response.new(
code: 200,
headers: { 'Content-Type' => format },
body: body
)
end
twitter_api_config = twitter_datasource.send :search_api_config
twitter_api = CartoDB::TwitterSearch::SearchAPI.new(twitter_api_config)
fields = {
categories: input_terms[:categories],
dates: input_dates[:dates]
}
filters = {
Search::Twitter::FILTER_CATEGORIES => (twitter_datasource.send :build_queries_from_fields, fields),
Search::Twitter::FILTER_FROMDATE => (twitter_datasource.send :build_date_from_fields, fields, 'from'),
Search::Twitter::FILTER_TODATE => (twitter_datasource.send :build_date_from_fields, fields, 'to'),
Search::Twitter::FILTER_MAXRESULTS => 500,
Search::Twitter::FILTER_TOTAL_RESULTS => Search::Twitter::NO_TOTAL_RESULTS
}
category = {
name: input_terms[:categories].first[:category],
terms: input_terms[:categories].first[:terms],
}
csv_dumper = twitter_datasource.send :csv_dumper
csv_dumper.begin_dump(input_terms[:categories][0][:category])
csv_dumper.begin_dump(input_terms[:categories][1][:category])
csv_dumper.additional_fields = { category[:name] => category }
output = twitter_datasource.send :search_by_category, twitter_api, filters, category
output.should eq remaining_tweets_quota
csv_dumper.send :destroy_files
end
it 'tests user limits on datasource usage' do
user_mock = CartoDB::Datasources::Doubles::User.new
twitter_datasource = Search::Twitter.get_new(get_config, user_mock)
# Service enabled tests
result = twitter_datasource.send :is_service_enabled?, CartoDB::Datasources::Doubles::User.new({
has_org: true,
twitter_datasource_enabled: true,
org_twitter_datasource_enabled: true
})
result.should eq true
result = twitter_datasource.send :is_service_enabled?, CartoDB::Datasources::Doubles::User.new({
has_org: true,
twitter_datasource_enabled: false,
org_twitter_datasource_enabled: true
})
result.should eq false
result = twitter_datasource.send :is_service_enabled?, CartoDB::Datasources::Doubles::User.new({
twitter_datasource_enabled: true,
})
result.should eq true
result = twitter_datasource.send :is_service_enabled?, CartoDB::Datasources::Doubles::User.new({
twitter_datasource_enabled: false,
})
result.should eq false
# Quota & soft limit tests
result = twitter_datasource.send :has_enough_quota?, CartoDB::Datasources::Doubles::User.new({
soft_twitter_datasource_limit: false,
twitter_datasource_quota: 10,
})
result.should eq true
result = twitter_datasource.send :has_enough_quota?, CartoDB::Datasources::Doubles::User.new({
soft_twitter_datasource_limit: true,
twitter_datasource_quota: 0,
})
result.should eq true
result = twitter_datasource.send :has_enough_quota?, CartoDB::Datasources::Doubles::User.new({
soft_twitter_datasource_limit: false,
twitter_datasource_quota: 0,
})
result.should eq false
end
it 'checks terms sanitize method' do
user_mock = CartoDB::Datasources::Doubles::User.new
twitter_datasource = Search::Twitter.get_new(get_config, user_mock)
terms = [ 'a', ' b', 'c ', ' d ', ' e f', 'g h ', ' i j ', ' 1 2 3 4 ', ' ' ]
terms_expected = [ 'a', 'b', 'c', 'd', '"e f"', '"g h"', '"i j"', '"1 2 3 4"' ]
result = twitter_datasource.send :sanitize_terms, terms
result.should eq terms_expected
end
end
protected
def terms_fixture
{
categories: [
{
category: 'Category 1',
terms: ['uno', '@dos', '#tres']
},
{
category: 'Category 2',
terms: ['aaa', 'bbb']
}
]
}
end
def dates_fixture
{
dates: {
fromDate: '2014-03-03',
fromHour: '13',
fromMin: '49',
toDate: '2014-03-04',
toHour: '11',
toMin: '59'
}
}
end
def data_from_file(filename, fullpath=false)
if fullpath
File.read(filename)
else
File.read(File.join(File.dirname(__FILE__), "../fixtures/#{filename}"))
end
end
end