diff --git a/README.md b/README.md
index 3e5eb0a..3723bb7 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,5 @@
-## Overview
+
+# funfunnet-crawler
뻔뻔넷 크롤러 입니다.
diff --git a/build.sbt b/build.sbt
index 1bdd22b..eed78dd 100644
--- a/build.sbt
+++ b/build.sbt
@@ -11,11 +11,25 @@ Seq(unmanagedResourceDirectories in Compile += baseDirectory.value / "conf")
libraryDependencies ++= Seq(
"com.typesafe.akka" %% "akka-actor" % Versions.akka,
- "com.typesafe.akka" %% "akka-testkit" % Versions.akka,
+ "com.typesafe.akka" %% "akka-testkit" % Versions.akka % Test,
+ "com.typesafe.akka" %% "akka-http" % Versions.akka_http,
+// "com.typesafe.akka" %% "akka-http-testkit" % Versions.akka_http % Test,
+ "org.jsoup" % "jsoup" % "1.10.3",
+
"com.enragedginger" %% "akka-quartz-scheduler" % "1.6.1-akka-2.5.x",
"com.typesafe" % "config" % "1.2.1",
+
+ "com.typesafe.scala-logging" %% "scala-logging" % "3.7.2",
+ "ch.qos.logback" % "logback-classic" % "1.2.3",
+
+ "org.json4s" %% "json4s-native" % "3.5.3",
+
"org.scalatest" %% "scalatest" % "3.0.1" % "test"
)
+dependencyOverrides ++= Set(
+ // akka-http bug : akka-stream 2.4.9 version을 가져옴
+ "com.typesafe.akka" %% "akka-stream" % Versions.akka
+)
// scalastyle configurations
// test task 수행시, scalastyle에 실패하면 에러를 발생시킴
diff --git a/conf/application.conf b/conf/application.conf
index c46b90e..6d14dfe 100644
--- a/conf/application.conf
+++ b/conf/application.conf
@@ -1,10 +1,9 @@
akka {
quartz {
schedules {
- Every30Seconds {
- description = "A cron job that fires off every 30 seconds"
- expression = "*/30 * * ? * *"
-// calendar = "OnlyBusinessHours"
+ Crawling {
+ description = "A cron job that fires off every 1 min"
+ expression = "0 * * ? * *"
}
}
}
diff --git a/project/Versions.scala b/project/Versions.scala
index e3fd321..be8ded4 100644
--- a/project/Versions.scala
+++ b/project/Versions.scala
@@ -1,4 +1,5 @@
object Versions {
- lazy val scala = "2.12.2"
+ lazy val scala = "2.12.3"
lazy val akka = "2.5.3"
+ lazy val akka_http = "10.0.9"
}
diff --git a/src/main/scala/net/funfunnet/crawler/Application.scala b/src/main/scala/net/funfunnet/crawler/Application.scala
index acf8a3c..e19cbfa 100644
--- a/src/main/scala/net/funfunnet/crawler/Application.scala
+++ b/src/main/scala/net/funfunnet/crawler/Application.scala
@@ -2,22 +2,13 @@ package net.funfunnet.crawler
import akka.actor.{ActorSystem, Props}
import com.typesafe.akka.extension.quartz.QuartzSchedulerExtension
-import scala.concurrent.duration._
+import net.funfunnet.crawler.actor.{Start, Supervisor}
object Application extends App {
val system = ActorSystem("funfunnet-crawler")
-
- import system.dispatcher
-
- val crawlingBySchedulerActor = system.actorOf(Props[CrawlingActor], name = "crawlingBySchedulerActor")
- val crawlingByQuartzActor = system.actorOf(Props[CrawlingActor], name = "crawlingByQuartzActor")
-
-
- //call by scheduler
- val cancellable =
- system.scheduler.schedule(5 seconds, 10 seconds, crawlingBySchedulerActor, "scheduler")
+ val supervisor = system.actorOf(Props[Supervisor], name = "supervisor")
//call by quartz
- QuartzSchedulerExtension(system).schedule("Every30Seconds", crawlingByQuartzActor, "quartz")
+ QuartzSchedulerExtension(system).schedule("Crawling", supervisor, Start)
}
diff --git a/src/main/scala/net/funfunnet/crawler/actor/Messages.scala b/src/main/scala/net/funfunnet/crawler/actor/Messages.scala
new file mode 100644
index 0000000..cf897b8
--- /dev/null
+++ b/src/main/scala/net/funfunnet/crawler/actor/Messages.scala
@@ -0,0 +1,7 @@
+package net.funfunnet.crawler.actor
+
+import net.funfunnet.crawler.model.{Article, SiteSource}
+
+case class Start()
+case class Crawl(siteSource: SiteSource)
+case class Result(article: Article)
diff --git a/src/main/scala/net/funfunnet/crawler/CrawlingActor.scala b/src/main/scala/net/funfunnet/crawler/actor/Processor.scala
similarity index 61%
rename from src/main/scala/net/funfunnet/crawler/CrawlingActor.scala
rename to src/main/scala/net/funfunnet/crawler/actor/Processor.scala
index 1fda659..2e1d0f2 100644
--- a/src/main/scala/net/funfunnet/crawler/CrawlingActor.scala
+++ b/src/main/scala/net/funfunnet/crawler/actor/Processor.scala
@@ -1,8 +1,8 @@
-package net.funfunnet.crawler
+package net.funfunnet.crawler.actor
import akka.actor.{Actor, ActorLogging}
-class CrawlingActor extends Actor with ActorLogging{
+class Processor extends Actor with ActorLogging{
override def receive: Receive = {
case msg => log.info(s"start crawling by $msg")
diff --git a/src/main/scala/net/funfunnet/crawler/actor/Supervisor.scala b/src/main/scala/net/funfunnet/crawler/actor/Supervisor.scala
new file mode 100644
index 0000000..c1640e8
--- /dev/null
+++ b/src/main/scala/net/funfunnet/crawler/actor/Supervisor.scala
@@ -0,0 +1,30 @@
+package net.funfunnet.crawler.actor
+
+import akka.actor.{Actor, ActorLogging, Props}
+import net.funfunnet.crawler.actor.medium.MediumArticleListCrawler
+import net.funfunnet.crawler.model.{Site, SiteSource}
+
+class Supervisor extends Actor with ActorLogging {
+ val actors = List(
+ context.system.actorOf(Props[MediumArticleListCrawler], name = Site.Medium.toString)
+ )
+ val crawlers = actors.map(x => x.path.name -> x).toMap
+
+ override def receive: Receive = {
+ case Start =>
+ log.info("start")
+ SiteSource.findAll().foreach(self ! Crawl(_))
+ case Crawl(siteSource) =>
+ log.info(s"crawl to ${siteSource.name}")
+ crawlers.get(siteSource.site.toString) match {
+ case Some(ref) => ref ! siteSource
+ case _ => log.error(s"could not found crawler actor : ${siteSource.site.toString}")
+ }
+ case Result(article) =>
+ //TODO 저장 관련 처리
+ log.info(s"result : title:${article.title}, url:${article.url}")
+ case x =>
+ log.warning(s"unknown message type : $x")
+ }
+
+}
diff --git a/src/main/scala/net/funfunnet/crawler/actor/medium/MediumArticleCrawler.scala b/src/main/scala/net/funfunnet/crawler/actor/medium/MediumArticleCrawler.scala
new file mode 100644
index 0000000..0363967
--- /dev/null
+++ b/src/main/scala/net/funfunnet/crawler/actor/medium/MediumArticleCrawler.scala
@@ -0,0 +1,61 @@
+package net.funfunnet.crawler.actor.medium
+
+import java.net.URLEncoder
+import java.time.{LocalDateTime, ZonedDateTime}
+import java.time.format.DateTimeFormatter
+
+import akka.actor.{Actor, ActorLogging}
+import akka.http.scaladsl.Http
+import akka.http.scaladsl.model.{HttpRequest, HttpResponse, StatusCodes, Uri}
+import akka.pattern.pipe
+import akka.stream.{ActorMaterializer, ActorMaterializerSettings}
+import akka.util.ByteString
+import net.funfunnet.crawler.actor.Result
+import net.funfunnet.crawler.common.TimeUtils
+import net.funfunnet.crawler.model.Article
+import org.jsoup.Jsoup
+
+class MediumArticleCrawler extends Actor with ActorLogging {
+
+ import context.dispatcher
+
+ private val DATE_REGEX = "\"datePublished\":\"(.{1,30})\",\"dateModified".r
+
+ private val http = Http(context.system)
+
+ final implicit val materializer: ActorMaterializer =
+ ActorMaterializer(ActorMaterializerSettings(context.system))
+
+ override def receive: Receive = {
+ case url: String =>
+ http.singleRequest(HttpRequest(uri = encodeUrl(url))).pipeTo(self)(sender())
+
+ case HttpResponse(StatusCodes.OK, headers, entity, _) =>
+ val sd = sender()
+ entity.dataBytes.runFold(ByteString(""))(_ ++ _).foreach { body =>
+ sd ! Result(findArticle(body.utf8String))
+ }
+ case resp@HttpResponse(code, _, _, _) =>
+ log.info("Request failed, response code: " + code)
+ resp.discardEntityBytes()
+ }
+
+ def encodeUrl(url: String) : String = {
+ val prefix = url.substring(0, url.lastIndexOf("/") + 1)
+ val params = url.substring(url.lastIndexOf("/") + 1)
+ prefix + URLEncoder.encode(params, "UTF-8")
+ }
+
+ def findArticle(html: String): Article = {
+ val doc = Jsoup.parse(html)
+ val title = doc.select("meta[property=og:title]").attr("content")
+ val desc = doc.select("meta[property=og:description]").attr("content")
+ val url = doc.select("meta[property=og:url]").attr("content")
+ val image = doc.select("meta[property=og:image]").attr("content")
+
+ val dateStr = DATE_REGEX.findFirstMatchIn(html).map(x => x.group(1)).get
+ val createdAt = TimeUtils.parseIsoTime(dateStr)
+
+ Article(title, desc, image, url, createdAt)
+ }
+}
diff --git a/src/main/scala/net/funfunnet/crawler/actor/medium/MediumArticleListCrawler.scala b/src/main/scala/net/funfunnet/crawler/actor/medium/MediumArticleListCrawler.scala
new file mode 100644
index 0000000..6c8a065
--- /dev/null
+++ b/src/main/scala/net/funfunnet/crawler/actor/medium/MediumArticleListCrawler.scala
@@ -0,0 +1,53 @@
+package net.funfunnet.crawler.actor.medium
+
+import akka.actor.{Actor, ActorLogging, Props}
+import akka.http.scaladsl.Http
+import akka.http.scaladsl.model.{HttpRequest, HttpResponse, StatusCodes}
+import akka.pattern.pipe
+import akka.stream.{ActorMaterializer, ActorMaterializerSettings}
+import akka.util.ByteString
+import net.funfunnet.crawler.model.SiteSource
+
+class MediumArticleListCrawler extends Actor with ActorLogging {
+
+ import context.dispatcher
+
+ private val MEDIUMID_REGEX = "https:\\/\\/medium.com\\/(.{1,20})\\/latest".r
+ private val UNIQUESLUG_REGEX = "\"uniqueSlug\":\"(.{1,100})\",\"previewContent\"".r
+ private val http = Http(context.system)
+ private val articleCrawler =
+ context.system.actorOf(Props[MediumArticleCrawler], name = "mediumArticleCrawler")
+
+ final implicit val materializer: ActorMaterializer =
+ ActorMaterializer(ActorMaterializerSettings(context.system))
+
+ override def receive: Receive = {
+ case source: SiteSource =>
+ http.singleRequest(HttpRequest(uri = source.url)).pipeTo(self)(sender())
+
+ case HttpResponse(StatusCodes.OK, headers, entity, _) =>
+ log.info("response ok")
+ val sd = sender()
+ entity.dataBytes.runFold(ByteString(""))(_ ++ _).foreach { body =>
+ log.info(s"Got response, body length: ${body.length}")
+ findArticleUrls(body.utf8String).foreach(x => {
+ articleCrawler.tell(x, sd)
+ })
+ }
+ log.info("end of response ok")
+ case resp@HttpResponse(code, _, _, _) =>
+ log.info("Request failed, response code: " + code)
+ resp.discardEntityBytes()
+ }
+
+ def findPrefixUrl(html: String): Option[String] = MEDIUMID_REGEX.findFirstMatchIn(html)
+ .map(x => x.group(1))
+ .map(x => s"https://medium.com/$x")
+
+ def findUniqueSlugs(html: String): List[String] =
+ UNIQUESLUG_REGEX.findAllMatchIn(html).map(x => x.group(1)).toList
+
+ def findArticleUrls(html: String): List[String] =
+ findPrefixUrl(html).map(x => findUniqueSlugs(html).map(y => s"$x/$y"))
+ .getOrElse(Nil)
+}
diff --git a/src/main/scala/net/funfunnet/crawler/common/TimeUtils.scala b/src/main/scala/net/funfunnet/crawler/common/TimeUtils.scala
new file mode 100644
index 0000000..58ca28b
--- /dev/null
+++ b/src/main/scala/net/funfunnet/crawler/common/TimeUtils.scala
@@ -0,0 +1,9 @@
+package net.funfunnet.crawler.common
+
+import java.time.format.DateTimeFormatter
+import java.time.{LocalDateTime, ZonedDateTime}
+
+object TimeUtils {
+ def parseIsoTime(text: String): LocalDateTime =
+ ZonedDateTime.parse(text, DateTimeFormatter.ISO_DATE_TIME).toLocalDateTime
+}
diff --git a/src/main/scala/net/funfunnet/crawler/model/Article.scala b/src/main/scala/net/funfunnet/crawler/model/Article.scala
new file mode 100644
index 0000000..cefa854
--- /dev/null
+++ b/src/main/scala/net/funfunnet/crawler/model/Article.scala
@@ -0,0 +1,11 @@
+package net.funfunnet.crawler.model
+
+import java.time.LocalDateTime
+
+case class Article(
+ title: String,
+ desc: String,
+ image: String,
+ url: String,
+ createdAt: LocalDateTime
+)
diff --git a/src/main/scala/net/funfunnet/crawler/model/Site.scala b/src/main/scala/net/funfunnet/crawler/model/Site.scala
new file mode 100644
index 0000000..5103a8e
--- /dev/null
+++ b/src/main/scala/net/funfunnet/crawler/model/Site.scala
@@ -0,0 +1,7 @@
+package net.funfunnet.crawler.model
+
+object Site extends Enumeration {
+ type Site = Value
+
+ val Medium = Value
+}
diff --git a/src/main/scala/net/funfunnet/crawler/model/SiteSource.scala b/src/main/scala/net/funfunnet/crawler/model/SiteSource.scala
new file mode 100644
index 0000000..5f82d0b
--- /dev/null
+++ b/src/main/scala/net/funfunnet/crawler/model/SiteSource.scala
@@ -0,0 +1,20 @@
+package net.funfunnet.crawler.model
+
+import net.funfunnet.crawler.model.Site.Site
+
+case class SiteSource(id: Int, name: String, site: Site, url: String)
+
+object SiteSource {
+
+ def findAll(): List[SiteSource] = {
+ //TODO db에서 가져오도록 변경
+ List(
+ SiteSource(id = 1, name = "Rainist Engineering", site = Site.Medium,
+ url = "https://medium.com/rainist-engineering/latest"),
+ SiteSource(id = 2, name = "디지털 세상을 만드는 아날로거", site = Site.Medium,
+ url = "https://medium.com/@goinhacker/latest"),
+ SiteSource(id = 3, name = "Lazysoul", site = Site.Medium,
+ url = "https://medium.com/@lazysoul/latest")
+ )
+ }
+}
diff --git a/src/test/resources/crawler/medium/article.html b/src/test/resources/crawler/medium/article.html
new file mode 100644
index 0000000..2cd0c9f
--- /dev/null
+++ b/src/test/resources/crawler/medium/article.html
@@ -0,0 +1,240 @@
+
Kotlin, AWS 그리고 레이니스트와 함께라면 육군훈련소에서도 외롭지 않아 – Rainist Engineering – Medium
Kotlin, AWS 그리고 레이니스트와 함께라면 육군훈련소에서도 외롭지 않아 올해 Google IO 에서 Kotlin 이 Android 공식 언어로 선정 되었는데요, 레이니스트에서는 작년부터 뱅크샐러드 Android 앱을 100% Kotlin 으로 작성하고 있습니다.
It’s real Kotlin 의 특징 중 하나는 Java 와 100% 호환된다는 점인데요, 이번 포스트에서는 AWS 의 Lambda Function 을 Kotlin 으로 작성한 경험을 공유하고자 합니다.
들어가면서 레이니스트는 병무청에서 지정한 병역지정업체 입니다. 아직 병역의 의무를 해결하지 못한 분이라면 레이니스트에서 산업기능요원으로 근무하면서 병역의 의무를 함께 할 수 있습니다. (현재는 보충역 대상자만 가능합니다)
저는 올해 초부터 레이니스트에서 산업기능요원으로 병역의 의무를 수행하고 있는데요. 열심히 뱅크샐러드 앱을 만들고 있는 도중 한 통의 편지를 받게 됩니다.
국방의 의무 축하해~♪ 바로 군사교육 소집 통지서였습니다. 통지서를 받고 나서 (말로만) 훈련을 받거나 사회 소식을 못 듣는 건 별로 걱정이 되지 않았는데 회사 소식을 4주 동안 못 듣게 되면 나오고 나서 업무를 진행하는데 흐름을 못 탈 것 같아(?) 회사 동료분들께 편지 작성을 부탁드릴 간단한 Slack Bot 을 만들기로 했습니다. Slack 에서 기본적으로 제공하는 Reminder 를 사용해도 되지만 AWS Lambda 를 활용해보고 싶어 주말에 조금씩 시간을 내서 만들기로 했습니다.
사실 주말에 할게 없어서… 사실 처음 목표는 육군훈련소 홈페이지에 자동으로 편지를 쓰게 해주는 Lambda Function 을 만들계획이었으나, 본인인증이라는 거대한 벽에 막혀 다음과 같은 흐름을 가지는 Lambda Function 을 만들기로 했습니다.
간단하쥬? 가장 먼저, Slack API 사이트 에서 Slack App 을 만들고 Lambda Function 에서 Slack 채널로 메시지를 보낼 때 사용할 Webhook URL 을 발급받습니다. 생성된 Slack App을 관리 페이지 좌측의 Incoming Webhooks 에서 다음 사진과 같이 Webhook URL 을 발급받을 수 있습니다.
먼저 aws-lambda-java-core 를 gradle dependency 로 추가하고, Lambda Function 의 Entry Point 역할을 할 RequestHandler 를 구현합니다.
handleRequest를 구현하면 됩니다 handleRequest 메소드를 어떻게 작성할까 고민하다, 뱅크샐러드 안드로이드 앱의 UseCase 처럼 코드를 작성하기로 했습니다. (자세한 내용은 Droid Knights 2017 에서 저희 CTO님께서 발표하신 Clean Architecture in Android 발표를 참고해주세요)
일단, 육군훈련소 홈페이지에서 제가 배치된 연대, 중대, 소대 정보를 가져올 KATCRepository , 편지 써줄 사람을 지목하고 채널에 메시지를 보낼 SlackRepository , 채널에 보낼 메시지를 만들 MessageRepository , 끝으로 지목당한 분이 편지에 쓸 내용이 없는 경우를 대비해 편지에 바깥 세상(?) 소식 요약본을 담기 위한 NaverNewsRepository , 총 4개를 만들었습니다.
Repository 의 각 메소드들은 비동기 처리를 위해 Single , 혹은 Completable 을 return 하게 되어있는데, .blockingGet()을 사용하면 Stream 이 종료될 때 Lambda Function 이 종료될 수 있도록 blocking 처리 할 수 있습니다.
Slack Bot 을 다 만들었으니, 이제 AWS 에 업로드하는 일만 남았습니다. Gradle Script 에 jar task 를 추가해서 jar 파일로 빌드합니다.
AWS Console 을 통해 Lambda Function 을 생성해주고, 생성된 jar 파일을 업로드 하면 되는데요. trigger 는 일단 비워둔 상태로, Lambda 함수 핸들러 및 역할 을 다음과 같이 런타임은 Java8 , 핸들러는 package.classname::handlerMethodName형식으로 생성하면 됩니다.
Slack Bot 의 전체 소스는 GitHub 에서 확인하실 수 있습니다.
CloudWatch Event 만들기 이제 일정 주기마다 위에서 생성한 Lambda Function 을 실행을 시켜야 하는데, 이 문제는 CloudWatch Event 로 간단하게 해결할 수 있습니다. CloudWatch Event 는 Cron 식을 지원하는데요. 저는 화요일, 금요일 아침 9시 에만 메시지를 보내기로 했으므로 다음 식을 사용했습니다.
0 0 ? * TUE, FRI *
여기서 중요한 점은 시간대가 GMT +0 으로 설정되어있기 때문에, 서울 시간 기준으로 -9시간을 해줘야 원하는 스케줄대로 작동 합니다. Cron 식을 입력하고 화면 우측의 호출할 대상을 위에서 생성한 Lambda 함수로 지정하면, 모든 준비가 끝납니다!
끝! 입소하고 시간은 흐르고 흘러 CloudWatch Event가 발생하는, 그리고 슬랙봇이 작동할 수도 있는 첫 번째 화요일이 되었습니다. 제 입소 정보는 화요일 오후쯤에 육군훈련소 홈페이지에 올라가니 편지가 오지 않을 거라 예상 했으나…
다시봐도 감동 봇이 작동하지 않았음에도 첫날에 편지가 두 통이나 왔습니다 ㅠㅠ
가… 감동이야… (슬랙 봇 같은건 필요 없었어!) 동료 분들과 가족들에게 편지를 받고 나니, 봇이 정상적으로 동작하는지 안 동작하는지 확인할 수 있는 금요일이 손꼽아 기다려지기 시작했습니다. (사회소식이 궁금하지 않다고 했었지만 진짜 궁금해서 애가 탔었… 볼 수 있는 게 국방일보 밖에 없었습니다 ㅠㅠ)
그리고 손꼽아 기다리던 금요일 저녁, 편지가 도착했습니다!
기술부채 말고 편지부채! 편지 써준다던 친구들은 거의 안써주고… 레이니스트 더럽… the love… 수료하고 난 뒤 Slack 을 켜서 봇이 동작한 모습을 보니 뿌듯한 마음 반, 편지 써주신 동료분들에 대한 감사의 마음 반으로 가득 찼습니다.
돌아와서 슬랙을 보니… Kotlin 은 Java 와 100% 호환되기에 AWS Lambda Function 을 Kotlin 으로도 작성할 수 있습니다.레이니스트에서 금융정보의 비대칭성을 함께 해결해 나갈 개발자를 찾습니다 . (산업기능요원 보충역 신규 편입 / 전직 가능) 군인에게 편지는 힘이 됩니다 (저희와 함께하시다 훈련소 가시게 되면 제가 1주일에 2번 편지써드릴께요 ㅠㅠ) 아, 마지막으로 다시 한번 편지를 써주신 분들께 감사의 말씀을 전합니다 ㅠㅠ
읽어주셔서 감사합니다 :D
Show your support
Clapping shows how much you appreciated Sunghoon Kang’s story.
\ No newline at end of file
diff --git a/src/test/resources/crawler/medium/latest.html b/src/test/resources/crawler/medium/latest.html
new file mode 100644
index 0000000..9518662
--- /dev/null
+++ b/src/test/resources/crawler/medium/latest.html
@@ -0,0 +1,242 @@
+
+
+Latest stories published on Rainist Engineering – Medium
Rainist Engineering
개인 금융 관리 서비스 뱅크샐러드를 만드는 Rainist의 Engineering 팀입니다.
More information
\ No newline at end of file
diff --git a/src/test/scala/net/funfunnet/crawler/actor/ActorTest.scala b/src/test/scala/net/funfunnet/crawler/actor/ActorTest.scala
new file mode 100644
index 0000000..abb2886
--- /dev/null
+++ b/src/test/scala/net/funfunnet/crawler/actor/ActorTest.scala
@@ -0,0 +1,5 @@
+package net.funfunnet.crawler.actor
+
+class ActorTest {
+
+}
diff --git a/src/test/scala/net/funfunnet/crawler/actor/common/TimeUtilsTest.scala b/src/test/scala/net/funfunnet/crawler/actor/common/TimeUtilsTest.scala
new file mode 100644
index 0000000..86cdfe8
--- /dev/null
+++ b/src/test/scala/net/funfunnet/crawler/actor/common/TimeUtilsTest.scala
@@ -0,0 +1,18 @@
+package net.funfunnet.crawler.actor.common
+
+import net.funfunnet.crawler.common.TimeUtils
+import org.scalatest.FunSuite
+
+class TimeUtilsTest extends FunSuite {
+ test("parseIsoTime") {
+ val dateText = "2017-09-15T06:09:11.760Z"
+ val localDateTime = TimeUtils.parseIsoTime(dateText)
+ assertResult(2017)(localDateTime.getYear)
+ assertResult(9)(localDateTime.getMonthValue)
+ assertResult(15)(localDateTime.getDayOfMonth)
+
+ assertResult(6)(localDateTime.getHour)
+ assertResult(9)(localDateTime.getMinute)
+ assertResult(11)(localDateTime.getSecond)
+ }
+}
diff --git a/src/test/scala/net/funfunnet/crawler/actor/medium/MediumArticleCrawlerTest.scala b/src/test/scala/net/funfunnet/crawler/actor/medium/MediumArticleCrawlerTest.scala
new file mode 100644
index 0000000..9ecd02d
--- /dev/null
+++ b/src/test/scala/net/funfunnet/crawler/actor/medium/MediumArticleCrawlerTest.scala
@@ -0,0 +1,54 @@
+package net.funfunnet.crawler.actor.medium
+
+import akka.actor.ActorSystem
+import akka.testkit.{ImplicitSender, TestActorRef, TestKit}
+import com.typesafe.scalalogging.LazyLogging
+import org.scalatest._
+
+import scala.io.Source
+
+class MediumArticleCrawlerTest extends TestKit(ActorSystem("MediumArticleListCrawlerTest"))
+ with ImplicitSender
+ with FunSuiteLike
+ with Matchers
+ with BeforeAndAfterAll
+ with LazyLogging {
+
+ lazy val html = Source.fromURL(getClass.getClassLoader.getResource("crawler/medium/article.html"))
+ .getLines().mkString
+
+ lazy val crawler = TestActorRef(new MediumArticleCrawler()).underlyingActor
+
+ override def afterAll {
+ TestKit.shutdownActorSystem(system)
+ }
+
+ test("encodeUrl") {
+ val url = "https://medium.com/rainist-engineering/레이니스트의-기술-블로그를-시작하며-2d757ea69844"
+ val encoded = "https://medium.com/rainist-engineering/" +
+ "%EB%A0%88%EC%9D%B4%EB%8B%88%EC%8A%A4%ED%8A%B8%EC%9D%98-%EA%B8%B0%EC%88%A0-" +
+ "%EB%B8%94%EB%A1%9C%EA%B7%B8%EB%A5%BC-%EC%8B%9C%EC%9E%91%ED%95%98%EB%A9%B0-2d757ea69844"
+ assertResult(encoded)(crawler.encodeUrl(url))
+ }
+
+ test("findArticle") {
+ val article = crawler.findArticle(html)
+ assertResult("Kotlin, AWS 그리고 레이니스트와 함께라면 육군훈련소에서도 외롭지 않아 – Rainist Engineering – Medium") {
+ article.title
+ }
+ assertResult("Kotlin과 AWS를 활용해 간단한 Slack 봇을 만든 경험을 통해 뱅크샐러드 Android 앱의 Architecture를 간단히 공유합니다.") {
+ article.desc
+ }
+ assertResult("https://cdn-images-1.medium.com/max/1200/1*KIa4eZARMVelBWNRMd3D9Q.png") {
+ article.image
+ }
+ assertResult("https://medium.com/rainist-engineering/writing-aws-lambda-function-in-kotlin-b3faf3f55777") {
+ article.url
+ }
+
+ assertResult(6)(article.createdAt.getHour)
+ assertResult(9)(article.createdAt.getMinute)
+ assertResult(11)(article.createdAt.getSecond)
+ }
+
+}
diff --git a/src/test/scala/net/funfunnet/crawler/actor/medium/MediumArticleListCrawlerTest.scala b/src/test/scala/net/funfunnet/crawler/actor/medium/MediumArticleListCrawlerTest.scala
new file mode 100644
index 0000000..7561bbd
--- /dev/null
+++ b/src/test/scala/net/funfunnet/crawler/actor/medium/MediumArticleListCrawlerTest.scala
@@ -0,0 +1,48 @@
+package net.funfunnet.crawler.actor.medium
+
+import java.net.URLEncoder
+
+import akka.actor.ActorSystem
+import akka.http.scaladsl.model.Uri
+import akka.testkit.{ImplicitSender, TestActorRef, TestKit}
+import com.typesafe.scalalogging.LazyLogging
+import org.scalatest._
+
+import scala.io.Source
+
+class MediumArticleListCrawlerTest extends TestKit(ActorSystem("MediumArticleListCrawlerTest"))
+ with ImplicitSender
+ with FunSuiteLike
+ with Matchers
+ with BeforeAndAfterAll
+ with LazyLogging {
+
+ lazy val html = Source.fromURL(getClass.getClassLoader.getResource("crawler/medium/latest.html"))
+ .getLines().mkString
+
+ lazy val crawler = TestActorRef(new MediumArticleListCrawler()).underlyingActor
+
+ override def afterAll {
+ TestKit.shutdownActorSystem(system)
+ }
+
+ test("findPrefixUrl") {
+ assertResult(Some("https://medium.com/rainist-engineering")) {
+ crawler.findPrefixUrl(html)
+ }
+ }
+
+ test("findUniqueSlugs") {
+ val slugs = crawler.findUniqueSlugs(html)
+ assertResult(7)(slugs.size)
+ assertResult("writing-aws-lambda-function-in-kotlin-b3faf3f55777")(slugs.head)
+ assertResult("레이니스트의-기술-블로그를-시작하며-2d757ea69844")(slugs.last)
+ }
+
+ test("findArticleUrls") {
+ val urls = crawler.findArticleUrls(html)
+ assertResult(7)(urls.size)
+ assertResult("https://medium.com/rainist-engineering/writing-aws-lambda-function-in-kotlin-b3faf3f55777")(urls.head)
+ assertResult("https://medium.com/rainist-engineering/레이니스트의-기술-블로그를-시작하며-2d757ea69844")(urls.last)
+ }
+}