mirror of
https://github.com/jwinterm/ethtrader-ticker.git
synced 2026-10-21 06:18:44 +02:00
Initial commit.
This commit is contained in:
+70
@@ -0,0 +1,70 @@
|
||||
# Created by .ignore support plugin (hsz.mobi)
|
||||
### JetBrains template
|
||||
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm
|
||||
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
|
||||
|
||||
# User-specific stuff:
|
||||
.idea/workspace.xml
|
||||
.idea/tasks.xml
|
||||
.idea/dictionaries
|
||||
.idea/vcs.xml
|
||||
.idea/jsLibraryMappings.xml
|
||||
|
||||
# Sensitive or high-churn files:
|
||||
.idea/dataSources.ids
|
||||
.idea/dataSources.xml
|
||||
.idea/dataSources.local.xml
|
||||
.idea/sqlDataSources.xml
|
||||
.idea/dynamic.xml
|
||||
.idea/uiDesigner.xml
|
||||
|
||||
# Gradle:
|
||||
.idea/gradle.xml
|
||||
.idea/libraries
|
||||
|
||||
# Mongo Explorer plugin:
|
||||
.idea/mongoSettings.xml
|
||||
|
||||
## File-based project format:
|
||||
*.iws
|
||||
|
||||
## Plugin-specific files:
|
||||
|
||||
# IntelliJ
|
||||
/out/
|
||||
|
||||
# mpeltonen/sbt-idea plugin
|
||||
.idea_modules/
|
||||
|
||||
# JIRA plugin
|
||||
atlassian-ide-plugin.xml
|
||||
|
||||
# Crashlytics plugin (for Android Studio and IntelliJ)
|
||||
com_crashlytics_export_strings.xml
|
||||
crashlytics.properties
|
||||
crashlytics-build.properties
|
||||
fabric.properties
|
||||
### Java template
|
||||
*.class
|
||||
|
||||
# Mobile Tools for Java (J2ME)
|
||||
.mtj.tmp/
|
||||
|
||||
# Package Files #
|
||||
*.jar
|
||||
*.war
|
||||
*.ear
|
||||
|
||||
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
|
||||
hs_err_pid*
|
||||
### Maven template
|
||||
target/
|
||||
pom.xml.tag
|
||||
pom.xml.releaseBackup
|
||||
pom.xml.versionsBackup
|
||||
pom.xml.next
|
||||
release.properties
|
||||
dependency-reduced-pom.xml
|
||||
buildNumber.properties
|
||||
.mvn/timing.properties
|
||||
|
||||
@@ -1,2 +1,39 @@
|
||||
# ethtrader-ticker
|
||||
|
||||
Creates a 'ticker' by pulling in various pieces of data and uploads it to reddit.
|
||||
|
||||
# Configuration
|
||||
|
||||
See the 'ticker.conf' https://github.com/jaycarey/ethtrader-ticker-conf at for inspiration.
|
||||
|
||||
Structure:
|
||||
- `ticker.conf` - the main configuration file for the ticker. This file is quite elaborate to try and make it as flexible as possible, it's probably best to learn by example from the existing configuration above.
|
||||
- `ticker.css` - CSS that applies to the ticker as a whole.
|
||||
- `per-ticker.css` - tickers have an 'upper' and 'lower' portion - this css is include for each one with certain placeholders applied.
|
||||
- `img/` - these are the images referred to in 'ticker.conf'
|
||||
|
||||
# Build
|
||||
|
||||
```
|
||||
mvn clean package
|
||||
```
|
||||
|
||||
# Deploy
|
||||
|
||||
Unzip the zip at `target/ethtrader-ticker-zip.zip` to the desired location.
|
||||
|
||||
# Run
|
||||
|
||||
An 'application' must be created. See here while logged in as the bot: https://www.reddit.com/prefs/apps/
|
||||
|
||||
```
|
||||
bash bin/start.sh <<subreddit>> <<username>> <<password>> <<clientId>> <<secret>> <<resourceUrl>>
|
||||
```
|
||||
|
||||
*subreddit* - is the name of the subreddit to show the ticker on.
|
||||
*username* - is the name of the bot's (must have the ability to modify CSS on the reddit.)
|
||||
*password* - password of the bot.
|
||||
*clientId* - the clientId from the application created above.
|
||||
*secret* - the secret from the application created above.
|
||||
*resourceUrl* - The base of the url the ticker's configuration is located at, e.g. for the configuration above: https://github.com/jaycarey/ethtrader-ticker-conf/blob/master/ would be the base.
|
||||
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>com.jay</groupId>
|
||||
<artifactId>ethtrader-ticker</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
|
||||
<properties>
|
||||
<kotlin.version>1.0.0</kotlin.version>
|
||||
<jackson.version>2.7.1</jackson.version>
|
||||
</properties>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.jetbrains.kotlin</groupId>
|
||||
<artifactId>kotlin-maven-plugin</artifactId>
|
||||
<version>${kotlin.version}</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>compile</id>
|
||||
<phase>process-sources</phase>
|
||||
<goals>
|
||||
<goal>compile</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<sourceDirs>
|
||||
<source>src/main/java</source>
|
||||
</sourceDirs>
|
||||
</configuration>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>test-compile</id>
|
||||
<phase>process-test-sources</phase>
|
||||
<goals>
|
||||
<goal>test-compile</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<sourceDirs>
|
||||
<source>src/test/java</source>
|
||||
</sourceDirs>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.3</version>
|
||||
<configuration>
|
||||
<source>1.8</source>
|
||||
<target>1.8</target>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-assembly-plugin</artifactId>
|
||||
<version>2.5.1</version>
|
||||
<configuration>
|
||||
<descriptor>src/assembly/zip.xml</descriptor>
|
||||
<finalName>ethtrader-ticker</finalName>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<phase>package</phase>
|
||||
<goals>
|
||||
<goal>single</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<artifactId>exec-maven-plugin</artifactId>
|
||||
<groupId>org.codehaus.mojo</groupId>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>Version Calculation</id>
|
||||
<phase>deploy</phase>
|
||||
<goals>
|
||||
<goal>exec</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<executable>${basedir}/src/scripts/deploy.sh</executable>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.jetbrains.kotlin</groupId>
|
||||
<artifactId>kotlin-stdlib</artifactId>
|
||||
<version>${kotlin.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-annotations</artifactId>
|
||||
<version>${jackson.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.jaxrs</groupId>
|
||||
<artifactId>jackson-jaxrs-base</artifactId>
|
||||
<version>${jackson.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.jaxrs</groupId>
|
||||
<artifactId>jackson-jaxrs-json-provider</artifactId>
|
||||
<version>${jackson.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.datatype</groupId>
|
||||
<artifactId>jackson-datatype-joda</artifactId>
|
||||
<version>${jackson.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.module</groupId>
|
||||
<artifactId>jackson-module-kotlin</artifactId>
|
||||
<version>${jackson.version}-2</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.glassfish.jersey.core</groupId>
|
||||
<artifactId>jersey-client</artifactId>
|
||||
<version>2.22.2</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.glassfish.jersey.media</groupId>
|
||||
<artifactId>jersey-media-multipart</artifactId>
|
||||
<version>2.22.2</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.typesafe</groupId>
|
||||
<artifactId>config</artifactId>
|
||||
<version>1.3.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.jsoup</groupId>
|
||||
<artifactId>jsoup</artifactId>
|
||||
<version>1.7.1</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>junit</groupId>
|
||||
<artifactId>junit</artifactId>
|
||||
<version>4.12</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.hamcrest</groupId>
|
||||
<artifactId>hamcrest-all</artifactId>
|
||||
<version>1.3</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mockito</groupId>
|
||||
<artifactId>mockito-all</artifactId>
|
||||
<version>1.9.5</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
|
||||
</project>
|
||||
@@ -0,0 +1,25 @@
|
||||
<assembly xmlns="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.3"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.3 http://maven.apache.org/xsd/assembly-1.1.3.xsd">
|
||||
<id>zip</id>
|
||||
<formats>
|
||||
<format>zip</format>
|
||||
</formats>
|
||||
<includeBaseDirectory>false</includeBaseDirectory>
|
||||
<dependencySets>
|
||||
<dependencySet>
|
||||
<outputDirectory>lib</outputDirectory>
|
||||
<useProjectArtifact>true</useProjectArtifact>
|
||||
<scope>runtime</scope>
|
||||
</dependencySet>
|
||||
</dependencySets>
|
||||
|
||||
<fileSets>
|
||||
<fileSet>
|
||||
<directory>src/main/bin</directory>
|
||||
<outputDirectory>bin</outputDirectory>
|
||||
<lineEnding>unix</lineEnding>
|
||||
<fileMode>0755</fileMode>
|
||||
</fileSet>
|
||||
</fileSets>
|
||||
</assembly>
|
||||
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
mkdir -p logs
|
||||
|
||||
LOG=logs/ticket.$(date "+%Y-%m-%d").$$.log
|
||||
nohup java -cp .:lib/* org/ethtrader/ticker/EntryKt $* >> $LOG 2>&1 &
|
||||
|
||||
tail -F $LOG
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,65 @@
|
||||
package org.ethtrader.ticker
|
||||
|
||||
import com.typesafe.config.Config
|
||||
import com.typesafe.config.ConfigFactory
|
||||
import java.awt.Color
|
||||
import java.lang.Long
|
||||
import java.net.URL
|
||||
import kotlin.reflect.KFunction1
|
||||
|
||||
class TickerEthConfig(getResourceUrl: (String) -> URL) {
|
||||
init {
|
||||
System.setProperty("java.awt.headless", "true")
|
||||
}
|
||||
|
||||
private val conf = ConfigFactory.parseURL(getResourceUrl("/ticker.conf")).getConfig("ticker")
|
||||
private val providerConf = conf.getConfig("providers")
|
||||
private val dataPointConf = conf.getConfig("dataPoints")
|
||||
private val tickerConf = conf.getConfig("tickers")
|
||||
|
||||
val reddit = RedditConf(conf)
|
||||
val providers = providerConf.keys().map { it to ProviderConf(providerConf, it) }.toMap()
|
||||
val dataPoints = dataPointConf.keys().map { it to dataPointConf.getStringList(it) }.toMap()
|
||||
val tickers = tickerConf.keys().map { it to TickerConf(tickerConf, it) }.toMap()
|
||||
}
|
||||
|
||||
class TickerConf(config: Config, name: String) {
|
||||
private val tickerConf = config.getConfig(name)
|
||||
val font = tickerConf.getString("font")
|
||||
val fontHeight = tickerConf.getInt("fontHeight")
|
||||
val color = tickerConf.getString("color").toColor()
|
||||
val background = tickerConf.getString("background").toColor()
|
||||
val backgroundHighlight = tickerConf.getString("backgroundHighlight").toColor()
|
||||
val positiveColor = tickerConf.getString("positiveColor").toColor()
|
||||
val negativeColor = tickerConf.getString("negativeColor").toColor()
|
||||
val segments = tickerConf.getStringList("segments")
|
||||
val top = tickerConf.getString("top")
|
||||
val cssSelector = tickerConf.getString("cssSelector")
|
||||
}
|
||||
|
||||
fun String.toColor(): Color = if (startsWith("#") && length == 9) java.lang.Long.decode(this).let {
|
||||
Color(((it shr 24) and 0xFF).toInt(), ((it shr 16) and 0xFF).toInt(), ((it shr 8) and 0xFF).toInt(), (it and 0xFF).toInt())
|
||||
} else Color.decode(this)
|
||||
|
||||
fun Config.keys(): Set<String> =
|
||||
entrySet().map { it.key.split(".", limit = 2).first() }.toSet()
|
||||
|
||||
class RedditConf(config: Config) {
|
||||
private val redditConf = config.getConfig("reddit")
|
||||
val frequencySeconds = redditConf.getLong("frequencySeconds")
|
||||
val authUrl = redditConf.getString("authUrl")
|
||||
val apiUrl = redditConf.getString("apiUrl")
|
||||
}
|
||||
|
||||
class ProviderConf(config: Config, name: String) {
|
||||
private val providerConf = config.getConfig(name)
|
||||
private val fieldsConf = providerConf.getConfig("fields")
|
||||
val url = providerConf.getString("url")
|
||||
val fields: Map<String, String> = fieldsConf.keys().map { it to fieldsConf.getString(it) }.toMap()
|
||||
}
|
||||
|
||||
fun Config.getStringOr(key: String, default: String?) = try {
|
||||
getString(key).toString()
|
||||
} catch(e: Throwable) {
|
||||
default
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package org.ethtrader.ticker
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.registerKotlinModule
|
||||
import org.glassfish.jersey.client.ClientProperties
|
||||
import java.lang.System.clearProperty
|
||||
import java.lang.System.setProperty
|
||||
import java.math.BigDecimal
|
||||
import java.net.URL
|
||||
import java.security.SecureRandom
|
||||
import java.security.cert.X509Certificate
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.TrustManager
|
||||
import javax.net.ssl.X509TrustManager
|
||||
import javax.ws.rs.client.Client
|
||||
import javax.ws.rs.client.ClientBuilder
|
||||
|
||||
class DataReader(providers: Map<String, ProviderConf>) {
|
||||
|
||||
private val client = ClientBuilder.newBuilder()
|
||||
.property(ClientProperties.CONNECT_TIMEOUT, 10000)
|
||||
.property(ClientProperties.READ_TIMEOUT, 10000)
|
||||
.sslContext(sslContext())
|
||||
|
||||
private val objectMapper = ObjectMapper()
|
||||
.registerKotlinModule()
|
||||
|
||||
private val providerFunctions = providers.map { provider ->
|
||||
provider.key to { values: List<String> ->
|
||||
try {
|
||||
val response = readObject(provider.value.url.format(*values.toTypedArray()))
|
||||
println("JSON Response form provider [${provider.key}], $values: $response")
|
||||
println("Reading fields: ${provider.value.fields}")
|
||||
response.parseFields(provider.value.fields)
|
||||
} catch (e: Throwable) {
|
||||
println("Unexpected exception: " + e.printStackTrace())
|
||||
mapOf<String, BigDecimal>()
|
||||
}
|
||||
}
|
||||
}.toMap()
|
||||
|
||||
fun read(dataPoints: Map<String, List<String>>): Map<String, Map<String, BigDecimal?>?> {
|
||||
val data = dataPoints.map {
|
||||
val function = providerFunctions.get(it.value.first())
|
||||
it.key to if (function == null) null else function(it.value.drop(1))
|
||||
}.toMap()
|
||||
println("Data points retrieved:\n - ${data.map { it }.joinToString("\n - ")}")
|
||||
return data
|
||||
}
|
||||
|
||||
private fun JsonNode.parseFields(fields: Map<String, String>): Map<String, BigDecimal?> =
|
||||
fields.map {
|
||||
it.key to try {
|
||||
val text = at(it.value).asText()
|
||||
if (text.isNullOrEmpty()) BigDecimal.ZERO else BigDecimal(text)
|
||||
} catch(t: Throwable) {
|
||||
null
|
||||
}
|
||||
}.toMap()
|
||||
|
||||
private fun readObject(url: String, attempts: Int = 3): JsonNode {
|
||||
println("Fetching from URL: $url")
|
||||
return retry(times = 10) {
|
||||
Thread.sleep(1000)
|
||||
val json = client.build().target(url).request().get(String::class.java)
|
||||
objectMapper.readTree(json)
|
||||
}
|
||||
}
|
||||
|
||||
private fun sslContext(): SSLContext? {
|
||||
val sslContext = SSLContext.getInstance("SSL")
|
||||
sslContext.init(null, arrayOf(object : X509TrustManager {
|
||||
override fun checkClientTrusted(p0: Array<out X509Certificate>?, p1: String?) = Unit
|
||||
override fun checkServerTrusted(p0: Array<out X509Certificate>?, p1: String?) = Unit
|
||||
override fun getAcceptedIssuers(): Array<out X509Certificate>? = null
|
||||
}), SecureRandom())
|
||||
return sslContext
|
||||
}
|
||||
}
|
||||
|
||||
fun <T> retry(times: Int, total: Int = times, action: () -> T): T = try {
|
||||
Thread.sleep(total.toLong() - times)
|
||||
action()
|
||||
} catch (e: Throwable) {
|
||||
if (times > 0) retry(times - 1, total, action) else throw e
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package org.ethtrader.ticker
|
||||
|
||||
import java.io.File
|
||||
import java.net.CookieHandler
|
||||
import java.net.CookieManager
|
||||
import java.net.CookiePolicy
|
||||
import java.net.URL
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
try {
|
||||
CookieHandler.setDefault(CookieManager(null, java.net.CookiePolicy.ACCEPT_ALL));
|
||||
|
||||
fun getResourceUrl(path: String): URL = URL(args[5] + path)
|
||||
|
||||
val outputDir = File("output")
|
||||
outputDir.mkdirs()
|
||||
val config = TickerEthConfig(::getResourceUrl)
|
||||
val reader = DataReader(config.providers)
|
||||
|
||||
val tickerFactory = TickerFactory(outputDir, ::getResourceUrl)
|
||||
val client = OAuthClient(config.reddit.apiUrl, AuthInfo(args[1], args[2], args[3], args[4]))
|
||||
val reddit = Reddit(client, args[0])
|
||||
val test = args.contains("--test")
|
||||
|
||||
while (true) {
|
||||
|
||||
val data = reader.read(config.dataPoints)
|
||||
config.tickers.entries.forEach {
|
||||
try {
|
||||
val (name, tickerConfig) = it
|
||||
val ticker = tickerFactory.produceTicker(name, data, tickerConfig)
|
||||
if (!test) reddit.uploadImage(name, ticker)
|
||||
} catch (e: Exception) {
|
||||
println("ERROR: " + e.message)
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
val oldCss = reddit.getStylesheet()
|
||||
File(outputDir, "old.css").writeTextAndLog(oldCss, "Wrote old css to %s")
|
||||
val newCss = tickerFactory.getAndResetCss(oldCss)
|
||||
File(outputDir, "new.css").writeTextAndLog(newCss, "Wrote new css to %s")
|
||||
|
||||
if (!test) reddit.uploadStylesheet(newCss)
|
||||
} catch (e: Throwable) {
|
||||
println("Error trying to update tickers")
|
||||
e.printStackTrace()
|
||||
}
|
||||
println("Waiting ${config.reddit.frequencySeconds} seconds.")
|
||||
Thread.sleep(TimeUnit.SECONDS.toMillis(config.reddit.frequencySeconds))
|
||||
if (test) break;
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
System.err.println("Usage: <<subreddit>> <<username>> <<password>> <<clientId>> <<secret>> <<resourceUrl>>")
|
||||
System.err.println("clientId and secret can be administered here: https://www.reddit.com/prefs/apps/")
|
||||
System.err.println("resourceUrl points to the configuration/resources to build the ticker.")
|
||||
System.err.println("\n Error: ${e.message}")
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
fun File.writeTextAndLog(text: String, message: String) {
|
||||
writeText(text)
|
||||
println(message.format(absolutePath))
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package org.ethtrader.ticker
|
||||
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.databind.PropertyNamingStrategy.SNAKE_CASE
|
||||
import com.fasterxml.jackson.jaxrs.json.JacksonJaxbJsonProvider
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import com.fasterxml.jackson.module.kotlin.registerKotlinModule
|
||||
import org.glassfish.jersey.client.authentication.HttpAuthenticationFeature.basicBuilder
|
||||
import org.glassfish.jersey.media.multipart.MultiPartFeature
|
||||
import java.io.Serializable
|
||||
import java.time.Instant.now
|
||||
import javax.ws.rs.client.ClientBuilder.newBuilder
|
||||
import javax.ws.rs.client.Entity
|
||||
import javax.ws.rs.client.Invocation
|
||||
import javax.ws.rs.core.HttpHeaders.AUTHORIZATION
|
||||
import javax.ws.rs.core.HttpHeaders.USER_AGENT
|
||||
import javax.ws.rs.core.MediaType
|
||||
|
||||
data class AuthInfo(val username: String, val password: String, val clientId: String, val secret: String) : Serializable
|
||||
data class AuthData(val accessToken: String?, val expiresIn: Int, val scope: String?, val tokenType: String?)
|
||||
|
||||
class OAuthClient(private val apiUrl: String, private val authInfo: AuthInfo) {
|
||||
|
||||
private var authData: AuthData? = null
|
||||
private var timeTokenAcquired = -1L
|
||||
|
||||
private val objectMapper = ObjectMapper()
|
||||
.registerKotlinModule()
|
||||
.setPropertyNamingStrategy(SNAKE_CASE)
|
||||
.disable(FAIL_ON_UNKNOWN_PROPERTIES)
|
||||
|
||||
private val client = newBuilder()
|
||||
.register(basicBuilder().credentials(authInfo.clientId, authInfo.secret).build())
|
||||
.register(JacksonJaxbJsonProvider(objectMapper, emptyArray()))
|
||||
.register(MultiPartFeature())
|
||||
.build()
|
||||
|
||||
fun renewAccessToken() {
|
||||
if (!newTokenNeeded()) return
|
||||
val post = client
|
||||
.target("https://www.reddit.com/api/v1/")
|
||||
.queryParam("grant_type", "password")
|
||||
.queryParam("username", authInfo.username)
|
||||
.queryParam("password", authInfo.password)
|
||||
.path("access_token")
|
||||
.request()
|
||||
.post(Entity.text(""))
|
||||
authData = objectMapper.readValue(post.readEntity(String::class.java))
|
||||
timeTokenAcquired = now().epochSecond
|
||||
}
|
||||
|
||||
private fun newTokenNeeded(): Boolean {
|
||||
val authData = authData
|
||||
return authData == null || now().epochSecond >= timeTokenAcquired + authData.expiresIn - 180
|
||||
}
|
||||
|
||||
fun requestApi(path: String, vararg urlParameters: Pair<String, Any?>): Invocation.Builder {
|
||||
return request(apiUrl, path, urlParameters, MediaType.APPLICATION_JSON_TYPE)
|
||||
.header(AUTHORIZATION, "bearer " + authData?.accessToken)
|
||||
}
|
||||
|
||||
fun requestPlain(path: String, vararg urlParameters: Pair<String, Any?>): Invocation.Builder {
|
||||
return request("https://www.reddit.com/", path, urlParameters, MediaType.WILDCARD_TYPE)
|
||||
}
|
||||
|
||||
private fun request(baseUrl: String, path: String, urlParameters: Array<out Pair<String, Any?>>, mediaType: MediaType?): Invocation.Builder {
|
||||
println("Calling $path, parameters: ${if (urlParameters.isEmpty()) "" else "\n - "}${urlParameters.filter { it.second != null }.joinToString("\n - ")}")
|
||||
renewAccessToken()
|
||||
return urlParameters.filter { it.second != null }.fold(client.target(baseUrl).path(path)) { q, it -> q.queryParam(it.first, it.second) }
|
||||
.request()
|
||||
.accept(mediaType)
|
||||
.header(USER_AGENT, "kotlin:org.ethtrader:0.1 (by /u/etherboard)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package org.ethtrader.ticker
|
||||
|
||||
import org.jsoup.Jsoup
|
||||
import java.io.InputStream
|
||||
|
||||
class Reddit(private val client: OAuthClient, val subRedditName: String) {
|
||||
|
||||
fun uploadImage(name: String, ticker: InputStream) {
|
||||
val response = client.postRSubredditUpload_sr_img(subRedditName,
|
||||
"file" to ticker,
|
||||
"name" to name,
|
||||
"upload_type" to "img")
|
||||
println("Response: " + response)
|
||||
}
|
||||
|
||||
fun uploadStylesheet(css: String) {
|
||||
val response = client.postRSubredditSubreddit_stylesheet(subRedditName,
|
||||
"stylesheet_contents" to css,
|
||||
"op" to "save")
|
||||
println("Response: " + response)
|
||||
}
|
||||
|
||||
fun getStylesheet(): String = retry(3) {
|
||||
val cssPage = client.requestPlain("/r/$subRedditName/about/stylesheet/").get(String::class.java)
|
||||
val document = Jsoup.parse(cssPage)
|
||||
document.select("pre.subreddit-stylesheet-source > code").text()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package org.ethtrader.ticker
|
||||
|
||||
import java.awt.*
|
||||
import java.awt.RenderingHints.VALUE_ANTIALIAS_ON
|
||||
import java.awt.image.BufferedImage
|
||||
import java.awt.image.BufferedImage.TYPE_INT_ARGB
|
||||
import java.io.*
|
||||
import java.math.BigDecimal
|
||||
import java.math.BigDecimal.ZERO
|
||||
import java.math.RoundingMode.HALF_DOWN
|
||||
import java.net.URL
|
||||
import java.util.Locale.ENGLISH
|
||||
import javax.imageio.ImageIO
|
||||
|
||||
class TickerFactory(private val outputDir: File, private val loadResource: (String) -> URL) {
|
||||
|
||||
val apiStart = "/** Generated by ticker bot [start of section] */"
|
||||
val apiEnd = "/** Generated by ticker bot [end of section] */"
|
||||
|
||||
private val css = StringBuilder()
|
||||
|
||||
init {
|
||||
System.setProperty("java.awt.headless", "true")
|
||||
resetCss()
|
||||
}
|
||||
|
||||
fun produceTicker(name: String, data: Map<String, Map<String, BigDecimal?>?>, config: TickerConf): InputStream {
|
||||
|
||||
val font = Font(config.font, Font.BOLD, config.fontHeight)
|
||||
val graphics: Graphics = BufferedImage(1, 1, TYPE_INT_ARGB).graphics.fluently { it.font = font }
|
||||
|
||||
val images = config.segments.map {
|
||||
try {
|
||||
if (it.startsWith("[[") && it.endsWith("]]")) {
|
||||
loadImage(config, it.substring(2, it.length - 2))
|
||||
} else {
|
||||
produceTickerSegment(data, config, it, graphics.fontMetrics)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
System.err.println(e.message)
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
}.filterNotNull()
|
||||
val image = combineImages(images, config)
|
||||
val file = File(outputDir, "$name.png")
|
||||
ImageIO.write(image, "png", file)
|
||||
println("Ticker [$name] saved to [${file.absolutePath}]")
|
||||
addCss(name, config, image)
|
||||
return getInputStream(image)
|
||||
}
|
||||
|
||||
private fun loadImage(config: TickerConf, location: String): BufferedImage {
|
||||
val image = ImageIO.read(loadResource(location.trimStart('!')))
|
||||
val imageWithBackground = BufferedImage(image.width, image.height, TYPE_INT_ARGB)
|
||||
val bold = location.startsWith("!")
|
||||
(imageWithBackground.graphics as Graphics2D)
|
||||
.fluently { it.color = if (bold) config.backgroundHighlight else config.background }
|
||||
.fluently { it.fillRect(0, 0, image.width, image.height) }
|
||||
.fluently { it.drawImage(image, 0, 0, null) }
|
||||
.fluently { it.drawImage(image, 0, 0, null) }
|
||||
.fluently { it.drawImage(image, 0, 0, null) }
|
||||
.fluently { it.drawImage(image, 0, 0, null) }
|
||||
return imageWithBackground
|
||||
}
|
||||
|
||||
private fun addCss(name: String, conf: TickerConf, image: BufferedImage) {
|
||||
val template = loadResource("/per-ticker.css").readText(Charsets.UTF_8)
|
||||
.replace("\${ticker.name}", name)
|
||||
.replace("\${ticker.width}", image.width.toString())
|
||||
.replace("\${ticker.top}", conf.top)
|
||||
.replace("\${css.selector}", conf.cssSelector)
|
||||
css.append("$template\n\n")
|
||||
}
|
||||
|
||||
private fun combineImages(images: List<BufferedImage>, tickerConf: TickerConf): BufferedImage {
|
||||
var cumulative = 0
|
||||
val image = BufferedImage(images.sumBy { it.width }, images.first().height, TYPE_INT_ARGB)
|
||||
val graphics = image.graphics
|
||||
images.forEach { image ->
|
||||
graphics.drawImage(image, cumulative, 0, null)
|
||||
cumulative += image.width
|
||||
}
|
||||
return image
|
||||
}
|
||||
|
||||
private fun produceTickerSegment(data: Map<String, Map<String, BigDecimal?>?>, config: TickerConf, it: String, fontMetrics: FontMetrics): BufferedImage {
|
||||
val segments = parseSegments(data, config, it, fontMetrics)
|
||||
return render(config, segments, fontMetrics)
|
||||
}
|
||||
|
||||
private fun parseSegments(data: Map<String, Map<String, BigDecimal?>?>, config: TickerConf, it: String, fontMetrics: FontMetrics): List<Triple<String, Color, Int>> {
|
||||
return it.split(Regex("[\\{\\}]")).map { match ->
|
||||
val tokens = match.trim('{', '}').split('|', '.')
|
||||
if (tokens.size == 8) format(data, tokens, config) else match to config.color
|
||||
}.map { Triple(it.first, it.second, fontMetrics.stringWidth(it.first)) }
|
||||
}
|
||||
|
||||
private fun render(config: TickerConf, segments: List<Triple<String, Color, Int>>, fontMetrics: FontMetrics): BufferedImage {
|
||||
val image = BufferedImage(segments.sumBy { it.third }, fontMetrics.height + 4, TYPE_INT_ARGB)
|
||||
val graphics = image.graphics as Graphics2D
|
||||
val bold = segments.first().first.startsWith("!")
|
||||
graphics.fluently { it.setRenderingHint(RenderingHints.KEY_ANTIALIASING, VALUE_ANTIALIAS_ON) }
|
||||
.fluently { it.color = if (bold) config.backgroundHighlight else config.background }
|
||||
|
||||
var x = 0
|
||||
val weight = if (true) Font.BOLD else Font.PLAIN
|
||||
segments.forEach { segment ->
|
||||
graphics.fluently { it.color = if (bold) config.backgroundHighlight else config.background }
|
||||
.fluently { it.fillRect(x, 0, segment.third, image.height) }
|
||||
.fluently { it.color = segment.second }
|
||||
.fluently { it.font = Font(config.font, weight, config.fontHeight) }
|
||||
.fluently { it.drawString(segment.first.trimStart('!'), x, fontMetrics.ascent + 2) }
|
||||
x += segment.third
|
||||
}
|
||||
return image
|
||||
}
|
||||
|
||||
private fun format(data: Map<String, Map<String, BigDecimal?>?>, tokens: List<String>, config: TickerConf): Pair<String, Color> {
|
||||
val (dataPointNames, valueName, prefix, suffix, precision, useColor, shorten, divisor) = tokens
|
||||
return dataPointNames.split('^').map { dataPointName ->
|
||||
val value = data[dataPointName]?.get(valueName)?.divide(BigDecimal(divisor.toString()))
|
||||
if (value == null) {
|
||||
null
|
||||
} else {
|
||||
val color = if (useColor == "Y") if (value.compareTo(ZERO) >= 0) config.positiveColor else config.negativeColor else config.color
|
||||
val adjustedPrecision = value.setScale(precision.toInt(), HALF_DOWN)
|
||||
val shortenedValue = if (shorten == "Y") shorten(adjustedPrecision, "", "k", "M", "B", "T") else String.format(ENGLISH, "%,.${precision}f", adjustedPrecision)
|
||||
|
||||
("$prefix$shortenedValue$suffix") to color
|
||||
}
|
||||
}.filterNotNull().firstOrNull() ?: throw RuntimeException("ERROR - No value [$valueName] or no data point [$dataPointNames] in [$data]")
|
||||
}
|
||||
|
||||
private val OneThousand = BigDecimal("1000")
|
||||
private val TenThousand = BigDecimal("10000")
|
||||
|
||||
private fun shorten(value: BigDecimal, vararg denomiators: String): String {
|
||||
return if (value < TenThousand) value.toPlainString() + denomiators.first()
|
||||
else shorten(value / OneThousand, *denomiators.drop(1).toTypedArray())
|
||||
}
|
||||
|
||||
private fun getInputStream(upper: BufferedImage): InputStream {
|
||||
val byteArrayOutputStream = ByteArrayOutputStream()
|
||||
ImageIO.write(upper, "png", byteArrayOutputStream)
|
||||
return ByteArrayInputStream(byteArrayOutputStream.toByteArray())
|
||||
}
|
||||
|
||||
fun <T> T.fluently(action: (T) -> Unit): T {
|
||||
action(this)
|
||||
return this
|
||||
}
|
||||
|
||||
fun getAndResetCss(existing: String): String {
|
||||
val existingLines = existing.lines()
|
||||
val newLines = css.lines()
|
||||
resetCss()
|
||||
return (existingLines.takeWhile { !it.equals(apiStart) } +
|
||||
"$apiStart\n\n" +
|
||||
newLines +
|
||||
if (existingLines.contains(apiEnd)) existingLines.dropWhile { !it.equals(apiEnd) } else listOf(apiEnd)).joinToString(System.lineSeparator())
|
||||
}
|
||||
|
||||
private fun resetCss() {
|
||||
css.setLength(0)
|
||||
css.append(loadResource("/ticker.css").readText(Charsets.UTF_8)).append("\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
operator fun <E> List<E>.component6(): Any = get(5)!!
|
||||
operator fun <E> List<E>.component7(): Any = get(6)!!
|
||||
operator fun <E> List<E>.component8(): Any = get(7)!!
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"symbol": "eth",
|
||||
"position": "2",
|
||||
"name": "Ethereum",
|
||||
"market_cap": {
|
||||
"usd": 984329687.973,
|
||||
"eur": 882254699.3301998,
|
||||
"cny": 6576649192.079028,
|
||||
"gbp": 740383261.4026514,
|
||||
"cad": 1278612766.1269119,
|
||||
"rub": 64165323174.881805,
|
||||
"hkd": 7633972832.393353,
|
||||
"jpy": 102314377622.91515,
|
||||
"aud": 1299426417.379101,
|
||||
"btc": 1614129.70563
|
||||
},
|
||||
"price": {
|
||||
"usd": 11.7671,
|
||||
"eur": 10.546851729999998,
|
||||
"cny": 78.6200900508,
|
||||
"gbp": 8.850859607,
|
||||
"cad": 15.285086352799999,
|
||||
"rub": 767.0598413890999,
|
||||
"hkd": 91.25979111839999,
|
||||
"jpy": 1223.11002872,
|
||||
"aud": 15.5339016823,
|
||||
"btc": 0.019296
|
||||
},
|
||||
"supply": "83651000",
|
||||
"volume": {
|
||||
"usd": 9764720,
|
||||
"eur": 8752118.536,
|
||||
"cny": 65241492.44256,
|
||||
"gbp": 7344729.4424,
|
||||
"cad": 12684058.80896,
|
||||
"rub": 636531054.75512,
|
||||
"hkd": 75730325.01888,
|
||||
"jpy": 1014976243.904,
|
||||
"aud": 12890533.81336,
|
||||
"btc": 16012.4
|
||||
},
|
||||
"change": "1.63",
|
||||
"timestamp": "1473015486.242"
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package com.ethtrader.ticker
|
||||
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.examples.HtmlToPlainText
|
||||
import org.jsoup.nodes.Element
|
||||
import org.jsoup.nodes.Node
|
||||
import org.jsoup.nodes.TextNode
|
||||
import org.junit.Test
|
||||
import java.io.BufferedWriter
|
||||
import java.io.File
|
||||
import java.util.*
|
||||
import kotlin.text.RegexOption.MULTILINE
|
||||
|
||||
class RedditApiGeneration {
|
||||
|
||||
private val url = "https://www.reddit.com/dev/api"
|
||||
private val apiStart = "// Generated API start."
|
||||
private val apiEnd = "// Generated API end."
|
||||
private val outputFile = File("src/main/java/${javaClass.`package`.name.replace(".", "/")}/Api.kt")
|
||||
|
||||
@Test fun generateApi() {
|
||||
// val document = Jsoup.parse(URL(url), TimeUnit.MINUTES.toMillis(1).toInt())
|
||||
val document = Jsoup.parse(javaClass.getResourceAsStream("/api.html"), Charsets.UTF_8.name(), url)
|
||||
val lines = outputFile.readLines()
|
||||
|
||||
val writer = outputFile.bufferedWriter()
|
||||
try {
|
||||
lines.takeWhile { !it.equals(apiStart) }.forEach { writer.write("$it\n") }
|
||||
writer.write("$apiStart\n\n")
|
||||
document.select("div.section.methods > div").forEach { parseEndpoint(writer, it) }
|
||||
writer.write("\n$apiEnd\n\n")
|
||||
} catch(throwable: Throwable) {
|
||||
lines.dropWhile { !it.equals(apiEnd) }.forEach { writer.write("$it\n") }
|
||||
} finally {
|
||||
writer.close()
|
||||
}
|
||||
}
|
||||
|
||||
private val types = mapOf(
|
||||
"comma-separated list" to "List<String>",
|
||||
"comma-delimited list" to "List<String>",
|
||||
"a positive integer" to "Int",
|
||||
"number of" to "Int",
|
||||
"expand subreddits" to "Boolean",
|
||||
"true, false" to "Boolean"
|
||||
).withDefault { "String" }
|
||||
|
||||
private fun parseEndpoint(writer: BufferedWriter, endpointElement: Element) {
|
||||
val queryParameters = parseQueryParameters(endpointElement)
|
||||
val method = parseEndpointAndGetMethod(endpointElement, writer, "get", endpointElement.selectFirst("h3")?.childNodes(), queryParameters)
|
||||
endpointElement.selectFirst("ul.uri-variants")?.childNodes()?.forEach {
|
||||
parseEndpointAndGetMethod(endpointElement, writer, method, it.childNodes(), queryParameters)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseEndpointAndGetMethod(endpointElement: Element, writer: BufferedWriter, previousMetho: String, urlElements: List<Node>?, urlParams: Map<String, Param>): String {
|
||||
|
||||
val (method, pathParams, url) = parseUrl(previousMetho, urlElements)
|
||||
val get = method == "get"
|
||||
|
||||
val urlParamsAndType = urlParams.filterNot { pathParams.contains(it.value.name) }.map { it.value.parameterName to it.value.type + "?" }
|
||||
val pathParamsAndType = pathParams.map { it to (urlParams.get(it)?.type ?: "String") }
|
||||
|
||||
val urlParamsPart = if (get) urlParamsAndType.map { "${it.first}: ${it.second}" } else listOf("vararg urlParams: Pair<String, Any?>")
|
||||
|
||||
val methodParams = (pathParamsAndType.map { "${it.first}: ${it.second}" } + urlParamsPart).joinToString(", ")
|
||||
|
||||
val paramsString = if (!get || urlParams.isEmpty()) "" else urlParams.map { "\n \"${it.key}\" to ${format(it.value)}" }.joinToString(", ")
|
||||
val text = HtmlToPlainText().getPlainText(endpointElement).replace("\n\n", "\n - ").replace(Regex("\n - $", MULTILINE), "")
|
||||
|
||||
val requestMethod = when (method) {
|
||||
"get" -> "get()"
|
||||
"post" -> "post(urlParams.multipart())"
|
||||
else -> "method(\"$method\")"
|
||||
}
|
||||
|
||||
val name = method + url.replaceFirst("/api", "").replace("$", "").split("/", "[", "]").map { it.capitalize() }.joinToString ("")
|
||||
val readResponse = if (url.startsWith("/api")) "getResponse()" else "readEntity(String::class.java)"
|
||||
|
||||
if (name.isNotEmpty()) {
|
||||
if (get) {
|
||||
writer.write("""
|
||||
/*
|
||||
* ${text.lines().joinToString("\n * ")}
|
||||
**/
|
||||
fun OAuthClient.$name($methodParams) =
|
||||
retry(3) { requestApi("$url"${if (paramsString.isEmpty()) "" else ", " + paramsString}).$requestMethod.$readResponse }
|
||||
""")
|
||||
} else {
|
||||
writer.write("""
|
||||
/*
|
||||
* ${text.lines().joinToString("\n * ")}
|
||||
**/
|
||||
fun OAuthClient.$name($methodParams) =
|
||||
retry(3) { requestApi("$url").$requestMethod.$readResponse }
|
||||
""")
|
||||
}
|
||||
}
|
||||
return method
|
||||
}
|
||||
|
||||
private fun parseUrl(originalMethod: String, urlNodes: List<Node>?): Triple<String, List<String>, String> {
|
||||
var method = originalMethod
|
||||
var queryParameters = listOf<String>()
|
||||
var url = ""
|
||||
urlNodes?.forEach {
|
||||
when (it) {
|
||||
is TextNode -> url += it.text().replace("[", "").replace("]", "")
|
||||
is Element -> {
|
||||
it.useText("span.method") { method = it }
|
||||
it.useText("em.placeholder") { url += "$$it" }
|
||||
it.useText("em.placeholder") { queryParameters += it }
|
||||
}
|
||||
}
|
||||
}
|
||||
return Triple(method.toLowerCase(Locale.ENGLISH), queryParameters, url.replace("→ ", ""))
|
||||
}
|
||||
|
||||
private fun parseQueryParameters(endpointElement: Element): Map<String, Param> {
|
||||
val select = endpointElement.selectFirst("div.info")?.children()?.select("table.parameters > tbody > tr")
|
||||
return select?.map { row -> row.selectFirst("td > p")?.parseParameter(row) }?.filterNotNull()?.toMap() ?: mapOf()
|
||||
}
|
||||
|
||||
private fun Element.parseParameter(row: Element): Pair<String, Param> {
|
||||
val text = text().split("(", ")").map { it.trim() }
|
||||
val name = row.selectFirst("th")!!.text().split(" ").first()
|
||||
val type = types.entries.firstOrNull { text.any { t -> t.contains(it.key) } }?.value ?: "String"
|
||||
val parameterName = name.split('|', '_', '-').map { it.capitalize() }.joinToString("").decapitalize()
|
||||
val optional = text.contains("default:") || text.contains("optional")
|
||||
return name to Param(name, optional, type, parameterName)
|
||||
}
|
||||
|
||||
private fun format(it: Param) = when (it.type) {
|
||||
"List<String>" -> it.parameterName + ".csv()"
|
||||
else -> it.parameterName
|
||||
}
|
||||
|
||||
private fun Element.useText(selector: String, action: (String) -> Unit) {
|
||||
val text = select(selector)?.text()?.trim()
|
||||
if (text != null && text != "") action(text)
|
||||
}
|
||||
|
||||
data class Param(val name: String, val optional: Boolean, val type: String, val parameterName: String)
|
||||
|
||||
fun Element.selectFirst(selector: String): Element? =
|
||||
select(selector).first()
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user