Skip to content

Commit

Permalink
Add support for sharing product from app (containing text and short URL)
Browse files Browse the repository at this point in the history
  • Loading branch information
ekuzmichev committed Sep 17, 2024
1 parent 6563f29 commit 9d2ae15
Show file tree
Hide file tree
Showing 14 changed files with 151 additions and 99 deletions.
6 changes: 4 additions & 2 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ ThisBuild / assemblyMergeStrategy := {
case x => MergeStrategy.defaultMergeStrategy(x)
}

ThisBuild / Test / testOptions += Tests.Filter(s => !s.endsWith("IntegrationTest"))

lazy val root = (project in file("."))
.settings(
name := "ozon-price-checker",
Expand All @@ -33,8 +35,8 @@ lazy val root = (project in file("."))
"org.jasypt" % "jasypt" % "1.9.3",
"com.github.pathikrit" %% "better-files" % "3.9.2",
"org.scalatest" %% "scalatest" % "3.2.19" % Test,
"dev.zio" %% "zio-test" % "2.1.6" % Test,
"dev.zio" %% "zio-test-sbt" % "2.1.6" % Test,
"dev.zio" %% "zio-test" % "2.1.9" % Test,
"dev.zio" %% "zio-test-sbt" % "2.1.9" % Test,
"com.stephenn" %% "scalatest-circe" % "0.2.5" % Test
) ++
Seq(
Expand Down
2 changes: 2 additions & 0 deletions src/main/scala/app/AppLayers.scala
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import schedule.{JobIdGeneratorLayers, ZioSchedulerLayers}
import store.{CacheStateRepository, CacheStateRepositoryLayers, ProductStore, ProductStoreLayers}
import telegram.TelegramClientLayers
import util.lang.Throwables.failure
import util.ozon.OzonShortUrlResolverLayers

import org.telegram.telegrambots.longpolling.interfaces.LongPollingUpdateConsumer
import zio.{RLayer, Task, TaskLayer, URLayer, ZIO, ZIOAppArgs, ZLayer}
Expand Down Expand Up @@ -46,6 +47,7 @@ object AppLayers:
BrowserLayers.jsoup,
JobIdGeneratorLayers.alphaNumeric,
ProductIdParserLayers.ozon,
OzonShortUrlResolverLayers.impl,
CommandProcessorLayers.ozonPriceChecker,
EncDecLayers.aes256(encryptionPassword.value),
ProductWatchingJobSchedulerLayers.impl,
Expand Down
3 changes: 2 additions & 1 deletion src/main/scala/consumer/OzonPriceCheckerUpdateConsumer.scala
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,13 @@ class OzonPriceCheckerUpdateConsumer(
case Some(productCandidate) =>
productCandidate match
case WaitingProductId =>
productIdParser.parse(text) match
productIdParser.parse(text).flatMap {
case Right(productId) =>
onProductId(sourceId, productId)
case Left(error) =>
ZIO.log(s"Failed to parse productId from text '$text'. Cause: $error") *>
sendTextMessage(sourceId.chatId, s"Send me valid URL or product ID")
}
case WaitingPriceThreshold(productId, productPrice) =>
text.toIntOption match
case Some(priceThreshold) =>
Expand Down
41 changes: 31 additions & 10 deletions src/main/scala/product/OzonProductIdParser.scala
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,40 @@ package ru.ekuzmichev
package product

import common.ProductId
import product.OzonProductIdParser.OzonHostRegex
import util.lang.Throwables
import util.lang.Throwables.makeCauseSeqMessage
import product.OzonProductIdParser.{OzonHostRegex, OzonProductUrlPathPart, OzonShortUrlPathPart}
import util.ozon.OzonShortUrlResolver
import util.uri.UrlExtractionUtils

import cats.syntax.either.*
import io.lemonlabs.uri.{Url, UrlPath}
import zio.{Task, ZIO}

import scala.util.matching.Regex

class OzonProductIdParser extends ProductIdParser:
override def parse(s: String): Either[String, ProductId] =
parseUrlEither(s).flatMap(extractProductId(s, _))
class OzonProductIdParser(ozonShortUrlResolver: OzonShortUrlResolver) extends ProductIdParser:
override def parse(s: String): Task[Either[String, ProductId]] =
UrlExtractionUtils.extractUrl(s).flatMap {
case Some(url) =>
if isOzonUrl(url) then
if isShortOzonUrl(url) then
ozonShortUrlResolver
.resolveShortUrl(url)
.flatMap(extractProductId)
else extractProductId(url)
else ZIO.left(s"URL $url contains host other than *ozon.ru")
case None =>
ZIO.logDebug(s"Returning $s as product id") *> ZIO.right(s)
}

private def extractProductId(url: Url) =
if isProductUrl(url) then
takeProductIdFromPath(url.path) match
case Some(productId) => ZIO.right(productId)
case None => ZIO.left(s"Not found product ID in URL path. URL: $url")
else ZIO.left(s"URL $url is not product OZON URL")

private def isShortOzonUrl(url: Url): Boolean = url.path.parts.contains(OzonShortUrlPathPart)

private def parseUrlEither(s: String): Either[String, Url] =
Url.parseTry(s).toEither.leftMap(makeCauseSeqMessage(_))
private def isProductUrl(url: Url) = url.path.parts.contains(OzonProductUrlPathPart)

private def extractProductId(s: ProductId, url: Url): Either[String, ProductId] =
if isOzonUrl(url) then
Expand All @@ -33,4 +52,6 @@ class OzonProductIdParser extends ProductIdParser:
urlPath.parts.drop(1).filter(!_.isBlank).headOption

object OzonProductIdParser:
val OzonHostRegex: Regex = ".*ozon.ru".r
val OzonHostRegex: Regex = ".*ozon.ru".r
val OzonShortUrlPathPart: String = "t"
val OzonProductUrlPathPart: String = "product"
4 changes: 3 additions & 1 deletion src/main/scala/product/ProductIdParser.scala
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,7 @@ package product

import common.ProductId

import zio.Task

trait ProductIdParser:
def parse(s: String): Either[String, ProductId]
def parse(s: String): Task[Either[String, ProductId]]
6 changes: 4 additions & 2 deletions src/main/scala/product/ProductIdParserLayers.scala
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package ru.ekuzmichev
package product

import zio.{ULayer, ZLayer}
import util.ozon.OzonShortUrlResolver

import zio.{RLayer, ZLayer}

object ProductIdParserLayers:
val ozon: ULayer[ProductIdParser] = ZLayer.succeed(new OzonProductIdParser)
val ozon: RLayer[OzonShortUrlResolver, ProductIdParser] = ZLayer.fromFunction(new OzonProductIdParser(_))
3 changes: 2 additions & 1 deletion src/main/scala/util/ozon/OzonShortUrlResolver.scala
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
package ru.ekuzmichev
package util.ozon

import io.lemonlabs.uri.Url
import zio.Task

trait OzonShortUrlResolver:
def resolveShortUrl(url: String): Task[String]
def resolveShortUrl(url: Url): Task[Url]
8 changes: 5 additions & 3 deletions src/main/scala/util/ozon/OzonShortUrlResolverImpl.scala
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package util.ozon

import util.ozon.OzonShortUrlResolverImpl.ProductUrlExtractor

import io.lemonlabs.uri.Url
import net.ruippeixotog.scalascraper.browser.Browser
import net.ruippeixotog.scalascraper.dsl.DSL.*
import net.ruippeixotog.scalascraper.dsl.DSL.Extract.*
Expand All @@ -13,11 +14,12 @@ import zio.{Task, ZIO}
class OzonShortUrlResolverImpl(browser: Browser) extends OzonShortUrlResolver:
private type BrowserDocument = browser.DocumentType

override def resolveShortUrl(url: String): Task[String] =
override def resolveShortUrl(url: Url): Task[Url] =
ZIO.attempt {
val responseHtml: BrowserDocument = browser.get(url)
val responseHtml: BrowserDocument = browser.get(url.toStringPunycode)
responseHtml >> ProductUrlExtractor
}
}.flatMap(fullUrlString => ZIO.fromTry(Url.parseTry(fullUrlString)))
.tap{fullUrl => ZIO.logDebug(s"Resolved short OZON URL $url into full OZON URL $fullUrl")}

object OzonShortUrlResolverImpl:
val ProductUrlExtractor: HtmlExtractor[Element, String] = attr("content")("meta[data-hid=property::og:url]")
5 changes: 0 additions & 5 deletions src/main/scala/util/ozon/OzonShortUrlUtils.scala

This file was deleted.

94 changes: 94 additions & 0 deletions src/test/scala/product/OzonProductIdParserIntegrationTest.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package ru.ekuzmichev
package product

import util.ozon.OzonShortUrlResolverImpl

import net.ruippeixotog.scalascraper.browser.JsoupBrowser
import zio.test.*
import zio.test.Assertion.*

object OzonProductIdParserIntegrationTest extends ZIOSpecDefault:
def spec: Spec[Any, Throwable] =
suite("OzonProductIdParser.parse")(
test("parse Ozon product id from Ozon URL 'shared' from browser") {
val parser = makeProductIdParser
parser
.parse(
"https://www.ozon.ru/product/kanistra-dlya-smeshivaniya-benzina-i-masla-dde-2-l-1422144661/?__rr=1&from=share_ios&utm_campaign=productpage_link&utm_medium=share_button&utm_source=smm"
)
.map { parseResult =>
assert(parseResult)(isRight(equalTo("kanistra-dlya-smeshivaniya-benzina-i-masla-dde-2-l-1422144661")))
}
},
test("parse take product id from plain non-URL string") {
val parser = makeProductIdParser
parser
.parse(
"akusticheskaya-gitara-donner-hush-i-silent-guitar-sunburst-6-strunnaya-988766503"
)
.map { parseResult =>
assert(parseResult)(
isRight(equalTo("akusticheskaya-gitara-donner-hush-i-silent-guitar-sunburst-6-strunnaya-988766503"))
)
}
},
test("parse Ozon product id from Ozon link URL without query parameters") {
val parser = makeProductIdParser
parser
.parse(
"https://www.ozon.ru/product/kanistra-dlya-smeshivaniya-benzina-i-masla-dde-2-l-1422144661"
)
.map { parseResult =>
assert(parseResult)(isRight(equalTo("kanistra-dlya-smeshivaniya-benzina-i-masla-dde-2-l-1422144661")))
}
},
test("parse Ozon product id from Ozon short URL") {
val parser = makeProductIdParser
parser
.parse("https://ozon.ru/t/YKknAE4")
.map { parseResult =>
assert(parseResult)(
isRight(
equalTo(
"kovrik-samonaduvayushchiysya-naturehike-yugu-ultralight-automatic-inflatable-cushion-mummy-blue-b-r-1449496528"
)
)
)
}
},
test("parse Ozon product id from Ozon short URL wrapped with text") {
val parser = makeProductIdParser
parser
.parse(
"Коврик Самонадувающийся Naturehike Yugu Ultralight Automatic Inflatable Cushion Mummy Blue (Б/Р) https://ozon.ru/t/YKknAE4"
)
.map { parseResult =>
assert(parseResult)(
isRight(
equalTo(
"kovrik-samonaduvayushchiysya-naturehike-yugu-ultralight-automatic-inflatable-cushion-mummy-blue-b-r-1449496528"
)
)
)
}
},
test("fail if link has no path") {
val parser = makeProductIdParser
parser
.parse("https://www.ozon.ru/product/")
.map { parseResult =>
assert(parseResult)(isLeft)
}
},
test("fail if link has invalid host") {
val parser = makeProductIdParser
parser
.parse("https://www.vk.ru/product/kanistra-dlya-smeshivaniya-benzina-i-masla-dde-2-l-1422144661")
.map { parseResult =>
assert(parseResult)(isLeft)
}
}
)

private def makeProductIdParser: ProductIdParser =
new OzonProductIdParser(new OzonShortUrlResolverImpl(new JsoupBrowser()))
56 changes: 0 additions & 56 deletions src/test/scala/product/OzonProductIdParserTest.scala

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
package ru.ekuzmichev
package util.ozon

import io.lemonlabs.uri.Url
import net.ruippeixotog.scalascraper.browser.JsoupBrowser
import zio.{Scope, ZIO, ZIOAppArgs, ZIOAppDefault}

object OzonShortUrlResolverImplTestApp extends ZIOAppDefault:
override def run: ZIO[Any with ZIOAppArgs with Scope, Any, Any] =
val productShortUrl = "https://ozon.ru/t/YKknAE4"
val productShortUrl: Url = Url.parse("https://ozon.ru/t/YKknAE4")
val ozonShortUrlResolver = new OzonShortUrlResolverImpl(new JsoupBrowser())
ozonShortUrlResolver
.resolveShortUrl(productShortUrl)
Expand Down
15 changes: 0 additions & 15 deletions src/test/scala/util/ozon/OzonShortUrlUtilsTest.scala

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ package util.uri
import io.lemonlabs.uri.{QueryString, Url}
import zio.test.{Spec, ZIOSpecDefault, assertTrue}

object UrlExtractionUtilsSpec extends ZIOSpecDefault:
object UrlExtractionUtilsTest extends ZIOSpecDefault:
def spec: Spec[Any, Throwable] =
suite("UrlExtractionUtilsSpec.extractUrl")(
suite("UrlExtractionUtils.extractUrl")(
test("should extract URL from text containing it") {
for maybeUrl <- UrlExtractionUtils.extractUrl(
s"Коврик Самонадувающийся Naturehike Yugu Ultralight Automatic Inflatable Cushion Mummy Blue (Б/Р) https://ozon.ru/t/YKknAE4"
Expand Down

0 comments on commit 9d2ae15

Please sign in to comment.