Skip to content

Commit 0c7148c

Browse files
committed
feat(Autoscout): fetch listing details
1 parent fdcca5a commit 0c7148c

File tree

6 files changed

+200
-0
lines changed

6 files changed

+200
-0
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
version = "0.1.0"
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package me.snoty.integration.contrib.autoscout.api
2+
3+
import io.ktor.client.*
4+
import io.ktor.client.request.*
5+
import kotlinx.serialization.json.Json
6+
import kotlinx.serialization.json.jsonObject
7+
import me.snoty.integration.contrib.autoscout.model.AutoscoutListing
8+
import me.snoty.integration.contrib.autoscout.model.parseListing
9+
import me.snoty.integration.contrib.utils.getOrThrow
10+
import me.snoty.integration.contrib.utils.parseNextPageProps
11+
import org.koin.core.annotation.Single
12+
import org.koin.core.component.KoinComponent
13+
14+
interface AutoscoutAPI {
15+
suspend fun fetchListing(url: String): AutoscoutListing?
16+
}
17+
18+
@Single
19+
class AutoscoutAPIImpl(private val httpClient: HttpClient, private val json: Json) : AutoscoutAPI, KoinComponent {
20+
override suspend fun fetchListing(url: String): AutoscoutListing? {
21+
val mappedUrl = parseAutoscoutUrl(url)
22+
23+
val response = httpClient.get(mappedUrl)
24+
25+
val props = response.parseNextPageProps(json)
26+
val advertDetails = props.getOrThrow("listingDetails").jsonObject
27+
28+
return advertDetails.parseListing()
29+
}
30+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package me.snoty.integration.contrib.autoscout.api
2+
import io.ktor.http.*
3+
4+
// yes, this is subject to "different TLD" attacks, but that's fine since we're not exposing anything but our own IP
5+
val AUTOSCOUT_HOST = "^(www\\.)?autoscout24.[a-z]+$".toRegex()
6+
7+
fun parseAutoscoutUrl(url: String): Url {
8+
val mappedUrl = parseUrl(url) ?: throw IllegalArgumentException("Invalid URL: $url")
9+
if (!AUTOSCOUT_HOST.matches(mappedUrl.host)) throw IllegalArgumentException("Not an autoscout URL: $url")
10+
return mappedUrl
11+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package me.snoty.integration.contrib.autoscout.listing
2+
3+
import kotlinx.serialization.Serializable
4+
import me.snoty.integration.common.annotation.RegisterNode
5+
import me.snoty.integration.common.model.NodePosition
6+
import me.snoty.integration.common.wiring.Node
7+
import me.snoty.integration.common.wiring.NodeHandleContext
8+
import me.snoty.integration.common.wiring.data.IntermediateData
9+
import me.snoty.integration.common.wiring.data.NodeOutput
10+
import me.snoty.integration.common.wiring.data.impl.SimpleIntermediateData
11+
import me.snoty.integration.common.wiring.get
12+
import me.snoty.integration.common.wiring.iterableStructOutput
13+
import me.snoty.integration.common.wiring.node.NodeHandler
14+
import me.snoty.integration.common.wiring.node.NodeSettings
15+
import me.snoty.integration.contrib.autoscout.api.AutoscoutAPI
16+
import me.snoty.integration.contrib.autoscout.model.AutoscoutListing
17+
import org.koin.core.annotation.Single
18+
19+
data class ListingInput(
20+
val url: String
21+
)
22+
23+
@Serializable
24+
data class AutoscoutListingInput(
25+
override val name: String = "Autoscout Listing",
26+
val listings: List<String>
27+
) : NodeSettings
28+
29+
@RegisterNode(
30+
name = "autoscout_listing",
31+
displayName = "Autoscout Listing",
32+
position = NodePosition.START,
33+
settingsType = AutoscoutListingInput::class,
34+
inputType = ListingInput::class,
35+
outputType = AutoscoutListing::class,
36+
)
37+
@Single
38+
class AutoscoutListingNodeHandler(private val autoscoutAPI: AutoscoutAPI) : NodeHandler {
39+
override suspend fun NodeHandleContext.process(node: Node, input: Collection<IntermediateData>): NodeOutput {
40+
val settings = node.settings as AutoscoutListingInput
41+
42+
val mappedFromInput = input.mapNotNull {
43+
// this node is also a start node, so the input may be the job context, in which case it is not parsed and used to fetch listings
44+
if (it is SimpleIntermediateData) return@mapNotNull null
45+
46+
val data = get<ListingInput>(it)
47+
autoscoutAPI.fetchListing(data.url)
48+
}
49+
50+
val mappedFromSettings = settings.listings.mapNotNull {
51+
autoscoutAPI.fetchListing(it)
52+
}
53+
54+
return iterableStructOutput(mappedFromInput + mappedFromSettings)
55+
}
56+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package me.snoty.integration.contrib.autoscout.model
2+
3+
import kotlinx.serialization.json.JsonObject
4+
import kotlinx.serialization.json.double
5+
import kotlinx.serialization.json.jsonObject
6+
import kotlinx.serialization.json.jsonPrimitive
7+
import me.snoty.integration.contrib.utils.getOrThrow
8+
9+
data class AutoscoutListing(
10+
val id: String,
11+
val model: String,
12+
val description: String,
13+
val price: Double,
14+
val seller: AutoscoutSeller,
15+
val location: AutoscoutLocation,
16+
)
17+
18+
fun JsonObject.parseListing(): AutoscoutListing {
19+
val id = getOrThrow("id").jsonPrimitive.content
20+
val model = getOrThrow("vehicle").jsonObject.getOrThrow("modelVersionInput").jsonPrimitive.content
21+
val description = getOrThrow("description").jsonPrimitive.content
22+
val price = getOrThrow("prices").jsonObject
23+
.getOrThrow("public").jsonObject
24+
.getOrThrow("priceRaw").jsonPrimitive.double
25+
26+
val seller = getOrThrow("seller").jsonObject.parseSeller()
27+
val location = getOrThrow("location").jsonObject.parseLocation()
28+
29+
return AutoscoutListing(
30+
id = id,
31+
model = model,
32+
description = description,
33+
price = price,
34+
seller = seller,
35+
location = location,
36+
)
37+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package me.snoty.integration.contrib.autoscout.model
2+
3+
import kotlinx.serialization.json.JsonObject
4+
import kotlinx.serialization.json.contentOrNull
5+
import kotlinx.serialization.json.double
6+
import kotlinx.serialization.json.jsonArray
7+
import kotlinx.serialization.json.jsonObject
8+
import kotlinx.serialization.json.jsonPrimitive
9+
import me.snoty.integration.common.model.metadata.FieldDescription
10+
11+
data class AutoscoutSeller(
12+
val contactName: String?,
13+
val phones: List<Phone>
14+
)
15+
16+
data class Phone(
17+
val phoneType: String,
18+
@FieldDescription("The number, in a pretty human-readable format")
19+
val formattedNumber: String,
20+
@FieldDescription("A callable number without any formatting")
21+
val callTo: String
22+
)
23+
24+
fun JsonObject.parseSeller(): AutoscoutSeller {
25+
val contactName = this["contactName"]?.jsonPrimitive?.contentOrNull
26+
val phones = this["phones"]?.jsonArray?.map { phone ->
27+
Phone(
28+
phoneType = phone.jsonObject["phoneType"]!!.jsonPrimitive.content,
29+
formattedNumber = phone.jsonObject["formattedNumber"]!!.jsonPrimitive.content,
30+
callTo = phone.jsonObject["callTo"]!!.jsonPrimitive.content
31+
)
32+
} ?: emptyList()
33+
34+
return AutoscoutSeller(
35+
contactName = contactName,
36+
phones = phones
37+
)
38+
}
39+
40+
data class AutoscoutLocation(
41+
val countryCode: String,
42+
val zip: String?,
43+
val city: String?,
44+
val street: String?,
45+
val latitude: Double,
46+
val longitude: Double,
47+
)
48+
49+
fun JsonObject.parseLocation(): AutoscoutLocation {
50+
val countryCode = this["countryCode"]!!.jsonPrimitive.contentOrNull!!
51+
val zip = this["zip"]?.jsonPrimitive?.contentOrNull
52+
val city = this["city"]?.jsonPrimitive?.contentOrNull
53+
val street = this["street"]?.jsonPrimitive?.contentOrNull
54+
val latitude = this["latitude"]!!.jsonPrimitive.double
55+
val longitude = this["longitude"]!!.jsonPrimitive.double
56+
57+
return AutoscoutLocation(
58+
countryCode = countryCode,
59+
zip = zip,
60+
city = city,
61+
street = street,
62+
latitude = latitude,
63+
longitude = longitude
64+
)
65+
}

0 commit comments

Comments
 (0)