mirror of
https://github.com/jwinterm/ethtrader-ticker.git
synced 2026-10-19 05:58:51 +02:00
Initial commit.
This commit is contained in:
@@ -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