├── .github └── workflows │ └── scala.yml ├── .gitignore ├── .gitmodules ├── .scalafix.conf ├── .scalafmt.conf ├── LICENSE ├── README.md ├── build.sbt ├── modules ├── benchmark │ └── src │ │ └── main │ │ ├── resources │ │ ├── all-user-agents.txt │ │ └── regexes_@7388149c.yaml │ │ └── scala │ │ └── org │ │ └── uaparser │ │ └── scala │ │ └── benchmark │ │ ├── Main.scala │ │ └── UapScalaBenchmarks.scala └── lib │ └── src │ ├── main │ ├── scala-2.12 │ │ └── org │ │ │ └── uaparser │ │ │ └── scala │ │ │ └── scala.scala │ ├── scala-2.13 │ │ └── org │ │ │ └── uaparser │ │ │ └── scala │ │ │ └── scala.scala │ ├── scala-3 │ │ └── org │ │ │ └── uaparser │ │ │ └── scala │ │ │ └── scala.scala │ └── scala │ │ └── org │ │ └── uaparser │ │ └── scala │ │ ├── CachingParser.scala │ │ ├── Client.scala │ │ ├── Device.scala │ │ ├── MatcherOps.scala │ │ ├── OS.scala │ │ ├── Parser.scala │ │ ├── UserAgent.scala │ │ ├── UserAgentStringParser.scala │ │ └── YamlUtil.scala │ └── test │ └── scala │ └── org │ └── uaparser │ └── scala │ ├── CachingParserSpec.scala │ ├── ParserSpec.scala │ └── ParserSpecBase.scala ├── project ├── build.properties └── plugins.sbt └── version.sbt /.github/workflows/scala.yml: -------------------------------------------------------------------------------- 1 | name: Scala CI 2 | on: 3 | push: 4 | branches: [ master ] 5 | pull_request: 6 | branches: [ master ] 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | scala-version: [2.12.20, 2.13.16, 3.3.6] 13 | steps: 14 | - name: Checkout repository and submodules 15 | uses: actions/checkout@v2 16 | with: 17 | submodules: recursive 18 | - name: Setup JDK 19 | uses: actions/setup-java@v3 20 | with: 21 | distribution: temurin 22 | java-version: 8 23 | cache: sbt 24 | - name: Setup sbt 25 | uses: sbt/setup-sbt@v1 26 | - name: Check formatting 27 | run: sbt -v "scalafixAll --check; scalafmtCheck" 28 | - name: Run tests 29 | run: sbt -v "++${{ matrix.scala-version }} clean; test;" 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | .classpath 3 | .idea/ 4 | .DS_Store 5 | .bsp/ 6 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "core"] 2 | path = core 3 | url = https://github.com/ua-parser/uap-core.git 4 | -------------------------------------------------------------------------------- /.scalafix.conf: -------------------------------------------------------------------------------- 1 | rules = [OrganizeImports, LeakingImplicitClassVal, ProcedureSyntax, RedundantSyntax, ExplicitResultTypes] 2 | 3 | OrganizeImports { 4 | targetDialect = Scala3 5 | coalesceToWildcardImportThreshold = 2147483647 # Int.MaxValue 6 | expandRelative = true 7 | groupExplicitlyImportedImplicitsSeparately = false 8 | groupedImports = AggressiveMerge 9 | groups = ["re:javax?\\.", "scala.", "*", "org.uaparser."] 10 | importSelectorsOrder = Ascii 11 | importsOrder = Ascii 12 | removeUnused = false 13 | } 14 | 15 | ExplicitResultTypes { 16 | memberVisibility = [Public] 17 | memberKind = [Def] 18 | skipSimpleDefinitions = false 19 | } 20 | -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | version = 3.9.7 2 | 3 | runner.dialect = scala3 4 | maxColumn = 120 5 | newlines.topLevelStatementBlankLines = [ 6 | { blanks: { after: 0 } } 7 | ] 8 | fileOverride { 9 | "glob:**/*.sbt" { 10 | runner.dialect = sbt1 11 | align.preset = most 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE 2 | Version 2, December 2004 3 | 4 | Copyright (C) 2015 Piotr Adamski 5 | 6 | Everyone is permitted to copy and distribute verbatim or modified 7 | copies of this license document, and changing it is allowed as long 8 | as the name is changed. 9 | 10 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE 11 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 12 | 13 | 0. You just DO WHAT THE FUCK YOU WANT TO. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | uap-scala 2 | ========= 3 | 4 | [![Codecov status](https://codecov.io/gh/ua-parser/uap-scala/branch/master/graph/badge.svg)](https://codecov.io/gh/ua-parser/uap-scala) 5 | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/org.uaparser/uap-scala_2.11/badge.svg)](https://maven-badges.herokuapp.com/maven-central/org.uaparser/uap-scala_2.11) 6 | 7 | A Scala user-agent string parser based on [ua-parser/uap-core](https://github.com/ua-parser/uap-core). It extracts browser, OS and device information. 8 | 9 | ### Usage 10 | 11 | To use this library in your own project, add the following dependency in `build.sbt`: 12 | 13 | ``` 14 | libraryDependencies += "org.uaparser" %% "uap-scala" % "0.16.0" 15 | ``` 16 | 17 | #### Note about these examples 18 | 19 | Instantiating Parser.default also instantiates secondary classes and reads in YAML files. This is slow. 20 | If performance is critical or you are handling user agents in real time, be sure not to do this on the 21 | critical path for processing requests. 22 | 23 | #### Retrieve data on a user-agent string 24 | 25 | ```scala 26 | import org.uaparser.scala.Parser 27 | 28 | val ua = "Mozilla/5.0 (iPhone; CPU iPhone OS 5_1_1 like Mac OS X) AppleWebKit/534.46 (KHTML, like Gecko) Version/5.1 Mobile/9B206 Safari/7534.48.3" 29 | val client = Parser.default.parse(ua) // you can also use CachingParser 30 | println(client) // Client(UserAgent(Mobile Safari,Some(5),Some(1),None),OS(iOS,Some(5),Some(1),Some(1),None),Device(iPhone)) 31 | ``` 32 | #### Extract partial data from user-agent string 33 | 34 | The time costs of parsing all the data may be high. 35 | To reduce the costs, we can just parse partial data. 36 | 37 | ```scala 38 | import org.uaparser.scala.Parser 39 | 40 | val raw = "Mozilla/5.0 (iPhone; CPU iPhone OS 5_1_1 like Mac OS X) AppleWebKit/534.46 (KHTML, like Gecko) Version/5.1 Mobile/9B206 Safari/7534.48.3" 41 | val parser = Parser.default 42 | 43 | val os = parser.osParser.parse(raw) 44 | println(os) // OS(iOS,Some(5),Some(1),Some(1),None) 45 | 46 | val userAgent = parser.userAgentParser.parse(raw) 47 | println(userAgent) // UserAgent(Mobile Safari,Some(5),Some(1),None) 48 | 49 | val device = parser.deviceParser.parse(raw) 50 | println(device) // Device(iPhone,Some(Apple),Some(iPhone)) 51 | ``` 52 | 53 | ### Development 54 | 55 | The code for this repository can be checked out normally. It uses a [git submodule](https://git-scm.com/docs/git-submodule) to include the files needed from [uap-core](https://github.com/ua-parser/uap-core) so care must be taken to make sure the `core` directory is properly checked out and initialized. 56 | 57 | Checking out the repo for the first time 58 | ``` 59 | git clone --recursive https://github.com/ua-parser/uap-scala.git 60 | ``` 61 | If uap-scala was checked out and core was not properly initialized, the following can be done 62 | 63 | ``` 64 | cd uap-scala 65 | git submodule update --init --recursive 66 | ``` 67 | 68 | #### Build 69 | 70 | To build and publish locally for the default Scala (currently 2.13.11): 71 | 72 | ```scala 73 | sbt publishLocal 74 | ``` 75 | 76 | To cross-build for different Scala versions: 77 | 78 | ```scala 79 | sbt +publishLocal 80 | ``` 81 | 82 | ### Maintainers 83 | 84 | * Piotr Adamski ([@mcveat](https://twitter.com/mcveat)) (Author. Based on the java implementation by Steve Jiang [@sjiang](https://twitter.com/sjiang) and using agent data from BrowserScope) 85 | * [Ahmed Sobhi](https://github.com/humanzz) ([@humanzz](https://twitter.com/humanzz)) 86 | * [Travis Brown](https://github.com/travisbrown) ([@travisbrown](https://twitter.com/travisbrown)) 87 | * [Nguyen Hong Phuc](https://github.com/phucnh) ([@phuc89](https://twitter.com/phuc89)) 88 | -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | import ReleaseTransformations.* 2 | 3 | val commonScalacOptions = Seq( 4 | "-Xfatal-warnings", 5 | "-deprecation", 6 | "-encoding", 7 | "UTF-8", 8 | "-feature", 9 | "-unchecked" 10 | ) 11 | 12 | val scalac2Flags = Seq( 13 | "-Xlint:adapted-args", 14 | "-Xsource:3", 15 | "-Ywarn-dead-code", 16 | "-Ywarn-numeric-widen", 17 | "-Ywarn-unused:imports" 18 | ) 19 | 20 | lazy val commonSettings = Seq( 21 | scalaVersion := "2.13.14", 22 | crossScalaVersions := Seq("2.12.20", "2.13.16", "3.3.6"), 23 | scalacOptions := { 24 | CrossVersion.partialVersion(scalaVersion.value) match { 25 | case Some((3, _)) => 26 | commonScalacOptions :+ "-language:implicitConversions" 27 | case Some((2, _)) => 28 | commonScalacOptions ++ scalac2Flags 29 | case _ => 30 | commonScalacOptions 31 | } 32 | }, 33 | // Enable scalafix 34 | semanticdbEnabled := true, 35 | semanticdbVersion := scalafixSemanticdb.revision 36 | ) 37 | 38 | lazy val lib = project 39 | .in(file("modules/lib")) 40 | .settings(commonSettings *) 41 | .settings( 42 | name := "uap-scala", 43 | organization := "org.uaparser", 44 | libraryDependencies ++= Seq( 45 | "org.yaml" % "snakeyaml" % "2.4", 46 | CrossVersion.partialVersion(scalaVersion.value) match { 47 | case Some((3, _)) => 48 | "org.specs2" %% "specs2-core" % "5.6.3" % "test" 49 | case Some((2, scalaMajor)) if scalaMajor >= 11 => 50 | "org.specs2" %% "specs2-core" % "4.21.0" % "test" 51 | case _ => 52 | "org.specs2" %% "specs2-core" % "3.10.0" % "test" 53 | } 54 | ), 55 | mimaPreviousArtifacts := Set("org.uaparser" %% "uap-scala" % "0.3.0"), 56 | 57 | // make sure we include necessary uap-core files 58 | Compile / unmanagedResourceDirectories += (ThisBuild / baseDirectory).value / "core", 59 | Compile / unmanagedResources / includeFilter := "regexes.yaml", 60 | Test / unmanagedResourceDirectories += (ThisBuild / baseDirectory).value / "core", 61 | Test / unmanagedResources / includeFilter := "*.yaml", 62 | 63 | // Publishing 64 | publishMavenStyle := true, 65 | publishTo := sonatypePublishToBundle.value, 66 | Test / publishArtifact := false, 67 | releaseCrossBuild := true, 68 | releaseTagComment := s"Release ${(ThisBuild / version).value}", 69 | releaseCommitMessage := s"Set version to ${(ThisBuild / version).value}", 70 | releaseProcess := Seq[ReleaseStep]( 71 | checkSnapshotDependencies, 72 | inquireVersions, 73 | runClean, 74 | runTest, 75 | setReleaseVersion, 76 | commitReleaseVersion, 77 | tagRelease, 78 | releaseStepCommandAndRemaining("+publishSigned"), 79 | releaseStepCommand("sonatypeBundleRelease"), 80 | setNextVersion, 81 | commitNextVersion, 82 | pushChanges 83 | ), 84 | pomExtra := (https://github.com/ua-parser/uap-scala 85 | 86 | 87 | WTFPL 88 | http://www.wtfpl.net/about 89 | repo 90 | 91 | 92 | 93 | git@github.com:ua-parser/uap-scala.git 94 | scm:git:git@github.com:ua-parser/uap-scala.git 95 | 96 | 97 | 98 | mcveat 99 | Piotr Adamski 100 | https://twitter.com/mcveat 101 | 102 | 103 | humanzz 104 | Ahmed Sobhi 105 | https://twitter.com/humanzz 106 | 107 | 108 | travisbrown 109 | Travis Brown 110 | https://twitter.com/travisbrown 111 | 112 | 113 | phucnh 114 | Nguyen Hong Phuc 115 | https://twitter.com/phuc89 116 | 117 | ) 118 | ) 119 | 120 | lazy val benchmark = project 121 | .in(file("modules/benchmark")) 122 | .settings(commonSettings *) 123 | .dependsOn(lib) 124 | .enablePlugins(JmhPlugin) 125 | .settings( 126 | name := "uap-scala-benchmark", 127 | Jmh / run / mainClass := Some("org.uaparser.scala.benchmark.Main"), 128 | publish / skip := true 129 | ) 130 | 131 | // do not cross build or publish the aggregating root 132 | crossScalaVersions := Nil 133 | publish / skip := true 134 | -------------------------------------------------------------------------------- /modules/benchmark/src/main/scala/org/uaparser/scala/benchmark/Main.scala: -------------------------------------------------------------------------------- 1 | package org.uaparser.scala.benchmark 2 | 3 | import org.openjdk.jmh.runner.Runner 4 | import org.openjdk.jmh.runner.options.CommandLineOptions 5 | 6 | object Main { 7 | def main(args: Array[String]): Unit = { 8 | val opts = new CommandLineOptions(args*) 9 | val runner = new Runner(opts) 10 | runner.run() 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /modules/benchmark/src/main/scala/org/uaparser/scala/benchmark/UapScalaBenchmarks.scala: -------------------------------------------------------------------------------- 1 | package org.uaparser.scala.benchmark 2 | 3 | import scala.io.Source 4 | 5 | import org.openjdk.jmh.annotations.{Benchmark, Scope, State} 6 | 7 | import org.uaparser.scala.Parser 8 | 9 | @State(Scope.Benchmark) 10 | class UapScalaBenchmarks { 11 | 12 | // These agents were chosen because they correspond to valid agents that get evaluated by the last regex defined 13 | // in the current yaml file in the resources folder. So, they should take the most time to evaluate. 14 | val userAgentSingleTest = 15 | """Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:102.0) Gecko/20100101 MullvadBrowser/102.13.0""" 16 | val osSingleTest = 17 | """Mozilla/5.0 (TAS-AL00 Build/HUAWEITAS-AL00; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/97.0.4692.98 Mobile Safari/537.36 T7/13.76 BDOS/1.0 (HarmonyOS 3.0.0) SP-engine/3.17.0 baiduboxapp/13.76.0.10 (Baidu; P1 12) NABar/1.0""" 18 | val deviceSingleTest = 19 | """Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_3) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.5 Safari/605.1.15""" 20 | 21 | // This is somewhere in the middle for all regexes. 22 | val allSingleTest = "(C)NokiaNXX/SymbianOS/9.1 Series60/3.0" 23 | 24 | // an entire bundle of strings taken from the current suite of tests 25 | val allUserAgentStrings: List[String] = Source.fromResource("all-user-agents.txt").getLines().toList 26 | 27 | var parser: Parser = 28 | Parser.fromInputStream(Thread.currentThread.getContextClassLoader.getResourceAsStream("regexes_@7388149c.yaml")).get 29 | 30 | @Benchmark 31 | def measureSingleStrDeviceParser(): Unit = { 32 | parser.deviceParser.parse(deviceSingleTest) 33 | } 34 | 35 | @Benchmark 36 | def measureSingleStrOsParser(): Unit = { 37 | parser.osParser.parse(osSingleTest) 38 | } 39 | 40 | @Benchmark 41 | def measureSingleStrUserAgentParser(): Unit = { 42 | parser.userAgentParser.parse(userAgentSingleTest) 43 | } 44 | 45 | @Benchmark 46 | def measureSingleStrAllParser(): Unit = { 47 | parser.parse(allSingleTest) 48 | } 49 | 50 | @Benchmark 51 | def measureAllStrDeviceParser(): Unit = { 52 | allUserAgentStrings.foreach(parser.deviceParser.parse) 53 | } 54 | 55 | @Benchmark 56 | def measureAllStrOsParser(): Unit = { 57 | allUserAgentStrings.foreach(parser.osParser.parse) 58 | } 59 | 60 | @Benchmark 61 | def measureAllStrUserAgentParser(): Unit = { 62 | allUserAgentStrings.foreach(parser.userAgentParser.parse) 63 | } 64 | 65 | @Benchmark 66 | def measureAllStrAllParser(): Unit = { 67 | allUserAgentStrings.foreach(parser.parse) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /modules/lib/src/main/scala-2.12/org/uaparser/scala/scala.scala: -------------------------------------------------------------------------------- 1 | package org.uaparser 2 | 3 | package object scala { 4 | object jdk { 5 | val CollectionConverters = _root_.scala.collection.JavaConverters 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /modules/lib/src/main/scala-2.13/org/uaparser/scala/scala.scala: -------------------------------------------------------------------------------- 1 | package org.uaparser 2 | 3 | package object scala { 4 | object jdk { 5 | val CollectionConverters = _root_.scala.jdk.CollectionConverters 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /modules/lib/src/main/scala-3/org/uaparser/scala/scala.scala: -------------------------------------------------------------------------------- 1 | package org.uaparser 2 | 3 | package object scala { 4 | object jdk { 5 | val CollectionConverters = _root_.scala.jdk.CollectionConverters 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /modules/lib/src/main/scala/org/uaparser/scala/CachingParser.scala: -------------------------------------------------------------------------------- 1 | package org.uaparser.scala 2 | 3 | import java.io.InputStream 4 | import java.util 5 | import java.util.{Collections, Map as JMap} 6 | 7 | import scala.util.Try 8 | 9 | case class CachingParser(parser: Parser, maxEntries: Int) extends UserAgentStringParser { 10 | lazy val clients: JMap[String, Client] = Collections.synchronizedMap( 11 | new util.LinkedHashMap[String, Client](maxEntries + 1, 1.0f, true) { 12 | override protected def removeEldestEntry(eldest: JMap.Entry[String, Client]): Boolean = 13 | super.size > maxEntries 14 | } 15 | ) 16 | def parse(agent: String): Client = Option(clients.get(agent)).getOrElse { 17 | val client = parser.parse(agent) 18 | clients.put(agent, client) 19 | client 20 | } 21 | } 22 | 23 | object CachingParser { 24 | private val defaultCacheSize: Int = 1000 25 | def fromInputStream(source: InputStream, size: Int = defaultCacheSize): Try[CachingParser] = 26 | Parser.fromInputStream(source).map(CachingParser(_, size)) 27 | def default(size: Int = defaultCacheSize): CachingParser = CachingParser(Parser.default, size) 28 | } 29 | -------------------------------------------------------------------------------- /modules/lib/src/main/scala/org/uaparser/scala/Client.scala: -------------------------------------------------------------------------------- 1 | package org.uaparser.scala 2 | 3 | case class Client(userAgent: UserAgent, os: OS, device: Device) 4 | -------------------------------------------------------------------------------- /modules/lib/src/main/scala/org/uaparser/scala/Device.scala: -------------------------------------------------------------------------------- 1 | package org.uaparser.scala 2 | 3 | import java.util.regex.{Matcher, Pattern} 4 | 5 | import org.uaparser.scala.MatcherOps.* 6 | 7 | case class Device(family: String, brand: Option[String] = None, model: Option[String] = None) 8 | 9 | object Device { 10 | private[scala] def fromMap(m: Map[String, String]) = m.get("family").map(Device(_, m.get("brand"), m.get("model"))) 11 | 12 | private[scala] case class DevicePattern( 13 | pattern: Pattern, 14 | familyReplacement: Option[String], 15 | brandReplacement: Option[String], 16 | modelReplacement: Option[String] 17 | ) { 18 | def process(agent: String): Option[Device] = { 19 | val matcher = pattern.matcher(agent) 20 | if (!matcher.find()) None 21 | else { 22 | val family = familyReplacement.map(r => replace(r, matcher)).orElse(matcher.groupAt(1)) 23 | val brand = brandReplacement.map(r => replace(r, matcher)).filterNot(s => s.isEmpty) 24 | val model = modelReplacement.map(r => replace(r, matcher)).orElse(matcher.groupAt(1)).filterNot(s => s.isEmpty) 25 | family.map(Device(_, brand, model)) 26 | } 27 | } 28 | 29 | private def replace(replacement: String, matcher: Matcher): String = { 30 | (if (replacement.contains("$") && matcher.groupCount() >= 1) { 31 | (1 to matcher.groupCount()).foldLeft(replacement)((rep, i) => { 32 | val toInsert = if (matcher.group(i) ne null) matcher.group(i) else "" 33 | rep.replaceFirst("\\$" + i, Matcher.quoteReplacement(toInsert)) 34 | }) 35 | } else replacement).trim 36 | } 37 | } 38 | 39 | private object DevicePattern { 40 | def fromMap(m: Map[String, String]): Option[DevicePattern] = m.get("regex").map { r => 41 | val pattern = 42 | m.get("regex_flag").map(flag => Pattern.compile(r, Pattern.CASE_INSENSITIVE)).getOrElse(Pattern.compile(r)) 43 | DevicePattern(pattern, m.get("device_replacement"), m.get("brand_replacement"), m.get("model_replacement")) 44 | } 45 | } 46 | 47 | case class DeviceParser(patterns: List[DevicePattern]) { 48 | def parse(agent: String): Device = patterns 49 | .foldLeft[Option[Device]](None) { 50 | case (None, pattern) => pattern.process(agent) 51 | case (result, _) => result 52 | } 53 | .getOrElse(Device("Other")) 54 | } 55 | 56 | object DeviceParser { 57 | def fromList(config: List[Map[String, String]]): DeviceParser = 58 | DeviceParser(config.flatMap(DevicePattern.fromMap)) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /modules/lib/src/main/scala/org/uaparser/scala/MatcherOps.scala: -------------------------------------------------------------------------------- 1 | package org.uaparser.scala 2 | 3 | import java.util.regex.Matcher 4 | 5 | object MatcherOps { 6 | implicit class MatcherImprovements(private val m: Matcher) extends AnyVal { 7 | // Tries to safely return the matching group at index i wrapped in an Option. 8 | // We also take care of converting empty strings to a None, because it seems possible in uap-core to define matching 9 | // groups that capture empty strings. At the time, the semantics of None and empty strings seemed to match. 10 | def groupAt(i: Int): Option[String] = { 11 | try { 12 | val matched = m.group(i) 13 | if (matched == null || matched.isEmpty) None 14 | else Some(matched) 15 | } catch { 16 | case _: IndexOutOfBoundsException => None 17 | } 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /modules/lib/src/main/scala/org/uaparser/scala/OS.scala: -------------------------------------------------------------------------------- 1 | package org.uaparser.scala 2 | 3 | import java.util.regex.{Matcher, Pattern} 4 | 5 | import scala.util.control.Exception.allCatch 6 | 7 | import org.uaparser.scala.MatcherOps.* 8 | 9 | case class OS( 10 | family: String, 11 | major: Option[String] = None, 12 | minor: Option[String] = None, 13 | patch: Option[String] = None, 14 | patchMinor: Option[String] = None 15 | ) 16 | 17 | object OS { 18 | private[scala] def fromMap(m: Map[String, String]) = m.get("family").map { family => 19 | OS(family, m.get("major"), m.get("minor"), m.get("patch"), m.get("patch_minor")) 20 | } 21 | 22 | private[this] val quotedBack1: Pattern = Pattern.compile(s"(${Pattern.quote("$1")})") 23 | 24 | private[this] def replacementBack1(matcher: Matcher)(replacement: String): String = 25 | if (matcher.groupCount() >= 1) { 26 | quotedBack1.matcher(replacement).replaceAll(matcher.group(1)) 27 | } else replacement 28 | 29 | private[this] def replaceBackreference(matcher: Matcher)(replacement: String): Option[String] = 30 | getBackreferenceGroup(replacement) match { 31 | case Some(group) => matcher.groupAt(group) 32 | case None => Some(replacement) 33 | } 34 | 35 | private[this] def getBackreferenceGroup(replacement: String): Option[Int] = 36 | for { 37 | ref <- Option(replacement).filter(_.contains("$")) 38 | groupOpt = allCatch opt ref.substring(1).toInt 39 | group <- groupOpt 40 | } yield group 41 | 42 | private[scala] case class OSPattern( 43 | pattern: Pattern, 44 | osReplacement: Option[String], 45 | v1Replacement: Option[String], 46 | v2Replacement: Option[String], 47 | v3Replacement: Option[String], 48 | v4Replacement: Option[String] 49 | ) { 50 | def process(agent: String): Option[OS] = { 51 | val matcher = pattern.matcher(agent) 52 | if (!matcher.find()) None 53 | else { 54 | osReplacement 55 | .map(replacementBack1(matcher)) 56 | .orElse(matcher.groupAt(1)) 57 | .map { family => 58 | val major = v1Replacement.flatMap(replaceBackreference(matcher)).orElse(matcher.groupAt(2)) 59 | val minor = v2Replacement.flatMap(replaceBackreference(matcher)).orElse(matcher.groupAt(3)) 60 | val patch = v3Replacement.flatMap(replaceBackreference(matcher)).orElse(matcher.groupAt(4)) 61 | val patchMinor = v4Replacement.flatMap(replaceBackreference(matcher)).orElse(matcher.groupAt(5)) 62 | OS(family, major, minor, patch, patchMinor) 63 | } 64 | } 65 | } 66 | } 67 | 68 | private object OSPattern { 69 | def fromMap(m: Map[String, String]): Option[OSPattern] = m.get("regex").map { r => 70 | OSPattern( 71 | Pattern.compile(r), 72 | m.get("os_replacement"), 73 | m.get("os_v1_replacement"), 74 | m.get("os_v2_replacement"), 75 | m.get("os_v3_replacement"), 76 | m.get("os_v4_replacement") 77 | ) 78 | } 79 | } 80 | 81 | case class OSParser(patterns: List[OSPattern]) { 82 | def parse(agent: String): OS = patterns 83 | .foldLeft[Option[OS]](None) { 84 | case (None, pattern) => pattern.process(agent) 85 | case (result, _) => result 86 | } 87 | .getOrElse(OS("Other")) 88 | } 89 | 90 | object OSParser { 91 | def fromList(config: List[Map[String, String]]): OSParser = OSParser(config.flatMap(OSPattern.fromMap)) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /modules/lib/src/main/scala/org/uaparser/scala/Parser.scala: -------------------------------------------------------------------------------- 1 | package org.uaparser.scala 2 | 3 | import java.io.InputStream 4 | 5 | import scala.util.Try 6 | 7 | import org.uaparser.scala.Device.DeviceParser 8 | import org.uaparser.scala.OS.OSParser 9 | import org.uaparser.scala.UserAgent.UserAgentParser 10 | 11 | case class Parser(userAgentParser: UserAgentParser, osParser: OSParser, deviceParser: DeviceParser) 12 | extends UserAgentStringParser { 13 | def parse(agent: String): Client = 14 | Client(userAgentParser.parse(agent), osParser.parse(agent), deviceParser.parse(agent)) 15 | } 16 | 17 | object Parser { 18 | def fromInputStream(source: InputStream): Try[Parser] = Try { 19 | val config = YamlUtil.loadYamlAsMap(source) 20 | val userAgentParser = UserAgentParser.fromList(config.getOrElse("user_agent_parsers", Nil)) 21 | val osParser = OSParser.fromList(config.getOrElse("os_parsers", Nil)) 22 | val deviceParser = DeviceParser.fromList(config.getOrElse("device_parsers", Nil)) 23 | Parser(userAgentParser, osParser, deviceParser) 24 | } 25 | def default: Parser = fromInputStream(this.getClass.getResourceAsStream("/regexes.yaml")).get 26 | } 27 | -------------------------------------------------------------------------------- /modules/lib/src/main/scala/org/uaparser/scala/UserAgent.scala: -------------------------------------------------------------------------------- 1 | package org.uaparser.scala 2 | 3 | import java.util.regex.{Matcher, Pattern} 4 | 5 | import org.uaparser.scala.MatcherOps.* 6 | 7 | case class UserAgent( 8 | family: String, 9 | major: Option[String] = None, 10 | minor: Option[String] = None, 11 | patch: Option[String] = None 12 | ) 13 | 14 | object UserAgent { 15 | private[scala] def fromMap(m: Map[String, String]) = m.get("family").map { family => 16 | UserAgent(family, m.get("major"), m.get("minor"), m.get("patch")) 17 | } 18 | 19 | private[scala] case class UserAgentPattern( 20 | pattern: Pattern, 21 | familyReplacement: Option[String], 22 | v1Replacement: Option[String], 23 | v2Replacement: Option[String], 24 | v3Replacement: Option[String] 25 | ) { 26 | def process(agent: String): Option[UserAgent] = { 27 | val matcher = pattern.matcher(agent) 28 | if (!matcher.find()) return None 29 | familyReplacement 30 | .map { replacement => 31 | if (replacement.contains("$1") && matcher.groupCount() >= 1) { 32 | replacement.replaceFirst("\\$1", Matcher.quoteReplacement(matcher.group(1))) 33 | } else replacement 34 | } 35 | .orElse(matcher.groupAt(1)) 36 | .map { family => 37 | val major = v1Replacement.orElse(matcher.groupAt(2)).filter(_.nonEmpty) 38 | val minor = v2Replacement.orElse(matcher.groupAt(3)).filter(_.nonEmpty) 39 | val patch = v3Replacement.orElse(matcher.groupAt(4)).filter(_.nonEmpty) 40 | UserAgent(family, major, minor, patch) 41 | } 42 | } 43 | } 44 | 45 | private object UserAgentPattern { 46 | def fromMap(config: Map[String, String]): Option[UserAgentPattern] = config.get("regex").map { r => 47 | UserAgentPattern( 48 | Pattern.compile(r), 49 | config.get("family_replacement"), 50 | config.get("v1_replacement"), 51 | config.get("v2_replacement"), 52 | config.get("v3_replacement") 53 | ) 54 | } 55 | } 56 | 57 | case class UserAgentParser(patterns: List[UserAgentPattern]) { 58 | def parse(agent: String): UserAgent = patterns 59 | .foldLeft[Option[UserAgent]](None) { 60 | case (None, pattern) => pattern.process(agent) 61 | case (result, _) => result 62 | } 63 | .getOrElse(UserAgent("Other")) 64 | } 65 | 66 | object UserAgentParser { 67 | def fromList(config: List[Map[String, String]]): UserAgentParser = 68 | UserAgentParser(config.flatMap(UserAgentPattern.fromMap)) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /modules/lib/src/main/scala/org/uaparser/scala/UserAgentStringParser.scala: -------------------------------------------------------------------------------- 1 | package org.uaparser.scala 2 | 3 | trait UserAgentStringParser { 4 | def parse(agent: String): Client 5 | } 6 | -------------------------------------------------------------------------------- /modules/lib/src/main/scala/org/uaparser/scala/YamlUtil.scala: -------------------------------------------------------------------------------- 1 | package org.uaparser.scala 2 | 3 | import java.io.InputStream 4 | import java.util.{List as JList, Map as JMap} 5 | 6 | import org.yaml.snakeyaml.constructor.SafeConstructor 7 | import org.yaml.snakeyaml.{LoaderOptions, Yaml} 8 | 9 | import org.uaparser.scala.jdk.CollectionConverters.* 10 | 11 | private[scala] object YamlUtil { 12 | def loadYamlAsMap(yamlStream: InputStream, loader: Yaml): Map[String, List[Map[String, String]]] = { 13 | val javaConfig = loader.load[JMap[String, JList[JMap[String, String]]]](yamlStream) 14 | javaConfig.asScala.map { case (k, v) => 15 | k -> v.asScala.map(_.asScala.filter { case (_, v) => v != null }.toMap).toList 16 | }.toMap 17 | } 18 | 19 | def loadYamlAsMap(yamlStream: InputStream): Map[String, List[Map[String, String]]] = { 20 | loadYamlAsMap(yamlStream, new Yaml(new SafeConstructor(new LoaderOptions))) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /modules/lib/src/test/scala/org/uaparser/scala/CachingParserSpec.scala: -------------------------------------------------------------------------------- 1 | package org.uaparser.scala 2 | 3 | import java.io.InputStream 4 | 5 | class CachingParserSpec extends ParserSpecBase { 6 | val parser: CachingParser = CachingParser.default() 7 | def createFromStream(stream: InputStream): UserAgentStringParser = CachingParser.fromInputStream(stream).get 8 | } 9 | -------------------------------------------------------------------------------- /modules/lib/src/test/scala/org/uaparser/scala/ParserSpec.scala: -------------------------------------------------------------------------------- 1 | package org.uaparser.scala 2 | 3 | import java.io.InputStream 4 | 5 | class ParserSpec extends ParserSpecBase { 6 | val parser: Parser = Parser.default 7 | def createFromStream(stream: InputStream): UserAgentStringParser = Parser.fromInputStream(stream).get 8 | } 9 | -------------------------------------------------------------------------------- /modules/lib/src/test/scala/org/uaparser/scala/ParserSpecBase.scala: -------------------------------------------------------------------------------- 1 | package org.uaparser.scala 2 | 3 | import java.io.{ByteArrayInputStream, InputStream} 4 | import java.nio.charset.StandardCharsets 5 | 6 | import org.specs2.mutable.Specification 7 | import org.yaml.snakeyaml.{LoaderOptions, Yaml} 8 | 9 | trait ParserSpecBase extends Specification { 10 | 11 | val parser: UserAgentStringParser 12 | def createFromStream(stream: InputStream): UserAgentStringParser 13 | 14 | "Parser should" >> { 15 | val yaml = { 16 | val maxFileSizeBytes = 5 * 1024 * 1024 // 5 MB 17 | val loaderOptions = new LoaderOptions() 18 | loaderOptions.setCodePointLimit(maxFileSizeBytes) 19 | new Yaml(loaderOptions) 20 | } 21 | 22 | def readCasesConfig(resource: String): List[Map[String, String]] = { 23 | val stream = this.getClass.getResourceAsStream(resource) 24 | val cases = YamlUtil.loadYamlAsMap(stream, yaml) 25 | 26 | cases.getOrElse("test_cases", List()).filterNot(_.contains("js_ua")).map { config => 27 | config.filterNot { case (_, value) => value eq null } 28 | } 29 | } 30 | 31 | "parse basic ua" >> { 32 | val cases = List( 33 | "Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10.4; fr; rv:1.9.1.5) Gecko/20091102 Firefox/3.5.5 ,gzip(gfe),gzip(gfe)" -> 34 | Client( 35 | UserAgent("Firefox", Some("3"), Some("5"), Some("5")), 36 | OS("Mac OS X", Some("10"), Some("4")), 37 | Device("Mac", Some("Apple"), Some("Mac")) 38 | ), 39 | "Mozilla/5.0 (iPhone; CPU iPhone OS 5_1_1 like Mac OS X) AppleWebKit/534.46 (KHTML, like Gecko) Version/5.1 Mobile/9B206 Safari/7534.48.3" -> 40 | Client( 41 | UserAgent("Mobile Safari", Some("5"), Some("1")), 42 | OS("iOS", Some("5"), Some("1"), Some("1")), 43 | Device("iPhone", Some("Apple"), Some("iPhone")) 44 | ) 45 | ) 46 | cases.map { case (agent, expected) => 47 | parser.parse(agent) must beEqualTo(expected) 48 | } 49 | } 50 | 51 | "properly quote replacements" >> { 52 | val testConfig = 53 | """ 54 | |user_agent_parsers: 55 | | - regex: 'ABC([\\0-9]+)' 56 | | family_replacement: 'ABC ($1)' 57 | |os_parsers: 58 | | - regex: 'CatOS OH-HAI=/\^\.\^\\=' 59 | | os_replacement: 'CatOS 9000' 60 | |device_parsers: 61 | | - regex: 'CashPhone-([\$0-9]+)\.(\d+)\.(\d+)' 62 | | device_replacement: 'CashPhone $1' 63 | """.stripMargin 64 | val stream = new ByteArrayInputStream(testConfig.getBytes(StandardCharsets.UTF_8)) 65 | val parser = createFromStream(stream) 66 | val client = parser.parse("""ABC12\34 (CashPhone-$9.0.1 CatOS OH-HAI=/^.^\=)""") 67 | client.userAgent.family must beEqualTo("""ABC (12\34)""") 68 | client.os.family must beEqualTo("CatOS 9000") 69 | client.device.family must beEqualTo("CashPhone $9") 70 | } 71 | 72 | "properly quote all os replacements" >> { 73 | val testConfig = 74 | """ 75 | |os_parsers: 76 | | - regex: '(\w+\s+Mac OS X\s+\w+\s+(\d+).(\d+).(\d+).*)' 77 | | os_replacement: 'Mac OS X' 78 | | os_v1_replacement: '$2' 79 | | os_v2_replacement: '$3' 80 | | os_v3_replacement: '$4' 81 | """.stripMargin 82 | 83 | val stream = new ByteArrayInputStream(testConfig.getBytes(StandardCharsets.UTF_8)) 84 | val parser = createFromStream(stream) 85 | val client = parser.parse("""ABC12\34 (Intelx64 Mac OS X Version 10.12.6 OH-HAI=/^.^\=)""") 86 | client.os.family must beEqualTo("Mac OS X") 87 | client.os.major must beSome("10") 88 | client.os.minor must beSome("12") 89 | client.os.patch must beSome("6") 90 | } 91 | 92 | "properly quote all user agent replacements" >> { 93 | val testConfig = 94 | """ 95 | |user_agent_parsers: 96 | | - regex: 'ABC([\\0-9]+)' 97 | | family_replacement: 'ABC ($1)' 98 | | v1_replacement: '1' 99 | | v2_replacement: '0' 100 | | v3_replacement: '2' 101 | """.stripMargin 102 | 103 | val stream = new ByteArrayInputStream(testConfig.getBytes(StandardCharsets.UTF_8)) 104 | val parser = createFromStream(stream) 105 | val client = parser.parse("""ABC12\34; OH-HAI=/^.^\=""") 106 | client.userAgent.family must beEqualTo("""ABC (12\34)""") 107 | client.userAgent.major must beSome("1") 108 | client.userAgent.minor must beSome("0") 109 | client.userAgent.patch must beSome("2") 110 | } 111 | 112 | "properly handle empty config" >> { 113 | val stream = new ByteArrayInputStream("{}".getBytes(StandardCharsets.UTF_8)) 114 | val parser = createFromStream(stream) 115 | val client = parser.parse("""ABC12\34 (CashPhone-$9.0.1 CatOS OH-HAI=/^.^\=)""") 116 | client.userAgent.family must beEqualTo("""Other""") 117 | client.os.family must beEqualTo("Other") 118 | client.device.family must beEqualTo("Other") 119 | } 120 | 121 | "properly parse an user agent with None for missing information" >> { 122 | val testConfig = 123 | """ 124 | |user_agent_parsers: 125 | | - regex: '(ABC) (\d+?\.|)(\d+|)(\d+|);' 126 | """.stripMargin 127 | val stream = new ByteArrayInputStream(testConfig.getBytes(StandardCharsets.UTF_8)) 128 | val parser = createFromStream(stream) 129 | val client = parser.parse("""(compatible; ABC ; OH-HAI=/^.^\=""") 130 | client.userAgent.family must beEqualTo("ABC") 131 | client.userAgent.major must beNone 132 | client.userAgent.minor must beNone 133 | client.userAgent.patch must beNone 134 | } 135 | 136 | "properly parse user agents" >> { 137 | List( 138 | "/tests/test_ua.yaml", 139 | "/test_resources/firefox_user_agent_strings.yaml", 140 | "/test_resources/pgts_browser_list.yaml", 141 | "/test_resources/opera_mini_user_agent_strings.yaml", 142 | "/test_resources/podcasting_user_agent_strings.yaml" 143 | ).flatMap { file => 144 | readCasesConfig(file).map { c => 145 | parser.parse(c("user_agent_string")).userAgent must beEqualTo(UserAgent.fromMap(c).get) 146 | } 147 | } 148 | } 149 | 150 | "properly parse os" >> { 151 | List("/tests/test_os.yaml", "/test_resources/additional_os_tests.yaml").flatMap { file => 152 | readCasesConfig(file).map { c => 153 | parser.parse(c("user_agent_string")).os must beEqualTo(OS.fromMap(c).get) 154 | } 155 | } 156 | } 157 | "properly parse device" >> { 158 | readCasesConfig("/tests/test_device.yaml").map { c => 159 | parser.parse(c("user_agent_string")).device must beEqualTo(Device.fromMap(c).get) 160 | } 161 | } 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.11.0 2 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.14.3") 2 | addSbtPlugin("com.github.sbt" % "sbt-pgp" % "2.3.1") 3 | addSbtPlugin("com.github.sbt" % "sbt-release" % "1.4.0") 4 | addSbtPlugin("com.typesafe" % "sbt-mima-plugin" % "1.1.4") 5 | addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.4") 6 | addSbtPlugin("org.xerial.sbt" % "sbt-sonatype" % "3.12.2") 7 | addSbtPlugin("pl.project13.scala" % "sbt-jmh" % "0.4.7") 8 | -------------------------------------------------------------------------------- /version.sbt: -------------------------------------------------------------------------------- 1 | ThisBuild / version := "0.19.1-SNAPSHOT" 2 | --------------------------------------------------------------------------------