11
Managing data projects

Deploying a successful data collection project requires more than knowledge of web technologies. The focus of this chapter is on R and operation system functionality that will be required for setting up and maintaining large-scale, automated data collection projects. Additionally, we discuss good practices to organize and write code that adds robustness and traceability in case of errors. In Section 11.1, we start by providing an overview of R functions for interacting with the local file system. In Section 11.2, we show methods for iterative code execution for downloading pages or extracting relevant information from multiple web documents. Section 11.3 provides a template for organizing extraction code and making it more robust to failed specification. We conclude the chapter with an overview of system tools that can executive R scripts automatically, which is a key requirement for building datasets from regularly updated Internet resources (Section 11.4).

11.1 Interacting with the file system

One type of R function that appears frequently in data projects is dedicated to working with files and folders on the local file system. Over the course of a data project, we are continuously interacting with the file system of our operating system. Web documents are stored locally, loaded into R, processed, and saved again after the post-processing or analysis. The file system has an important role in the data collection and analysis workflow and a firm command over the hard drive constitutes a valuable auxiliary skill. For the numerous virtues of a scripted approach, any interaction with the file management system should be performed in a programmable fashion. Luckily, R provides an extensive list of functions for interacting with the system and files located in it. Table 11.1 provides an overview of the basic file management functions which we rely upon in the case studies and in data projects more generally.

Table 11.1 Basic R functions for folder and file management

Function Important arguments Description
Functions for folder management
dir() path Returns character vector of the names of files and directories in path
dir.create() path, recursive Creates new directory in path (only the last element). If recursive=T all elements on the path are created.
Functions for file management
file.path() ... Constructs a file path of character elements
file.info() path Returns character vector with information about a file in path
file.exists() path Returns logical value whether a file already exists in path
file.access() names, mode Tests files in names for existence (mode=0), execute permission (mode=1), writing permission (mode=2), read permission (mode=4). Returns integer with values 0 for success and −1 for failure
file.rename() from, to Renames a file in path from to a name in to
file.remove() path Deletes a file in path from the hard drive
file.append() file1, file2 Appends contents in file2 to file1
file.copy() from, to Creates a copy of a file in path from to path to
basename() path Returns the lowest level in a path
dirname() path Returns all but the lower level in a path
Functions for working with compressed files
zip() zipfile, files Create a zip file in path zipfile of files
unzip() zipfile, files Extracts specific files (all when unspecified) from a zip file in path zipfile

Path arguments may usually be passed as complete or incomplete paths. In the latter case, paths are expanded to the working directory (getwd()). File paths may be passed in abbreviated form without the user's home directory. (path.expand() is used to replace a leading tilde by the user's home directory.)

11.2 Processing multiple documents/links

A frequently encountered task in web scraping is executing a piece of code repeatedly. In fact, using a programming language for data collection is most valuable as it allows the researcher to automate tasks that would otherwise have to be done in tedious and time-consuming manual processing of every file, URL, or document. To exemplify, consider the problem of downloading a bunch of HTML sites from a vector of URLs. Another job is the processing of multiple web documents, such as the pages from a news website where the task is extracting the text corpora, or the extraction of tabular information from economic indicators organized in XML files to create a single database.

This section illustrates that with only a little bit of overhead one can instruct R to repeatedly execute a function on a set of files and, thus, comfortably download countless pages or scan thousands of documents and reassemble the extracted information. We introduce two ways to accomplish this goal; the first one is through the use of standard R looping structures and the second one is through functionality from the plyr package.

11.2.1 Using for-loops

For illustrative purposes, we consider the set of 11 XML files that are located in the folder /stocks.1 The XML files contain stock information for four technology companies. Our interest is in extracting the daily closing values for the Apple stock over all years (2003–2013). We can divide this problem into two subtasks. First, we need to come up with an extraction code that loads the file, extracts, and recasts the target information into the desired format. The second task is executing the extraction code on all XML files. A straightforward approach is to wrap the extraction code in a for-loop. Loops are standard programming structures that help formulate an iterating statement over the set of documents from which information needs to be extracted.

A first step is to obtain the names of the files that we would like to process. To this end, we use the dir() function to produce a character vector with all the file names in the current directory. The file names are inserted into a new object called all_files and its content is printed to the screen.


R> all_files <- dir(”stocks”)
R> all_files
[1] ”stocks_2003.xml” ”stocks_2004.xml” ”stocks_2005.xml”
[4] ”stocks_2006.xml” ”stocks_2007.xml” ”stocks_2008.xml”
[7] ”stocks_2009.xml” ”stocks_2010.xml” ”stocks_2011.xml”
[10] ”stocks_2012.xml” ”stocks_2013.xml”

Next, we need to create a placeholder in which we can store the extracted stock information from each file. Although it might be necessary to obtain a data frame at the end of the process for analytical purposes, we set up a list as an intermediate data structure. Lists provide the flexibility to collect the information which we recast only afterwards. We create an empty list that we name closing_stock and which serves as a container for the yearly stock information from each file.


R> closing_stock <- list()

The core of the extraction routine consists of a for-loop over the number of elements in the all_files character vector. This structure allows iterating over each of the files and work on their contents individually.

images

First, we construct the path of each XML file and save the information in a new object called path. The information is needed for the next step, where we pass the path to the parsing function xmlParse(). This creates the internal representation of the file inside a new object called parsed_stock. Finally, we obtain the desired information from the parsed object by means of an XPath statement. Here, we pull the entire Apple node and do the post-processing in the extractor function, which we discuss below. The return value from xpathSApply() is stored at the ith position our previously defined list. The get_stock() extractor function is a custom function that works on the entire Apple node and returns the date and closing value for each day.

images

We go ahead and unlist the container list to process each information individually and put it into a more convenient data format. Here we go for a data frame and choose more appropriate column names.


R> closing_stock <- unlist(closing_stock)
R> closing_stock <- data.frame(matrix(closing_stock, ncol = 2, byrow = T))
R> colnames(closing_stock) <- c(”date”, ”value”)

Finally, we recast the value information into a numerical vector and the date information into a vector of class Date.


R> closing_stock$date <- as.Date(closing_stock$date, ”%Y/%m/%d”)
R> closing_stock$value <- as.numeric(as.character(closing_stock$value))

We are ready to create a visual representation of the extracted data. We use plot() to create a time-series of the stock values. The result is displayed in Figure 11.1.


R> plot(closing_stock$date, closing_stock$value, type = ”l”, main 
= ””, ylab = ”Closing stock”, xlab = ”Time”)
images

Figure 11.1 Time-series of Apple stock values, 2003–2013

11.2.2 Using while-loops and control structures

A second control statement that we can use for iterations is the while() expression. Instead of iterating over a fixed sequence, it will run an expression as long as a particular condition evaluates to TRUE. Consider the snippet below for an abstract usage of the expression.

images

We set the a to 0 and while the a is lower than 3 it will continue looping. In each iteration we add 1 to a and print the value of a to the screen. Once the a has reached the critical value of 3, the loop will break.

Apart from setting a condition in the while() statement that evaluates to FALSE at some point, thus breaking the loop, we can also break a loop with an if() clause and a break command.

images

In the above snippet, we set a condition in the while() statement that will always evaluate to TRUE, thus creating an infinite loop. Instead, in each iteration we test whether a is equal to or greater than 0. If that condition is TRUE, the break is encountered which forces R to break the current loop. Notice how we used the if() clause in the snippet.

In web scraping practice, the while() statement is handy to iterate over a set of documents where you do not know the total number of documents in advance. Consider the following scenario. You care to download a selection of HTML documents where the link to additional documents is embedded in the source code of the last inspected HTML document, say in a link to a NEXT document. If you do not happen to find a counter at the bottom of the page that contains information on the total number of pages, there is no way of specifying the number of pages you can expect. In such a case, you can apply the while() statement to check for the existence of a link before accessing the document.

images

images

11.2.3 Using the plyr package

The data structure which we typically wish to produce is tabular with variables populating the columns and each row presenting a case or unit of analysis. Producing tabular data structures from multiple web documents can be achieved easily using functionality from the plyr package (Wickham 2011) which allows performing an extraction routine more quickly on multiple documents. To illustrate, let us run through the previous example in the plyr framework. As the first step, we construct the paths to the XML files on the local hard drive.


R> files <- str_c(”stocks/”, all_files)

We create a function getStock2() that parses an XML file and extracts relevant information. This code is similar to the one we used before.

images

The function returns an n × 2 data frame with the first column holding information on the date and the second one on the closing stock. We are now set to evoke ldply() and initiate the extraction process.


R> library(plyr)
R> appleStocks <- ldply(files, getStock2)

For its input ldply() requires a list or a vector and it returns a dataframe. ldply() executes getStock2() on every element in files and binds the results row-wise. We confirm that the procedure has worked correctly by printing the first five lines to the console.

images

If you are dealing with larger file stacks, plyr also provides the option parallel which, if set to TRUE, parallelizes the code execution which can speed up the process.

11.3 Organizing scraping procedures

When you begin scraping the Web regularly, you will find that numerous tasks come up over and over again. One of the central principles of good coding practice states that you should never repeat yourself. If you find yourself rewriting certain lines of code or copy-pasting elements of your code, then it is time to start thinking about organizing your code in a more efficient way. Problems arise when you need to trace all the places in your scripts where some particular functionality has been defined. The solution to this problem is to wrap your code in functions and store them in dedicated places. Not only does this guarantee that revisions happen in only one place, but it also greatly simplifies the maintenance of code.

Besides ensuring a better maintenance of code, writing functions also allows a generalization of functionality. This is a great improvement of your code when you want to apply a sequence of operations to lots of data, as is often the case in web scraping. In fact, by writing your code into functions you can frequently speed up the execution time of your R code dramatically by applying the function on a list or a vector via one of the apply functions from the plyr package as shown in the previous section. This section serves to elaborate how to modularize your code by using functions.

We demonstrate the use of functions with a scenario that we already discussed in Section 9.1.4. Imagine that we want to collect all links from a website. We have learned that the XML package provides the function getHTMLLinks() which makes link collection from HTML documents quite convenient. In fact, this function is a good example for a function which help tackle a frequently occurring task. Imagine that the function did not exist and we needed to build it.

We have learned in Chapter 2 that links are stored in href attributes of <a> elements. Our task is thus simply to collect the content of all nodes with this attribute. Let us begin by loading the necessary packages and specifying a URL that will serve as our running example.


R> library(RCurl)
R> library(XML)
R> url <- ”http://www.buzzfeed.com

We can perform the task for this single website by calling and parsing it via htmlParse() and collecting the relevant information via xpathSApply() .


R> parsed_page <- htmlParse(url)
R> links <- xpathSApply(parsed_page, ”//a[@href]”, xmlGetAttr, ”href”)

R> length(links)
[1] 945

Now imagine that we care to apply these steps to several websites. To apply these three steps to other sites we wrap them into a single function we call collectHref(). This is done by storing the necessary steps in an object and calling the function function(). The argument in the function call represents the object that the function is supposed to run on, in this case the URL.

images

Now we can simply run the function on various sites to collect all the links. First we apply it to the URL we specified above and then we try out a second page.


R> buzzfeed <- collectHref(”http://www.buzzfeed.com”)

R> length(buzzfeed)
[1] 945

R> slate <- collectHref(”http://www.slate.com”)

R> length(slate)
[1] 475

We are able to generalize functions by adding arguments to it. For example, we can add a variable to our function that will discard all links that do not explicitly begin with http. This could be done with a simple regular expression that detects whether a string begins in http, using the str_detect() function from the stringr package.

images

Notice that we also added a test to the function that checks whether begins.http is a logical value. If not, the function will throw an error (produced by stop()) and not return any results. Let us run the altered function on our example URL, and set begins.http to TRUE.


R> buzzfeed <- collectHref(url, begins.http = TRUE)
R> length(buzzfeed)
[1] 63

The vector of links has shrunk considerably. Now let us call the function on the base URL again, but change the begins.http variable to the wrong type.


R> testPage <- collectHref(url, begins.http = ”TRUE”)
Error: begins.http must be a logical value

We can add any number of arguments to the function. In order not to have to specify the value for each argument whenever we call the function, we can set predefined values for the arguments. For example, we can set begins.http to TRUE by default in the function definition.

images

Thus, whenever we call the function, it will assume that we care to collect only those links that explicitly contain the sequence http.

Once you start writing functions in R, you will find that grouping them into topical files is the most sensible way to collect functions for use in various projects. The advantage of generating a set of functions in modules ensures that you have to modify specific functions only in one location and that you do not have to create the same functionality over and over again with each project that you are beginning. Instead you can call the necessary module when you start a new project, almost like you would load a library that you download from CRAN.

A reasonable approach for storing such recurring functions is to create a dedicated folder where functions are stored in dedicated R script files. Whenever you want to draw on one of these functions, you can access them using the source() command. This automatically evaluates code from foreign R source files. Imagine we have stored our function from above in a file named collectHref.r. In order to run the command, we proceed as follows:


R> source(”collectHref.r”)
R> test_out <- collectHref(”http://www.buzzfeed.com”)
R> length(test_out)
[1] 63

Eventually you can also go one step further and create R packages yourself and upload them to CRAN or GitHub .

11.3.1 Implementation of progress feedback: Messages and progress bars

When performing a web scraping task, it can often be useful to receive a visual feedback on the progress that our program has made in order to get a sense of when your program will have finished. A very basic version of such a feedback would be a simple textual printout directly to the R console. We can accomplish this with the cat() function. To illustrate, consider the problem of downloading the stock XML files from the book homepage. The files are stored in the following path: http://www.r-datacollection.com/materials/workflow/stocks. Let us start by building a character vector for their URLs and save them in a new object called links.


R> baseurl <- ”http://www.r-datacollection.com/materials/workflow/stocks”
R> links <- str_c(baseurl, ”/stocks_”, 2003:2013, ”.xml”)

Next, we set up a loop over the length of links. Inside the loop, we download the file, create a sensible name using basename() to return the source file name, and then write the XML code to the local hard drive.

images

In the final line, we ask R to print the number of the document just downloaded to the console. We append the message with a so each new output is written to a new line.

We can enrich the information in the feedback, for example, by adding the name of the file that is currently being downloaded.

images

In some cases, you might not want to get output on each individual case but only create summary information. One possibility for this is to shorten the output by providing information on, say, each tenth download. We add an if() statement to our code such that only those iterations are printed where the i divided by 10 does not result in a fraction.

images

Incidentally, you might want to store the progress information in an external file for later inspection to be able to trace potential errors. Possibly, this should consist of more extensive information than what is printed to your screen. For example, you can use the write() command to write the progress to a log file that is appended in each iteration. We begin by creating an empty file on our local hard drive.


R> write(””, ”download.txt”)

We then append the information that is written to the screen to the external file. To make the information a little more useful for later inspection, we add information on the number of characters in the downloaded file. We also add dashes and a space to visually separate the various downloads.

images

In many instances, the best feedback is textual. Nevertheless, you can also create other types of feedback. For instance, you can easily create a simple progress bar for your function using the txtProgressBar() that is predefined in R. For our example we start by initializing a progress bar with the extreme values of 0 and N, that is 3. We set the style of the progress bar to 3, which generates a progress bar that displays the percentage of the task that is done at the right end of the bar.


R> progress_bar <- txtProgressBar(min = 0, max = N, style = 3)

Next, we download the documents once more with the shortest version of the code that was introduced previously. We add the command setTxtProgressBar() to our call which sets the value of the progress bar that we initialized above. The first argument specifies which progress bar we want to change the value of, the second argument sets the value, in this case the values 1, 2, and 3. We add a 1 second delay after each iteration using the Sys.sleep() function, so you can clearly see the development of the progress bar.

images

You can even create audio cues to signal that the execution of a piece of code is complete. For example, the escape sequence a calls the alert bell.2

images

Imagine a ping! when reading cat(”a”) in the last code snippet.

11.3.2 Error and exception handling

When you start to scrape the Web seriously, you will begin to stumble across exceptions. For example, websites might not be formatted consistently such that you will not find all the elements that you are looking for. It is sometimes difficult to build your functions sufficiently robust to be able to deal with all the exceptions. This section introduces some simple techniques that can help you overcome such problems. Let us consider as an example the same task that we have looked at in Section 11.3—downloading a list of websites. First, we expand the list with a mistyped URL.


R> wrong_pages <- c(”http://www.bozzfeed.com”, links)

When we try to download the content of all of the sites to our hard drive using a simple loop over all the entries, we find that this operation fails as the function is unable to collect the first entry in our vector. The problem with errors is that they break the execution of the entire piece of code. Even though the remaining three entries could have been collected with the code snippet as we have previously shown, the single false entry stops the execution altogether. The simplest way to change this behavior is to wrap the getURL() expressions in a try() statement.

images

images

Notice that we added a statement to test the class of the url object. If the object is of class try-error, we do not write the content of the object to the hard drive. The disadvantage of wrapping code in try() statements is that you discard errors as inconsequential. This is a strong assumption, as something has gone wrong in your code and frequently it makes sense to consider more carefully what exception you encountered. R also offers the tryCatch() function which is a more flexible device for catching errors and defining actions to be performed as errors occur. For example, you could log the error to consider the systematics of the errors later on. First, we create a function that combines the two steps of our task in a single function. We also set up a log file to export errors and the relevant URL during the execution of the code.

images

We customize the error handling in the tryCatch() statement by making it print Not available and the name of the website that cannot be accessed.3

images

11.4 Executing R scripts on a regular basis

On many websites, smaller or larger parts of the contents are changed on a regular basis, which renders these resources dynamic. To exemplify, imagine a news site that publishes new articles every other hour or the press release repository of a non-governmental organization that adds new releases sporadically.

Implicitly, we assumed so far that scraping can be carried out in a one-time job. Yet, when dynamic web resources are concerned, it might be a key aspect of a data project to collect information over a longer period of time. While nothing prevents us from manually executing a script in regular intervals, this process is cumbersome and error-prone. This section discusses ways to free the data scientist from this responsibility by setting up a system task that initiates the scraping automatically and in the background. To this end, we employ tools that are built right into the architecture of all modern operating systems for scheduling the execution of programs. We provide an introduction to these tools and show how an R scraping script can be invoked in user-defined intervals.

We motivate this section with the problem of downloading information from http://www.r-datacollection.com/materials/workflow/rQuotes.php. Scraping this site is complicated by its very dynamic nature—the site changes every minute and displays a different R quote. We set ourselves to the task of downloading a day's worth of quotes. Clearly, a manual approach is out of the question for obvious reasons. We approach the problem by first assembling an R script that allows downloading and storing information from one instance of the site. The first line loads the stringr package and the second makes sure that we have a folder called quotes that serves as a container for the downloaded pages. The next three lines are overhead for the file names that include the date and time of the download. The last line conducts the download, using R’s built-in download.file() function. We save the downloading routine under the name getQuotes.R.


R> library(stringr)
R> if (!file.exists(”quotes”)) dir.create(”quotes”)
R> time <- str_replace_all(as.character(Sys.time()), ”:”, ”_”)
R> fname <- str_c(”quotes/rquote ”, time, ”.html”)
R> url <- ”http://www.r-datacollection.com/materials/workflow/rQuotes.php”
R> download.file(url = url, destfile = fname)

In the remainder of this section, we describe how to embed the R script with system utilities for regular execution in predefined intervals. We discuss solutions for Linux, Mac OS, and Windows.

11.4.1 Scheduling tasks on Mac OS and Linux

For users working on a UNIX-like operating system such as Mac OS or Linux, we propose using Cron for the creation and administration of time-based tasks. Cron is a preinstalled general-purpose system utility that allows setting up so-called jobs that are being run periodically or at designated times in the background of the system.

For the administration of tasks, Cron uses a simple text-based table structure called a crontab. A crontab includes information on the specific actions and the times when the actions should be executed. Notice that Cron will run the jobs regardless of whether the user is actually logged into the system or not. Although graphical interfaces exist to set up a task, it is convenient and quick to edit tasks using a text editor. To create a new task, open a system shell4 and write.

images

This command opens the crontab specific to your logged in user in the default editor of your system. If you prefer a different text editor, prepend the command with the respective editor's name (e.g., nano, emacs, gedit). Conditional on the OS you use, the text file you are being shown can be empty or include some general comments on how this file can be edited. In any case, since crontab requires that each task has to appear on a separate line, go ahead and point the prompt to the last line of the file. The general layout of a crontab follows the pattern “[time] [script],”where the script component refers to a shell command and the time component describes the temporal pattern by which the script is executed.

Cron has its own time format to express chronological regularity. Essentially, this time format consists of five fields, separated by white space, that refer to the minute, hour, day, month, and weekday on which the task is to be executed. Take a look at Table 11.2 to learn about the allowed values for each of the five time fields. Notice, that any of the five fields may be left unspecified which in the Cron time format is indicated by the asterisk symbol *.

Table 11.2 The five field Cron time format

Field Description Allowed values
MIN Minute field 0–59
HOUR Hour field 0–23 (0 = midnight)
DOM Day of Month field 1–31
MON Month field 1–12 or literals
DOW Day of Week field 0–6 (0 = Sunday) or literals

Source: Adapted from http://www.thegeekstuff.com/2009/06/15-practical-crontab-examples/

From this basic template, we can construct a wide range of temporal patterns for task execution. To illustrate their capability, take a look at the following three specifications.

15 16 * * * executes the script everyday quarter past four
15 16 * 1 * executes the script everyday quarter past four when the month is January
15 16 * 1 0 executes the script everyday quarter past four when the month is January and it's Sunday

In any of these five fields, one can produce an unconnected or connected series of time units by using “,” or “-” respectively.

15 10-20 * * * executes the script quarter past every hour from 10 am to 8 pm
15 10-20 * * 6,0 executes the script quarter past every hour from 10 am to 8 pm on Saturdays and Sundays

In many circumstances, exact specification of a time is overly rigid for a given task. Instead, one can use the Cron time schema to express the intention of having a task executed in certain time intervals. The preferred way to do this is by using a “*/n”construct that specifies an interval of length n for the respective time unit. To illustrate, consider the following examples.

*/15 * * * * executes the script every 15 minutes
15 0 */2 * * executes the script 15 minutes past midnight on every second day

The second piece of information in any Cron job is the shell command that has to be executed regularly. If you have never worked with the shell before, think of it as a command line based user-interface for accessing the operating system and installed programs (such as R). In order to set up a new task for the execution of getQuotes.r every minute, we append the following line to the crontab:

images

We first specify the chronological pattern “*/1 * * * *” for every-minute repeated execution. This is followed by the scripting part, where we first change the directory to the folder in which getQuotes.r is saved and then use the Rscript-executable on getQuotes.r. Rscript is a scripting front-end that should be used in cases when an R script is executed via the shell. Once you have saved the crontab, the task is active and should be executed in the background.

For the maintainability of Cron-induced R routines, it is helpful to retain an overview over the outputs that are generated from the script, such as warnings or errors. The UNIX shell allows to route the output of the R script to a log file by extending the Cron job as follows:

images

11.4.2 Scheduling tasks on Windows platforms

On Windows platforms, the Windows Task Scheduler is the tool for scheduling tasks. To find the tool click Start > All Programs > Accessories > System Tools > Scheduled Tasks.

To set up a new task, double-click on Create Task. From here, the procedure differs according to your version of Windows, but the presented options should be very similar. On Windows 7, we are presented with a window with five tabs—General, Triggers, Actions, Conditions, and Settings. Under General we can provide a name for the task. Here we put in Testing R Batch Mode for a descriptive title.

In the field Triggers we can add several triggers for starting the task—see Figure 11.2. There are schedule triggers which start the task every day, week, or month and also triggers that refer to events like the startup of the computer or when it is in idle mode, and many more. To execute getQuotes.r every minute for 24 hours, we select On a schedule as general trigger and define that it should be executed only once but repeated every 1 minutes for 1 day. Last but not least, we should make sure that the start date and time of our one-time scheduled task should be placed somewhere in the future when we will be done specifying the schedule.

images

Figure 11.2 Trigger selection on Windows platform

images

Figure 11.3 Action selection on Windows platform

After the trigger specification we still have to tell the program what to do if the task is triggered. The Actions tab is the right place to do that—see Figure 11.3. We choose Start a program for action and use the browse button to select the destination of Rscript.exe, which should be placed under, for example, C:ProgramFilesRR-3.0.2inx64. Furthermore, we add getQuotes.r in the Add arguments field and type in the directory where the script is placed in the Start in field. If logging is needed we modify the procedure.

  • Program/script field: replace Rscript.exe by R.exe
  • Add arguments field: replace getQuotes.r by CMD BATCH –vanilla getQuotes.r log.txt

While CMD BATCH tells R to run in batch mode, –vanilla ensures that no R profiles or saved workspaces are stored or restored that might interfere with the execution of the script. log.txt provides the name for the logfile. Now we can confirm our configuration and click on Task Scheduler Library in the left panel to get a list of all tasks available on our system.

Notes

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.145.8.222