Hexoのdb.jsonを読んでblog記事のURLとタイトルをLemmyに投稿するコード
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import io.ktor.client.* | |
import io.ktor.client.call.* | |
import io.ktor.client.features.* | |
import io.ktor.client.request.* | |
import io.ktor.client.statement.* | |
import io.ktor.http.* | |
import kotlinx.cli.ArgParser | |
import kotlinx.cli.ArgType | |
import kotlinx.cli.default | |
import kotlinx.cli.required | |
import kotlinx.coroutines.runBlocking | |
import java.io.File | |
import java.io.FileInputStream | |
import java.text.SimpleDateFormat | |
val parser = ArgParser("HexoToLemmy") | |
val verbose by parser.option(ArgType.Boolean, shortName = "v", description = "verbose logging").default(false) | |
val hexoDbFile by parser.option(ArgType.String, shortName = "i", description = "file path of hexo's db.json").required() | |
val hexoUrlPrefix by parser.option(ArgType.String, shortName = "p", description = "url prefix of hexo blog").required() | |
val lemmyUser by parser.option(ArgType.String, fullName = "lemmyUser", description = "lemmy account login user").required() | |
val lemmyPassword by parser.option(ArgType.String, shortName = "lemmyPassword", description = "lemmy account login password").required() | |
private const val lemmyUrlPrefix = "https://lemmy.juggler.jp/api/v2" | |
private const val lemmyCommunityName = "bottom_audio" | |
////////////////////////////////////////////////////////////////////// | |
private var lemmyAuth = "" | |
private var lemmyCommunityId = -1L | |
// | |
private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ") | |
private val Zto0000 = """Z\z""".toRegex() | |
fun String.parseTime() = dateFormat.parse(this.replace(Zto0000, "+0000")).time | |
fun JsonObject.encodeQuery() = | |
entries.joinToString("&") { it.key.encodePercent() + "=" + it.value.toString().encodePercent() } | |
/* | |
GETの場合rate limit は 503 になる | |
POSTの場合 5xx { "error" : "..." } になる | |
*/ | |
suspend fun HttpClient.lemmyApi( | |
method: HttpMethod, | |
endpoint: String, | |
form: JsonObject? = null, | |
): String { | |
if (method == HttpMethod.Get) { | |
val url = "$lemmyUrlPrefix$endpoint" + if (form == null) { | |
"" | |
} else { | |
if (lemmyAuth.isNotEmpty()) form["auth"] = lemmyAuth | |
"?${form.encodeQuery()}".also { form.remove("auth") } | |
} | |
get<HttpResponse>(url) { | |
}.let { res -> | |
when (res.status) { | |
HttpStatusCode.OK -> { | |
return res.receive<ByteArray>().decodeUtf8() | |
} | |
else -> error("lemmyApi failed. ${res.status} $method $url") | |
} | |
} | |
} else { | |
val url = "$lemmyUrlPrefix$endpoint" | |
request<HttpResponse>(url) { | |
this.method = method | |
if (form != null) { | |
header("Content-Type", "application/json") | |
if (lemmyAuth.isNotEmpty()) form["auth"] = lemmyAuth | |
this.body = form.toString().encodeUtf8() | |
} | |
}.let { res -> | |
form?.remove("auth") | |
when (res.status) { | |
HttpStatusCode.OK -> { | |
return res.receive<ByteArray>().decodeUtf8() | |
} | |
else -> { | |
var content = try { | |
res.receive<ByteArray>().decodeUtf8() | |
} catch (_: Throwable) { | |
null | |
} | |
try{ | |
content?.decodeJsonObject()?.string("error")?.notEmpty()?.let{ content = it} | |
}catch (_: Throwable) { | |
} | |
error("lemmyApi failed. ${res.status} $method $url $content") | |
} | |
} | |
} | |
} | |
} | |
suspend fun HttpClient.postToLemmy(blogUrl: String, blogTitle: String) { | |
if (lemmyAuth.isEmpty()) { | |
lemmyAuth = lemmyApi(HttpMethod.Post, | |
"/user/login", | |
jsonObject("username_or_email" to lemmyUser, "password" to lemmyPassword)) | |
.decodeJsonObject() | |
.string("jwt") | |
?: error("missing jwt in LoginResponse") | |
println("lemmy login succeeded.") | |
} | |
// 投稿先のコミュニティーIDを知りたい | |
if (lemmyCommunityId < 0L) { | |
lemmyCommunityId = lemmyApi(HttpMethod.Get, "/community", jsonObject("name" to lemmyCommunityName)) | |
.decodeJsonObject().let { root -> | |
root.jsonObject("community_view")?.jsonObject("community")?.long("id") | |
?: error("can't get community id. $root") | |
} | |
println("community: $lemmyCommunityId $lemmyCommunityName") | |
} | |
// WebUIではWebSocketで処理されている | |
// {"op":"Search","data":{"q":"https://www.victor.jp/headphones/in-ear/ha-fw10000/","type_":"Url","sort":"TopAll","page":1,"limit":6,"auth":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6MywiaXNzIjoibGVtbXkuanVnZ2xlci5qcCJ9.fZlkzOwQhZcbLsBBB2E8qwGk4HvSnDKuZB2WRqWhWLQ"}} | |
lemmyApi( | |
HttpMethod.Get, | |
"/search", | |
jsonObject( | |
"q" to blogUrl, | |
"type_" to "Url", | |
"sort" to "TopAll", | |
"page" to 1, | |
"limit" to 3 | |
) | |
).decodeJsonObject().let { root -> | |
val oldPost = root.jsonArray("posts")?.objectList() | |
?.find { it.jsonObject("community")?.string("name") == lemmyCommunityName } | |
if (oldPost != null) { | |
println("$blogUrl already exists. ${oldPost.jsonObject("post")?.string("ap_id")}") | |
return | |
} | |
} | |
val lemmyUrl = lemmyApi(HttpMethod.Post, "/post", jsonObject( | |
"name" to blogTitle, | |
"url" to blogUrl, | |
"nsfw" to false, | |
"community_id" to lemmyCommunityId | |
)).decodeJsonObject().let { root -> | |
root.jsonObject("post_view")?.jsonObject("post")?.string("ap_id") | |
?: error("can't get lemmyUrl. $root") | |
} | |
println("$lemmyUrl created.") | |
} | |
data class BlogPost( | |
val url: String, | |
val title: String, | |
val time: Long, | |
) | |
suspend fun HttpClient.mainImpl() { | |
val urlPrefix = if (hexoUrlPrefix.endsWith("/")) { | |
hexoUrlPrefix | |
} else { | |
"$hexoUrlPrefix/" | |
} | |
val root = FileInputStream(File(hexoDbFile)).use { it.readBytes() }.toString(Charsets.UTF_8).decodeJsonObject() | |
val posts = root.jsonObject("models")?.jsonArray("Post")?.objectList() | |
?: error("can't find models.Post") | |
println("${posts.size} posts found.") | |
posts.mapNotNull { post -> | |
val title = post.string("title") | |
val url = "$urlPrefix${post.string("slug")}" | |
val time = post.string("date")?.parseTime() ?: error("date is null") | |
if (post.boolean("published") != true) { | |
println("$url is not published.") | |
null | |
} else { | |
BlogPost(url = url, title = title ?: url, time = time) | |
} | |
} | |
.sortedBy { it.time } | |
.forEach { post -> | |
get<HttpResponse>(post.url) { | |
// header(it.key, it.value) | |
}.let { res -> | |
when (res.status) { | |
HttpStatusCode.OK -> { | |
println("${post.url} ${post.title}") | |
postToLemmy(post.url, post.title) | |
} | |
else -> error("get failed. ${res.status} ${post.url}") | |
} | |
} | |
} | |
} | |
fun main(args: Array<String>) = runBlocking { | |
parser.parse(args) | |
HttpClient { | |
// エラーレスポンスで例外を出さない | |
expectSuccess = false | |
install(HttpTimeout) { | |
val t = 30000L | |
requestTimeoutMillis = t | |
connectTimeoutMillis = t | |
socketTimeoutMillis = t | |
} | |
}.use { client -> | |
client.mainImpl() | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment