Initial commit.

This commit is contained in:
jay
2016-09-27 11:16:23 +01:00
parent 61ee23c0f2
commit 77941e2ce0
15 changed files with 6532 additions and 0 deletions
+70
View File
@@ -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
+37
View File
@@ -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.
+164
View File
@@ -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>
+25
View File
@@ -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>
+10
View File
@@ -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)!!
+44
View File
@@ -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