diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..612c5bc --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +target +.idea +*.iml diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..884d022 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2016 callate.io + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/logo.png b/logo.png new file mode 100644 index 0000000..6b63497 Binary files /dev/null and b/logo.png differ diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..2af930f --- /dev/null +++ b/pom.xml @@ -0,0 +1,132 @@ + + 4.0.0 + io.callate + tuDownloader + 1.0 + 2016 + + 2.11.7 + + + + + scala-tools.org + Scala-Tools Maven2 Repository + http://scala-tools.org/repo-releases + + + + + + scala-tools.org + Scala-Tools Maven2 Repository + http://scala-tools.org/repo-releases + + + + + org.scala-lang + scala-library + ${scala.version} + + + org.jsoup + jsoup + 1.8.3 + + + org.json4s + json4s-native_2.11 + 3.3.0 + + + org.scala-lang + scala-swing + 2.11.0-M7 + + + + + src/main/scala + + + org.scala-tools + maven-scala-plugin + + + + compile + testCompile + + + + + ${scala.version} + + -target:jvm-1.5 + + + + + org.apache.maven.plugins + maven-eclipse-plugin + + true + + ch.epfl.lamp.sdt.core.scalabuilder + + + ch.epfl.lamp.sdt.core.scalanature + + + org.eclipse.jdt.launching.JRE_CONTAINER + ch.epfl.lamp.sdt.launching.SCALA_CONTAINER + + + + + org.apache.maven.plugins + maven-assembly-plugin + 2.2-beta-5 + + + jar-with-dependencies + + + + io.callate.main.Main + + + + + + package + + single + + + + + + + + . + + **/*.png + + + + + + + + org.scala-tools + maven-scala-plugin + + ${scala.version} + + + + + + diff --git a/src/main/scala/io/callate/gui/About.scala b/src/main/scala/io/callate/gui/About.scala new file mode 100644 index 0000000..09a2600 --- /dev/null +++ b/src/main/scala/io/callate/gui/About.scala @@ -0,0 +1,129 @@ +package io.callate.gui + +import java.awt.Color +import java.awt.Dimension +import java.awt.Image +import java.awt._ +import java.awt.event.{MouseEvent, MouseAdapter} +import java.io.IOException +import java.net.{URISyntaxException, URI} +import javax.swing.ImageIcon + +import scala.swing.Dialog +import scala.swing.Label +import scala.swing._ +import scala.swing.BorderPanel.Position._ + +object About extends Dialog { + title = "tuDownloader" + resizable = false + modal = true + + val imagePanel = new BorderPanel{ + layout(new Label { + icon = new ImageIcon(new ImageIcon(getClass.getClassLoader.getResource("logo.png")).getImage.getScaledInstance(200, 200, Image.SCALE_SMOOTH)) + preferredSize = new Dimension(100, 100) + }) = Center + } + + val createdBy = new Label { text = "tuDownloader v1.0 - "} + val callateName = new Label { + text = "callate.io" + foreground = Color.blue + } + callateName.peer.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)) + callateName.peer.addMouseListener(new MouseAdapter() { + override def mouseClicked(e: MouseEvent) { + if (e.getClickCount > 0) { + if (Desktop.isDesktopSupported) { + val desktop: Desktop = Desktop.getDesktop + try { + val uri: URI = new URI("http://callate.io") + desktop.browse(uri) + } catch { + case _: IOException => + case _: URISyntaxException => + } + } + } + } + }) + + + val nurLabel = new Label { + text = "@subnurmality" + foreground = Color.blue + } + nurLabel.peer.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)) + nurLabel.peer.addMouseListener(new MouseAdapter() { + override def mouseClicked(e: MouseEvent) { + if (e.getClickCount > 0) { + if (Desktop.isDesktopSupported) { + val desktop: Desktop = Desktop.getDesktop + try { + val uri: URI = new URI("http://twitter.com/subnurmality") + desktop.browse(uri) + } catch { + case _: IOException => + case _: URISyntaxException => + } + } + } + } + }) + + val toroLabel = new Label { + text = "@wynkth" + foreground = Color.blue + } + toroLabel.peer.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)) + toroLabel.peer.addMouseListener(new MouseAdapter() { + override def mouseClicked(e: MouseEvent) { + if (e.getClickCount > 0) { + if (Desktop.isDesktopSupported) { + val desktop: Desktop = Desktop.getDesktop + try { + val uri: URI = new URI("http://twitter.com/wynkth") + desktop.browse(uri) + } catch { + case _: IOException => + case _: URISyntaxException => + } + } + } + } + }) + + val nurFlowPanel = new FlowPanel { + contents += new Label("UI") + contents += nurLabel + } + + val toroFlowPanel = new FlowPanel { + contents += new Label("Code") + contents += toroLabel + } + + val flowPanel = new FlowPanel { + contents += createdBy + contents += callateName + } + + val borderLayout = new BorderPanel { + layout(flowPanel) = North + layout(toroFlowPanel) = Center + layout(nurFlowPanel) = South + } + + val gridPanel = new BorderPanel { + layout(imagePanel) = Center + layout(borderLayout) = South + } + // Add the grid, set the size + contents = new BorderPanel { + layout(gridPanel) = Center + } + size = new Dimension(320, 260) + + peer.setLocationRelativeTo(null) +} \ No newline at end of file diff --git a/src/main/scala/io/callate/gui/Login.scala b/src/main/scala/io/callate/gui/Login.scala new file mode 100644 index 0000000..440be4e --- /dev/null +++ b/src/main/scala/io/callate/gui/Login.scala @@ -0,0 +1,82 @@ +package io.callate.gui + +import io.callate.main.Main +import scala.swing._ +import scala.swing.BorderPanel.Position._ +import event._ + +object Login extends MainFrame { + title = "tuDownloader" + resizable = false + + if (System.getProperty("os.name") != "Mac OS X") { + menuBar = new MenuBar { + contents += new MenuItem(Action("Acerca de..") { + About.open() + }) + } + } + + // Define components + val emailLabel = new Label { + text = "Email: " + } + + val passLabel = new Label { + text = "Contraseña: " + } + + val button = new Button { + text = "Conectar" + enabled = true + tooltip = "Haz click para iniciar sesión" + } + + val emailText = new TextField { + columns = 20 + text = "" + } + + val passText = new PasswordField { + columns = 20 + text = "" + } + + val gridPanel = new GridPanel(5, 1) { + contents += emailLabel + contents += emailText + contents += passLabel + contents += passText + contents += button + } + + // Define alignments + emailLabel.horizontalAlignment = Alignment.Left + passLabel.horizontalAlignment = Alignment.Left + + // Add the grid, set the size + contents = new BorderPanel { + layout(gridPanel) = Center + } + + // Listen to events + listenTo(button) + listenTo(emailText.keys) + listenTo(passText.keys) + + // Add the reactions to the events + reactions += { + case ButtonClicked(component) if component == button => Main.login(emailText.text, new String(passText.password)) + case KeyPressed(_, Key.Enter, _, _) => Main.login(emailText.text, new String(passText.password)) + } + + // Set frame location + peer.setLocationRelativeTo(null) + + def error() = { + Dialog.showMessage(contents.head, + "Error al conectar, asegúrate de que has introducido los datos correctamente", + title="Error", + Dialog.Message.Error) + } +} \ No newline at end of file diff --git a/src/main/scala/io/callate/gui/MenuPanel.scala b/src/main/scala/io/callate/gui/MenuPanel.scala new file mode 100644 index 0000000..940b82f --- /dev/null +++ b/src/main/scala/io/callate/gui/MenuPanel.scala @@ -0,0 +1,119 @@ +package io.callate.gui + +import javax.swing.table.AbstractTableModel + +import io.callate.main.Main + +import scala.swing._ +import scala.swing.BorderPanel.Position._ +import event._ + +class AlbumModel(var cells: Array[Array[Any]], val columns: Array[String]) extends AbstractTableModel { + def getRowCount: Int = cells.length + def getColumnCount: Int = columns.length + def getValueAt(row: Int, col: Int): AnyRef = cells(row)(col).asInstanceOf[AnyRef] + override def getColumnClass(column: Int) = getValueAt(0, column).getClass + override def isCellEditable(row: Int, column: Int) = if (column == 2) true else false + override def setValueAt(value: Any, row: Int, col: Int) { + cells(row)(col) = value + fireTableCellUpdated(row, col) + } + override def getColumnName(column: Int): String = columns(column).toString +} + +class MenuPanel(val albums: List[(String, String, Int)]) extends MainFrame { + if (System.getProperty("os.name") != "Mac OS X") { + menuBar = new MenuBar { + contents += new MenuItem(Action("Acerca de..") { + About.open() + }) + } + } + + title = "tuDownloader" + resizable = false + // Define components + val header = Array("Nombre del álbum", "Fotos", "Descargar") + val items = albums.map(_.productIterator.toList.slice(1, 3).++(List(true)).toArray).toArray + val albumsTable = new Table(items, header) { + model = new AlbumModel(items, header) + + // Set widths + peer.getColumnModel.getColumn(0).setPreferredWidth(400) + peer.getColumnModel.getColumn(1).setPreferredWidth(100) + peer.getColumnModel.getColumn(2).setPreferredWidth(100) + } + val albumsScroll = new ScrollPane(albumsTable) + val downloadAlbums = new Button { + text = "Descargar marcados" + enabled = true + tooltip = "Haz click para bajar los álbumes seleccionados" + } + val downloadPMs = new Button { + text = "Descargar MPs" + enabled = true + tooltip = "Haz click para bajar tus mensajes privados" + } + val deselectAll = new Button { + text = "Desmarcar todos" + enabled = true + tooltip = "Haz click para desmarcar todos tus álbumes" + } + val selectAll = new Button { + text = "Marcar todos" + enabled = true + tooltip = "Haz click para marcar todos tus álbumes" + } + val buttonTopPanel = new FlowPanel { + contents += deselectAll + contents += selectAll + } + val buttonBottomPanel = new FlowPanel { + contents += downloadAlbums + contents += downloadPMs + } + val buttonPanel = new BorderPanel { + layout(buttonTopPanel) = North + layout(buttonBottomPanel) = South + } + val gridPanel = new BorderPanel { + layout(albumsScroll) = Center + layout(buttonPanel) = South + } + // Add the grid, set the size + contents = new BorderPanel { + layout(gridPanel) = Center + } + size = new Dimension(600, 400) + // Listen to events + listenTo(downloadAlbums) + listenTo(downloadPMs) + listenTo(deselectAll) + listenTo(selectAll) + + // Add the reactions to the events + reactions += { + case ButtonClicked(component) if component == deselectAll => albums.indices.foreach(albumsTable.model.setValueAt(false, _, 2)) + case ButtonClicked(component) if component == selectAll => albums.indices.foreach(albumsTable.model.setValueAt(true, _, 2)) + case ButtonClicked(component) if component == downloadAlbums => downloadMarkedAlbumsIDs() + case ButtonClicked(component) if component == downloadPMs => + Main.downloadPrivateMessages() + Dialog.showMessage(contents.head, + "Mensajes privados descargados correctamente (Mensajes privados.zip)", + title="Mensajes privados", + Dialog.Message.Info) + + } + + peer.setLocationRelativeTo(null) + + def downloadMarkedAlbumsIDs() = { + val albumsTuple = albums.zipWithIndex.filter(p => albumsTable.model.getValueAt(p._2, 2).asInstanceOf[Boolean]).map(p => p._1) + val numPhotos = albumsTuple.map(_._3).sum + val albumsIds = albumsTuple.map(_._1) + + if (numPhotos != 0) { + Main.downloadAlbums(albumsIds, numPhotos) + } + } +} \ No newline at end of file diff --git a/src/main/scala/io/callate/gui/OSX.scala b/src/main/scala/io/callate/gui/OSX.scala new file mode 100644 index 0000000..5d7dff4 --- /dev/null +++ b/src/main/scala/io/callate/gui/OSX.scala @@ -0,0 +1,15 @@ +package io.callate.gui + +import com.apple.eawt.{Application, AboutHandler} +import com.apple.eawt.AppEvent.AboutEvent + + +class OSX { + System.setProperty("apple.awt.application.name", "tuDownloader") + val app = Application.getApplication + app.setAboutHandler(new AboutHandler { + override def handleAbout(aboutEvent: AboutEvent): Unit = { + About.open() + } + }) +} diff --git a/src/main/scala/io/callate/gui/PhotoModal.scala b/src/main/scala/io/callate/gui/PhotoModal.scala new file mode 100644 index 0000000..ebde7e9 --- /dev/null +++ b/src/main/scala/io/callate/gui/PhotoModal.scala @@ -0,0 +1,42 @@ +package io.callate.gui + +import scala.swing._ +import scala.swing.BorderPanel.Position._ + +import io.callate.main.Main + +class PhotoModal(total: Int) extends MainFrame { + title = "Descargando fotos..." + resizable = false + val progressBar = new ProgressBar() + val progressPane = new ScrollPane(progressBar) + val progressText = new Label() + + progressBar.preferredSize = new Dimension(400, 20) + progressText.peer.setText("0 / " + total) + + contents = new BorderPanel { + layout(progressPane) = Center + layout(progressText) = South + } + + progressBar.peer.setMaximum(total) + progressBar.peer.setMinimum(0) + + peer.pack() + peer.setLocationRelativeTo(null) + + + def newPhoto() = { + progressBar.peer.setValue(progressBar.peer.getValue + 1) + progressText.peer.setText(progressBar.peer.getValue + " / " + total) + + if (progressBar.peer.getValue == progressBar.peer.getMaximum) { + Dialog.showMessage(contents.head, + "Fotos descargadas correctamente (carpeta 'Fotos')", + title="Fotos", + Dialog.Message.Info) + Main.finishedPhotos() + } + } +} diff --git a/src/main/scala/io/callate/main/Main.scala b/src/main/scala/io/callate/main/Main.scala new file mode 100644 index 0000000..6f59e3c --- /dev/null +++ b/src/main/scala/io/callate/main/Main.scala @@ -0,0 +1,90 @@ +package io.callate.main + +import java.io.{FileNotFoundException, File} +import java.net.{URL, HttpURLConnection} +import java.nio.file.Paths +import javax.swing.JMenuBar + +import io.callate.model.{Utils, TuDownloader} +import io.callate.gui._ + +import scala.concurrent.Future +import scala.concurrent.ExecutionContext.Implicits._ + +import scala.swing.{Swing, SimpleSwingApplication} + +object Main extends App { + if (System.getProperty("os.name").equals("Mac OS X")) { + new OSX() + } + val loginUI = Login + var menuUI: MenuPanel = null + var photoModal: PhotoModal = null + val tuDownloader = new TuDownloader() + var albumsTuple: List[(String, String, Int)] = null + + loginUI.visible = true + + def login(email: String, password: String) = { + try { + if (tuDownloader.login(email, password)) { + loginUI.visible = false + + albumsTuple = tuDownloader.getAlbums.map(x => (x._1, x._2._1, x._2._2)).toList + + menuUI = new MenuPanel(albumsTuple) + menuUI.visible = true + + } else { + loginUI.error() + } + } catch { + case e: Exception => loginUI.error() + } + + } + + def downloadAlbums(albumIds: List[String], total: Int) = { + val folder = "Fotos" + if (!new File(folder).exists) { + Utils.mkdir(folder) + } + + photoModal = new PhotoModal(total) + + menuUI.visible = false + photoModal.visible = true + + Future { + for (id <- albumIds) { + val albumName = tuDownloader.getAlbums.get(id).get._1 + if (!new File(folder, albumName).exists()) { + Utils.mkdirs(List(folder, albumName)) + } + var photoCount = 1 + for (photoURL <- tuDownloader.getPhotosAlbumURLs(id)) { + val photoImg = tuDownloader.getPhotoUrl(photoURL) + val httpURLConnection: HttpURLConnection = new URL(photoImg).openConnection().asInstanceOf[HttpURLConnection] + httpURLConnection.setRequestMethod("GET") + httpURLConnection.connect() + // Photos that result in 404 requests + if (httpURLConnection.getResponseCode != 404) { + val outputFile = Paths.get(folder, albumName, photoCount + ".jpg") + Utils.fileDownloader(httpURLConnection.getInputStream, outputFile) + photoCount += 1 + } + photoModal.newPhoto() + } + } + } + } + + def finishedPhotos() = { + photoModal.visible = false + menuUI.visible = true + } + + def downloadPrivateMessages() = { + Utils.fileDownloader(tuDownloader.getPrivateMessagesURL, "Mensajes privados.zip") + } +} diff --git a/src/main/scala/io/callate/model/TuDownloader.scala b/src/main/scala/io/callate/model/TuDownloader.scala new file mode 100644 index 0000000..612d69d --- /dev/null +++ b/src/main/scala/io/callate/model/TuDownloader.scala @@ -0,0 +1,152 @@ +package io.callate.model + +import java.util + +import org.json4s.native.JsonMethods._ +import org.jsoup.Connection.{Method, Response} +import org.jsoup.Jsoup + +import scala.collection.mutable + + +class TuDownloader { + private var csrf: String = "" + private var cookies: util.Map[String, String] = new util.HashMap[String, String]() + private val albums: mutable.Map[String, (String, Int)] = mutable.HashMap[String, (String, Int)]() + + def login(email: String, pass: String): Boolean = { + var response: Response = Jsoup.connect("https://www.tuenti.com/?m=Login") + .execute() + + cookies = response.cookies() + val csfr: String = response.parse().select("input[name=csfr]").attr("value") + + response = Jsoup.connect("https://www.tuenti.com/?m=Login&func=do_login") + .method(Method.POST) + .cookies(cookies) + .data("email", email) + .data("input_password", pass) + .data("csfr", csfr) + .execute() + + if (!response.cookies().containsKey("sid")) { + return false + } + + // Get cookies response (header) + cookies = response.cookies() + + // Add cookies generated by javascript response + cookies.put("redirect_url", "m=Profile&func=index") + cookies.put("tempHash", "m=Profile&func=index") + + response = Jsoup.connect("https://www.tuenti.com/") + .ignoreContentType(true) + .cookies(cookies) + .execute() + + val profilePage = response.parse().html() + val csfrIndex = profilePage.indexOf("csfr") + csrf = profilePage.substring(csfrIndex + 9, csfrIndex + 17) + val json_payload = parse(response.parse().select("#response_json_payload").text()) + + cookies.remove("temp_hash") + cookies.remove("redirect_url") + cookies.remove("ourl") + + loadAlbums(response) + + true + } + + private def loadAlbums(indexResponse: Response) = { + val pAlbums = indexResponse.parse().select("#albumSelector").select(".sel-block") + for(i <- 0 until pAlbums.size()) { + val href = pAlbums.get(i).attr("href") + val indexStart = href.lastIndexOf("y=") + 2 + val indexFinal = href.lastIndexOf("&") + val albumId = href.substring(indexStart, indexFinal) + val text = pAlbums.get(i).text() + val number = Integer.valueOf(text.substring(text.lastIndexOf("(") + 1, text.lastIndexOf(")")).replace(".","")) + val title = text.substring(0, text.lastIndexOf("(") - 1) + albums.put(albumId, (title, number)) + } + } + + def getAlbums = albums + + def getPhotosAlbumURLs(albumId: String): mutable.Set[String] = { + var response = Jsoup.connect("https://www-1.tuenti.com/index.cupcake.php?m=Albums&func=getAlbumPhotos&collection_key=" + albumId + "&ajax=1") + .ignoreContentType(true) + .cookies(cookies) + .execute() + val htmlAlbum = Jsoup.parse(compact(render(parse(response.body().substring(8)) \\ "renderOutput" \\ "albumPhotosContainer" \\ "html")).replace("\\", "")) + val photosElements = htmlAlbum.select("#albumBody").select("li") + val photos = mutable.HashSet[String]() + + for (i <- 0 until photosElements.size()) { + val id = photosElements.get(i).attr("id").substring(5) + photos.add(id) + } + + var i = 1 + var viewMore = "" + while (viewMore != "null") { + response = Jsoup.connect("https://www-1.tuenti.com/index.cupcake.php?m=Albums&func=getMorePhotosPage&collection_key=" + albumId + "&photos_page=" + i + "&ajax=1") + .ignoreContentType(true) + .cookies(cookies) + .execute() + + val moreJson = parse(response.body().substring(8)) + viewMore = compact(render(moreJson \\ "renderOutput" \\ "albumsViewMore" \\ "html")).replace("\\", "") + val htmlMore = compact(render(moreJson \\ "renderOutput" \\ "albumBody" \\ "html")).replace("\\", "") + + if (htmlMore != "null") { + val morePhotos = Jsoup.parse(htmlMore).select("li") + for (i <- 0 until morePhotos.size()) { + val id = morePhotos.get(i).attr("id").substring(5) + photos.add(id) + } + + i += 1 + } + } + + photos + } + + def getPhotoUrl(photoId: String): String = { + val response = Jsoup.connect("https://www-1.tuenti.com/index.cupcake.php?m=Photo&func=preloadPhotos&ajax=1") + .ignoreContentType(true) + .data("itemKey", photoId) + .data("backgrounded", "false") + .data("prefetchDirection", "10") + .data("offset", "0") + .data("pc", "{\"wt\":3}") + .data("csfr", csrf) + .data("csrf", csrf) + .method(Method.POST) + .cookies(cookies) + .execute() + + val photoJson = parse(response.body().substring(8)) + val photoUrl = compact(render(photoJson \\ "jsonData" \\ photoId \\ "url")) + val photoIndex = photoUrl.indexOf("\"", 8) + photoUrl.substring(8, photoIndex) + } + + def getPrivateMessagesURL: String = { + val response = Jsoup.connect("https://www-1.tuenti.com/?m=Messages&func=index&ajax=1") + .ignoreContentType(true) + .cookies(cookies) + .execute() + + val json = parse(response.body().substring(8)) + val html = Jsoup.parse(compact(render(json \\ "renderOutput" \\ "canvas" \\ "html")).replace("\\","")) + val button = html.select("button").attr("onclick") + val parent = button.indexOf("'") + val comma = button.lastIndexOf(",") + + button.substring(parent + 1, comma - 1) + } +} diff --git a/src/main/scala/io/callate/model/Utils.scala b/src/main/scala/io/callate/model/Utils.scala new file mode 100644 index 0000000..19a02a6 --- /dev/null +++ b/src/main/scala/io/callate/model/Utils.scala @@ -0,0 +1,22 @@ +package io.callate.model + +import java.io.{InputStream, FileOutputStream, BufferedOutputStream, File} +import java.net.URL +import java.nio.file.{Path, Files} + +import scala.sys.process._ + +object Utils { + def fileDownloader(url: String, filename: String) = { + new URL(url) #> new File(filename) !! + } + + def fileDownloader(is: InputStream, filename: Path) = { + Files.copy(is, filename) + } + + def mkdir(path: String) = new java.io.File(path).mkdirs + + def mkdirs(path: List[String]) = + path.tail.foldLeft(new File(path.head)){(a,b) => a.mkdir; new File(a,b)}.mkdir +}