Compare commits
10
Commits
a7832c27e1
...
062656038f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
062656038f | ||
|
|
f65143a3fd | ||
|
|
1f39fc7dc8 | ||
|
|
aa9dcd639c | ||
|
|
ca61b2fcba | ||
|
|
79c706730b | ||
|
|
055d864490 | ||
|
|
0d61b36cb3 | ||
|
|
21721b51d1 | ||
|
|
b92643eebf |
@@ -0,0 +1,28 @@
|
||||
BINARY_NAME=reactor-crw
|
||||
BIN_DIR=bin
|
||||
BUILD_PATH=${BIN_DIR}/${BINARY_NAME}
|
||||
VERSION=0.0.1
|
||||
|
||||
.PHONY: build clean test test-coverage dep vet
|
||||
|
||||
build:
|
||||
@mkdir -p $(BIN_DIR)
|
||||
GOOS=darwin GOARCH=amd64 go build -ldflags "-s -w" -o ${BUILD_PATH}_${VERSION}_macOS_64bit cmd/main.go && upx --best --lzma ${BUILD_PATH}_${VERSION}_macOS_64bit
|
||||
GOOS=linux GOARCH=amd64 go build -ldflags "-s -w" -o ${BUILD_PATH}_${VERSION}_Linux_64bit cmd/main.go && upx --best --lzma ${BUILD_PATH}_${VERSION}_Linux_64bit
|
||||
GOOS=windows GOARCH=amd64 go build -ldflags "-s -w" -o ${BUILD_PATH}_${VERSION}_Windows_64bit.exe cmd/main.go && upx --best --lzma ${BUILD_PATH}_${VERSION}_Windows_64bit.exe
|
||||
|
||||
clean:
|
||||
@go clean
|
||||
@rm -rf ${BIN_DIR}
|
||||
|
||||
test:
|
||||
go test ./... --tags unit
|
||||
|
||||
test-coverage:
|
||||
go test ./... --tags unit -coverprofile=coverage.out
|
||||
|
||||
dep:
|
||||
go mod download
|
||||
|
||||
vet:
|
||||
go vet
|
||||
@@ -0,0 +1,56 @@
|
||||
Reactor Crawler
|
||||
==========
|
||||
[](https://github.com/reactor-joy/reactor-crw/actions/workflows/go.yml)
|
||||
[](https://goreportcard.com/report/github.com/reactor-joy/reactor-crw)
|
||||
|
||||
Simple CLI content crawler for [Joyreactor](http://joyreactor.cc). He'll find all media content on the
|
||||
page you've provided and save it. If there will be any kind of pagination... he'll go through all pages
|
||||
as well unless you'll tell him to not.
|
||||
|
||||
<p>
|
||||
<img src="https://i.imgur.com/DjD6cW0.gif" width="800" alt="reactor_crawler_example">
|
||||
</p>
|
||||
|
||||
## Quick start
|
||||
|
||||
Here's the quickest way to download something and test the crawler:
|
||||
* Download a build according to your OS.
|
||||
* Pick some URL from [Joyreactor](http://joyreactor.cc).
|
||||
* Run the crawler `$ reactor-crw -p "http://joyreactor.cc/tag/digital+art"`
|
||||
|
||||
## What else
|
||||
|
||||
There's a list of optional flags that adds a little more control over the crawler.
|
||||
|
||||
```
|
||||
$ reactor-crw --help
|
||||
|
||||
Allows to quickly download all content by its direct url or entire tag or fandom from joyreactor.cc.
|
||||
Example: reactor-crw -d "." -p "http://joyreactor.cc/tag/someTag/all" -w 2 -c "cookie-string"
|
||||
|
||||
Usage:
|
||||
reactor-crw [flags]
|
||||
|
||||
Flags:
|
||||
-c, --cookie string User's cookie. Some content may be unavailable without it
|
||||
-d, --destination string Save path for content. Default value is a user's home folder
|
||||
(example C:\Users\username for Windows) (default "/home/avpretty")
|
||||
-h, --help help for reactor-crw
|
||||
-p, --path string Provide a full page URL
|
||||
-s, --search string A comma separated list of content types that should be downloaded.
|
||||
Possible values: image,gif,webm,mp4. Example: -s "image,webm" (default "image,gif")
|
||||
-o, --single-page Crawl only one page
|
||||
-w, --workers int Amount of workers (default 1)
|
||||
```
|
||||
|
||||
From all flags only `-p --path` is required. All other flags can be omitted and default values will be used.
|
||||
|
||||
Here's another example:
|
||||
|
||||
```
|
||||
$ reactor-crw -p "http://joyreactor.cc/post/000000" -d "." -s "mp4" -o -c "cookies from joyreactor"
|
||||
```
|
||||
This one will download only `mp4` content from the post and will save it to the current directory.
|
||||
`-o` means that only the current page will be parsed, and the user's cookie `-s` will be used by the crawler.
|
||||
|
||||
**Note**: some content may be parsed only with user's cookie.
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"reactor-crw"
|
||||
"reactor-crw/handler/fs"
|
||||
"reactor-crw/parser"
|
||||
|
||||
"github.com/vbauerster/mpb/v7"
|
||||
"github.com/vbauerster/mpb/v7/decor"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
search string
|
||||
path string
|
||||
savePath string
|
||||
cookie string
|
||||
maxWorkers int
|
||||
singlePage bool
|
||||
|
||||
crawlerCmd = &cobra.Command{
|
||||
Use: "reactor-crw",
|
||||
Short: "Grab your favorite content from joyreactor.cc",
|
||||
Long: "Allows to quickly download all content by its direct url or entire" +
|
||||
" tag or fandom from joyreactor.cc.\nExample: reactor-crw -d \".\" " +
|
||||
"-p \"http://joyreactor.cc/tag/someTag/all\" -w 2 -c \"cookie-string\"",
|
||||
Run: run,
|
||||
}
|
||||
)
|
||||
|
||||
func init() {
|
||||
hd, _ := os.UserHomeDir()
|
||||
|
||||
crawlerCmd.Flags().StringVarP(&search, "search", "s", "image,gif", "A comma separated list of content types that should be downloaded.\nPossible values: image,gif,webm,mp4. Example: -s \"image,webm\"")
|
||||
crawlerCmd.Flags().StringVarP(&path, "path", "p", "", "Provide a full page URL")
|
||||
crawlerCmd.Flags().StringVarP(&savePath, "destination", "d", hd, "Save path for content. Default value is a user's home folder \n(example C:\\Users\\username for Windows)")
|
||||
crawlerCmd.Flags().StringVarP(&cookie, "cookie", "c", "", "User's cookie. Some content may be unavailable without it")
|
||||
crawlerCmd.Flags().IntVarP(&maxWorkers, "workers", "w", 1, "Amount of workers")
|
||||
crawlerCmd.Flags().BoolVarP(&singlePage, "single-page", "o", false, "Crawl only one page")
|
||||
|
||||
_ = crawlerCmd.MarkFlagRequired("path")
|
||||
}
|
||||
|
||||
func run(_ *cobra.Command, _ []string) {
|
||||
start := time.Now()
|
||||
t := reactor_crw.NewHttpTransport(&http.Client{}, reactor_crw.Headers{"Cookie": cookie})
|
||||
|
||||
pathUrl, err := url.Parse(path)
|
||||
if err != nil {
|
||||
log.Fatalf("invalid path provided: %s", path)
|
||||
}
|
||||
|
||||
pr, err := fs.NewPathResolver(savePath)
|
||||
if err != nil {
|
||||
log.Fatalf("cannot process provided destination: %s", savePath)
|
||||
}
|
||||
|
||||
ch, _ := fs.NewFileSaver(pr, t, strings.Replace(pathUrl.Path, "/", "_", -1))
|
||||
c := reactor_crw.NewClient(
|
||||
&reactor_crw.HtmlCrawler{
|
||||
Transport: t,
|
||||
Parser: &parser.Html{},
|
||||
MultiPage: !singlePage,
|
||||
},
|
||||
maxWorkers,
|
||||
ch,
|
||||
)
|
||||
|
||||
go func() {
|
||||
err = c.Run(path, search)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}()
|
||||
|
||||
progress(c.TotalSources, c.Progress, c.Errors)
|
||||
|
||||
fmt.Printf("\n>>> Done in %s\n", time.Since(start).String())
|
||||
}
|
||||
|
||||
func progress(total, task <-chan int, err <-chan error) {
|
||||
fmt.Print("\n>>> Trying to count the amount of links. Please wait...\n\n")
|
||||
|
||||
t := <-total
|
||||
if t == 0 {
|
||||
fmt.Print(">>> No links were found. Stopping...\n")
|
||||
return
|
||||
}
|
||||
fmt.Printf(">>> %d links were found. Start downloading\n\n", t)
|
||||
|
||||
prg := mpb.New(mpb.WithWidth(64))
|
||||
|
||||
name := ">>> Progress:"
|
||||
bar := prg.AddBar(int64(t),
|
||||
mpb.PrependDecorators(
|
||||
decor.Name(name, decor.WC{W: len(name) + 1, C: decor.DidentRight}),
|
||||
decor.CountersNoUnit("%d/%d", decor.WCSyncWidth),
|
||||
),
|
||||
mpb.AppendDecorators(decor.Percentage(decor.WC{W: 5})),
|
||||
)
|
||||
|
||||
errBuf := strings.Builder{}
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-task:
|
||||
bar.Increment()
|
||||
case e := <-err:
|
||||
if e != nil {
|
||||
errBuf.WriteString(e.Error() + "\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
prg.Wait()
|
||||
|
||||
if errBuf.Len() != 0 {
|
||||
fmt.Printf(">>> Errors during crawler work:\n%s", errBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
err := crawlerCmd.Execute()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
package reactor_crw
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
|
||||
"reactor-crw/parser"
|
||||
)
|
||||
|
||||
// Transport is an interface that wraps all network related operations performed
|
||||
// by crawler.
|
||||
type Transport interface {
|
||||
FetchData(url string) (io.ReadCloser, error)
|
||||
}
|
||||
|
||||
// HtmlCrawler allows crawling HTML pages using provided transport.Transport and
|
||||
// parser.Parser. The crawler doesn't do anything with the content itself it only
|
||||
// gathers the collection of sources links.
|
||||
type HtmlCrawler struct {
|
||||
// Transport performs all network request.
|
||||
Transport Transport
|
||||
|
||||
// Parser contains parser.Parser implementation for HTML.
|
||||
Parser parser.Parser
|
||||
|
||||
// MultiPage allows applying the crawler for multiple pages depending on its
|
||||
// value. If its value set to true the crawler will try to calculate the
|
||||
// number of available pages using the source page.
|
||||
MultiPage bool
|
||||
}
|
||||
|
||||
// Fetch retrieves content sources from the page using the path value. Depending
|
||||
// on HtmlCrawler.MultiPage it may fetch content links from multiple pages.
|
||||
func (c *HtmlCrawler) Fetch(path string, search []string) (parser.QueryResult, error) {
|
||||
if c.MultiPage {
|
||||
return c.multiPage(path, search)
|
||||
}
|
||||
|
||||
collectedData := make(parser.QueryResult)
|
||||
|
||||
err := c.fetch(path, search, collectedData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return collectedData, nil
|
||||
}
|
||||
|
||||
// fetchMultiPage will fetch content sources from multiple pages. It'll try to
|
||||
// retrieve the number of pages and consequently crawl all pages.
|
||||
func (c *HtmlCrawler) multiPage(path string, search []string) (parser.QueryResult, error) {
|
||||
maxPage, err := c.resolveMaxPage(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
collectedData := make(parser.QueryResult)
|
||||
|
||||
for p := 1; p <= maxPage; p++ {
|
||||
err = c.fetch(fmt.Sprintf("%s/%d", path, p), search, collectedData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return collectedData, nil
|
||||
}
|
||||
|
||||
func (c *HtmlCrawler) fetch(path string, search []string, qr parser.QueryResult) error {
|
||||
body, err := c.Transport.FetchData(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func(b io.ReadCloser) {
|
||||
_ = b.Close()
|
||||
}(body)
|
||||
|
||||
err = c.Parser.FindAttrMap(body, buildQuery(search), qr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot apply crawler: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *HtmlCrawler) resolveMaxPage(path string) (int, error) {
|
||||
const htmlPagination = ".pagination_expanded .current"
|
||||
|
||||
body, err := c.Transport.FetchData(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer func(b io.ReadCloser) {
|
||||
_ = b.Close()
|
||||
}(body)
|
||||
|
||||
pa, err := c.Parser.FindContent(body, htmlPagination)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
intPa, err := strconv.Atoi(pa)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("cannot prosess pagination: %w", err)
|
||||
}
|
||||
|
||||
return intPa, nil
|
||||
}
|
||||
|
||||
// query represents a search query that will be applied against the parser.
|
||||
// Each query has a query itself to find corresponding elements, its attr,
|
||||
// content of which should be returned, contentType marks a type of content
|
||||
// and used for fetching specific types only.
|
||||
type query struct {
|
||||
contentType string
|
||||
query string
|
||||
attr string
|
||||
}
|
||||
|
||||
var queries = []query{
|
||||
{"image", ".post_content .image > img", "src"},
|
||||
{"image", ".post_content .image > a", "href"},
|
||||
{"gif", ".post_content .video_gif_source", "href"},
|
||||
{"mp4", ".post_content .video_gif source[type='video/mp4']", "src"},
|
||||
{"webm", ".post_content .video_gif source[type='video/webm']", "src"},
|
||||
}
|
||||
|
||||
// buildQuery builds a parser.QueryAttrMap according to provided search list.
|
||||
// The resulting parser.QueryAttrMap will contain only those queries that meet
|
||||
// required content types.
|
||||
//
|
||||
// Example: buildQuery([]string{"image", "mp4"}).
|
||||
func buildQuery(search []string) parser.QueryAttrMap {
|
||||
qa := parser.QueryAttrMap{}
|
||||
|
||||
searchMap := make(map[string]struct{}, len(search))
|
||||
for _, s := range search {
|
||||
searchMap[s] = struct{}{}
|
||||
}
|
||||
|
||||
for _, sq := range queries {
|
||||
if _, ok := searchMap[sq.contentType]; ok {
|
||||
qa[sq.query] = sq.attr
|
||||
}
|
||||
}
|
||||
|
||||
return qa
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
//go:build unit
|
||||
// +build unit
|
||||
|
||||
package reactor_crw
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"reactor-crw/parser"
|
||||
)
|
||||
|
||||
type transportMock struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *transportMock) FetchData(url string) (io.ReadCloser, error) {
|
||||
args := m.Called(url)
|
||||
return args.Get(0).(io.ReadCloser), args.Error(1)
|
||||
}
|
||||
|
||||
type parserMock struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *parserMock) FindContent(r io.Reader, q string) (string, error) {
|
||||
args := m.Called(r, q)
|
||||
return args.String(0), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *parserMock) FindAttrMap(r io.Reader, qa parser.QueryAttrMap, qr parser.QueryResult) error {
|
||||
args := m.Called(r, qa, qr)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func TestHtmlCrawler_Fetch(t *testing.T) {
|
||||
path := "https://test.com/test/path"
|
||||
|
||||
trp := &transportMock{}
|
||||
prs := &parserMock{}
|
||||
|
||||
t.Log("Given the need to crawl html page.")
|
||||
{
|
||||
t.Log("When multiple pages requested")
|
||||
{
|
||||
c := &HtmlCrawler{trp, prs, true}
|
||||
|
||||
rc := ioutil.NopCloser(strings.NewReader(""))
|
||||
trp.On("FetchData", path).Return(rc, nil).Once()
|
||||
|
||||
prs.On("FindContent", rc, ".pagination_expanded .current").Return("2", nil)
|
||||
trp.On("FetchData", path+"/1").Return(rc, nil).Once()
|
||||
trp.On("FetchData", path+"/2").Return(rc, nil).Once()
|
||||
|
||||
prs.On("FindAttrMap", rc, mock.Anything, mock.Anything).
|
||||
Run(func(args mock.Arguments) {
|
||||
qr := args.Get(2).(parser.QueryResult)
|
||||
qr["link_1"] = struct{}{}
|
||||
}).
|
||||
Return(nil).
|
||||
Once()
|
||||
|
||||
prs.On("FindAttrMap", rc, mock.Anything, mock.Anything).
|
||||
Run(func(args mock.Arguments) {
|
||||
qr := args.Get(2).(parser.QueryResult)
|
||||
qr["link_2"] = struct{}{}
|
||||
}).
|
||||
Return(nil).
|
||||
Once()
|
||||
|
||||
res, err := c.Fetch(path, []string{"image"})
|
||||
require.NoErrorf(t, err, "Wasn't expected an error during crawl")
|
||||
require.Equal(t, parser.QueryResult{"link_1": struct{}{}, "link_2": struct{}{}}, res)
|
||||
}
|
||||
|
||||
t.Log("When parser returned an error")
|
||||
{
|
||||
expectedErr := errors.New("error")
|
||||
c := &HtmlCrawler{trp, prs, false}
|
||||
|
||||
rc := ioutil.NopCloser(strings.NewReader(""))
|
||||
trp.On("FetchData", path).Return(rc, nil).Once()
|
||||
|
||||
prs.On("FindAttrMap", rc, mock.Anything, mock.Anything).
|
||||
Return(expectedErr).
|
||||
Once()
|
||||
|
||||
_, err := c.Fetch(path, []string{"image"})
|
||||
assert.ErrorIs(t, err, expectedErr)
|
||||
}
|
||||
|
||||
t.Log("When transport returned an error")
|
||||
{
|
||||
expectedErr := errors.New("error")
|
||||
c := &HtmlCrawler{trp, prs, false}
|
||||
|
||||
rc := ioutil.NopCloser(strings.NewReader(""))
|
||||
trp.On("FetchData", path).Return(rc, expectedErr).Once()
|
||||
|
||||
_, err := c.Fetch(path, []string{"image"})
|
||||
assert.ErrorIs(t, err, expectedErr)
|
||||
}
|
||||
|
||||
t.Log("When single page requested")
|
||||
{
|
||||
c := &HtmlCrawler{trp, prs, false}
|
||||
|
||||
rc := ioutil.NopCloser(strings.NewReader(""))
|
||||
trp.On("FetchData", path).Return(rc, nil).Once()
|
||||
|
||||
prs.On("FindAttrMap", rc, mock.Anything, mock.Anything).
|
||||
Run(func(args mock.Arguments) {
|
||||
qr := args.Get(2).(parser.QueryResult)
|
||||
qr["link_1"] = struct{}{}
|
||||
}).
|
||||
Return(nil).
|
||||
Once()
|
||||
|
||||
res, err := c.Fetch(path, nil)
|
||||
require.NoErrorf(t, err, "Wasn't expected an error during crawl")
|
||||
require.Equal(t, parser.QueryResult{"link_1": struct{}{}}, res)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package handler
|
||||
|
||||
// ContentHandler defines an interface for handling content sources that are
|
||||
// represented as URLs. When the handler will finish its job it should notify
|
||||
// the progress. Each error should be sent to the errors channel.
|
||||
type ContentHandler interface {
|
||||
Process(url string, progress chan<- int, errors chan<- error)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package fs
|
||||
|
||||
import (
|
||||
"io"
|
||||
"path"
|
||||
"reactor-crw"
|
||||
)
|
||||
|
||||
// PathResolver defines a simple interface to resolve path for content
|
||||
// before saving it.
|
||||
type pathResolver interface {
|
||||
// CreateFolder creates a new folder in the "current" dir. The current
|
||||
// directory is specified in Config.SavePath provided by a user. If dir
|
||||
// with such name already exists it'll skip creation step.
|
||||
CreateFolder(name string) error
|
||||
|
||||
// CreateFile creates a new file for downloaded content in the directory
|
||||
// created by the CreateFolder function and returns the corresponding
|
||||
// io.WriteCloser. If a file with such a name already exists then it'll
|
||||
// be overwritten.
|
||||
CreateFile(name string) (io.WriteCloser, error)
|
||||
|
||||
// Remove removes a file or folder by its name. If provided path does not
|
||||
// exist it'll be skipped.
|
||||
Remove(name string)
|
||||
}
|
||||
|
||||
// FileSaver defines ContentHandler implementation that will download content
|
||||
// and save it to the host's file system. It resolves the corresponding file
|
||||
// path with PathResolver.
|
||||
type FileSaver struct {
|
||||
pr pathResolver
|
||||
t reactor_crw.Transport
|
||||
}
|
||||
|
||||
// NewFileSaver creates a new FileSaver instance along with a new folder that
|
||||
// will act as a context for a new instance. All files will be processed within
|
||||
// this new folder.
|
||||
func NewFileSaver(pr pathResolver, t reactor_crw.Transport, baseFolder string) (*FileSaver, error) {
|
||||
err := pr.CreateFolder(baseFolder)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &FileSaver{
|
||||
pr: pr,
|
||||
t: t,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Process downloads content by the corresponding URL by making an HTTP request
|
||||
// and saves the result to the file system. In case of error during saving the
|
||||
// content the corresponding file will be deleted from the file system.
|
||||
func (f *FileSaver) Process(url string, progress chan<- int, e chan<- error) {
|
||||
defer func() {
|
||||
progress <- 1
|
||||
}()
|
||||
|
||||
data, err := f.t.FetchData(url)
|
||||
if err != nil {
|
||||
e <- err
|
||||
return
|
||||
}
|
||||
|
||||
defer func(b io.ReadCloser) {
|
||||
_ = b.Close()
|
||||
}(data)
|
||||
|
||||
file, err := f.pr.CreateFile(path.Base(url))
|
||||
if err != nil {
|
||||
e <- err
|
||||
return
|
||||
}
|
||||
|
||||
defer func(f io.WriteCloser) {
|
||||
_ = f.Close()
|
||||
}(file)
|
||||
|
||||
_, err = io.Copy(file, data)
|
||||
if err != nil {
|
||||
f.pr.Remove(path.Base(url))
|
||||
e <- err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
//go:build unit
|
||||
// +build unit
|
||||
|
||||
package fs_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"reactor-crw/handler/fs"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type pathResolverMock struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *pathResolverMock) CreateFolder(name string) error {
|
||||
args := m.Called(name)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *pathResolverMock) CreateFile(name string) (io.WriteCloser, error) {
|
||||
args := m.Called(name)
|
||||
return args.Get(0).(io.WriteCloser), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *pathResolverMock) Remove(name string) {
|
||||
_ = m.Called(name)
|
||||
}
|
||||
|
||||
type transportMock struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *transportMock) FetchData(url string) (io.ReadCloser, error) {
|
||||
args := m.Called(url)
|
||||
return args.Get(0).(io.ReadCloser), args.Error(1)
|
||||
}
|
||||
|
||||
func TestNewFileSaver(t *testing.T) {
|
||||
pr := pathResolverMock{}
|
||||
trp := transportMock{}
|
||||
|
||||
t.Log("Given the need to create a file saver handler.")
|
||||
{
|
||||
t.Log("When baseFolder is incorrect.")
|
||||
{
|
||||
pr.On("CreateFolder", "baseFolder").Return(errors.New("error"))
|
||||
_, err := fs.NewFileSaver(&pr, &trp, "baseFolder")
|
||||
require.Error(t, err, "Expected an error during create folder")
|
||||
pr.ExpectedCalls = []*mock.Call{}
|
||||
}
|
||||
|
||||
t.Log("When all data correct.")
|
||||
{
|
||||
pr.On("CreateFolder", "baseFolder").Return(nil)
|
||||
_, err := fs.NewFileSaver(&pr, &trp, "baseFolder")
|
||||
require.NoError(t, err, "Wasn't expected an error on new creating file saver")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileSaver_Process(t *testing.T) {
|
||||
tmlFile, _ := ioutil.TempFile(os.TempDir(), "file-title.txt")
|
||||
|
||||
pr := pathResolverMock{}
|
||||
pr.On("CreateFolder", "baseFolder").Return(nil)
|
||||
pr.On("Remove", mock.Anything)
|
||||
|
||||
trp := transportMock{}
|
||||
|
||||
p := make(chan int, 1)
|
||||
e := make(chan error, 1)
|
||||
|
||||
fileSaver, _ := fs.NewFileSaver(&pr, &trp, "baseFolder")
|
||||
|
||||
t.Log("Given the need to process data.")
|
||||
{
|
||||
t.Log("When transport returns an error.")
|
||||
{
|
||||
trp.On("FetchData", "file-title.txt").Return(tmlFile, errors.New("error")).Once()
|
||||
|
||||
fileSaver.Process("file-title.txt", p, e)
|
||||
require.Error(t, <-e, "Expected an error during create file")
|
||||
<-p
|
||||
}
|
||||
|
||||
t.Log("When path resolver returns an error.")
|
||||
{
|
||||
trp.On("FetchData", "file-title.txt").Return(tmlFile, nil).Once()
|
||||
pr.On("CreateFile", "file-title.txt").Return(tmlFile, errors.New("error")).Once()
|
||||
|
||||
fileSaver.Process("file-title.txt", p, e)
|
||||
require.Error(t, <-e, "Expected an error during create file")
|
||||
<-p
|
||||
}
|
||||
|
||||
t.Log("When cannot save the data.")
|
||||
{
|
||||
trp.On("FetchData", "file-title.txt").Return(tmlFile, nil).Once()
|
||||
pr.On("CreateFile", "file-title.txt").Return(tmlFile, nil).Once()
|
||||
pr.On("Remove", mock.Anything)
|
||||
|
||||
fileSaver.Process("file-title.txt", p, e)
|
||||
require.Error(t, <-e, "Expected an error file copying")
|
||||
<-p
|
||||
}
|
||||
|
||||
t.Log("When all data correct.")
|
||||
{
|
||||
tmlFile, _ = ioutil.TempFile(os.TempDir(), "new-file-title.txt")
|
||||
|
||||
trp.On("FetchData", "new-file-title.txt").Return(tmlFile, nil).Once()
|
||||
pr.On("CreateFile", "new-file-title.txt").Return(tmlFile, nil).Once()
|
||||
|
||||
fileSaver.Process("new-file-title.txt", p, e)
|
||||
select {
|
||||
case err := <-e:
|
||||
require.NoError(t, err, "Wasn't expected an error on new file process")
|
||||
case <-p:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package fs
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrNoCurrentDir returned on creating the content file before the current
|
||||
// directory was created.
|
||||
ErrNoCurrentDir = errors.New("current dir was not created")
|
||||
|
||||
// ErrInvalidDest returned when provided save path is not a valid directory.
|
||||
ErrInvalidDest = errors.New("invalid destination path")
|
||||
)
|
||||
|
||||
type PathResolver struct {
|
||||
// Dest contains a base directory for all folders that will be created.
|
||||
// It should be a valid absolute or relative path and must already exist.
|
||||
Dest string
|
||||
|
||||
absDest string
|
||||
currentDest string
|
||||
}
|
||||
|
||||
// NewPathResolver returns new *PathResolver along with resolving provided dest
|
||||
// value. If an incorrect dest value is provided an error will be returned.
|
||||
func NewPathResolver(dest string) (*PathResolver, error) {
|
||||
absDest, err := filepath.Abs(dest)
|
||||
if err != nil {
|
||||
return nil, ErrInvalidDest
|
||||
}
|
||||
|
||||
stat, err := os.Stat(absDest)
|
||||
if err != nil || !stat.IsDir() {
|
||||
return nil, ErrInvalidDest
|
||||
}
|
||||
|
||||
return &PathResolver{
|
||||
Dest: dest,
|
||||
absDest: absDest,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateFolder creates a new dir with the provided name within PathResolver.Dest dir.
|
||||
// Created dir will be marked as current dir. If dir already exists the creation
|
||||
// step will be skipped.
|
||||
func (p *PathResolver) CreateFolder(name string) error {
|
||||
err := os.Chdir(p.absDest)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot resolve path for folder %s: %w", name, err)
|
||||
}
|
||||
|
||||
p.currentDest = path.Join(p.absDest, name)
|
||||
|
||||
err = os.Mkdir(name, fs.ModePerm)
|
||||
if err != nil {
|
||||
if os.IsExist(err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("cannot create folder %s: %w", p.currentDest, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateFile creates a new file with the corresponding name and returns its
|
||||
// io.WriteCloser. If FSResolver.currentDest wasn't created before, then an error
|
||||
// will be returned.
|
||||
func (p *PathResolver) CreateFile(name string) (io.WriteCloser, error) {
|
||||
if p.currentDest == "" {
|
||||
return nil, ErrNoCurrentDir
|
||||
}
|
||||
|
||||
filePath := path.Join(p.currentDest, name)
|
||||
|
||||
f, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create file %s: %w", filePath, err)
|
||||
}
|
||||
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// Remove removes file or dir by its name. If the provided path wasn't found the
|
||||
// deletion step will be skipped.
|
||||
func (p *PathResolver) Remove(name string) {
|
||||
_ = os.Remove(name)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
//go:build unit
|
||||
// +build unit
|
||||
|
||||
package fs_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"reactor-crw/handler/fs"
|
||||
)
|
||||
|
||||
func TestNewPathResolver(t *testing.T) {
|
||||
paths := []struct {
|
||||
name string
|
||||
msg string
|
||||
errExpected bool
|
||||
}{
|
||||
{" ", "Expected an error during resolving empty path", true},
|
||||
{"invalid_path", "Expected an error during resolving non-existent dir", true},
|
||||
{os.TempDir(), "Wasn't expected an error", false},
|
||||
}
|
||||
|
||||
for _, path := range paths {
|
||||
_, err := fs.NewPathResolver(path.name)
|
||||
if path.errExpected {
|
||||
require.Errorf(t, err, path.msg)
|
||||
}
|
||||
|
||||
if !path.errExpected {
|
||||
require.NoError(t, err, path.msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathResolver_CreateFolder(t *testing.T) {
|
||||
p, _ := fs.NewPathResolver(os.TempDir())
|
||||
|
||||
folders := []struct {
|
||||
name string
|
||||
errExpected bool
|
||||
}{
|
||||
{name: "test"},
|
||||
{name: "_123_test"},
|
||||
{name: "109483___"},
|
||||
}
|
||||
|
||||
for _, folder := range folders {
|
||||
err := p.CreateFolder(folder.name)
|
||||
if !folder.errExpected {
|
||||
require.NoError(t, err, "Wasn't expected an error during creating %s", folder.name)
|
||||
}
|
||||
|
||||
if folder.errExpected {
|
||||
require.Error(t, err, "Expected an error during creating %s", folder.name)
|
||||
}
|
||||
|
||||
p.Remove(folder.name)
|
||||
}
|
||||
|
||||
p = &fs.PathResolver{}
|
||||
err := p.CreateFolder("test")
|
||||
require.Error(t, err, "Expected an error during creating folder")
|
||||
}
|
||||
|
||||
func TestPathResolver_CreateFile(t *testing.T) {
|
||||
p, _ := fs.NewPathResolver(os.TempDir())
|
||||
_, err := p.CreateFile("filename")
|
||||
require.Error(t, err, "Expected an error during file. Folder wasn't created yet.")
|
||||
|
||||
_ = p.CreateFolder("test")
|
||||
|
||||
_, err = p.CreateFile("filename")
|
||||
require.NoError(t, err, "Wasn't expected an error during creating filename")
|
||||
|
||||
filePath := "./test/filename"
|
||||
_, err = os.Stat(filePath)
|
||||
require.NoError(t, err, "File %s wasn't created", filePath)
|
||||
|
||||
p.Remove(filePath)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"io"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// Html represents a HTML parser implementation.
|
||||
type Html struct{}
|
||||
|
||||
// FindContent searches for the first element that satisfies the query and returns
|
||||
// its text node as content.
|
||||
func (h *Html) FindContent(r io.Reader, query string) (string, error) {
|
||||
doc, err := goquery.NewDocumentFromReader(r)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot parse document: %w", err)
|
||||
}
|
||||
|
||||
return doc.Find(query).First().Text(), nil
|
||||
}
|
||||
|
||||
// FindAttrMap parses HTML documents with multiple queries and retrieves the
|
||||
// corresponding attributes of found elements. All queries and related attributes
|
||||
// are stores within QueryAttrMap. All results will be stored in QueryResult.
|
||||
//
|
||||
// Example: p.FindAttrMap(body, QueryAttrMap{"div": "class"}, QueryResult{})
|
||||
func (h *Html) FindAttrMap(r io.Reader, q QueryAttrMap, res QueryResult) error {
|
||||
doc, err := goquery.NewDocumentFromReader(r)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot parse document: %w", err)
|
||||
}
|
||||
|
||||
for query, attr := range q {
|
||||
doc.Find(query).Each(func(_ int, s *goquery.Selection) {
|
||||
val, ok := s.Attr(attr)
|
||||
if !ok || val == "javascript:" {
|
||||
return
|
||||
}
|
||||
|
||||
val, _ = url.QueryUnescape(val)
|
||||
res[val] = struct{}{}
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
//go:build unit
|
||||
// +build unit
|
||||
|
||||
package parser_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"reactor-crw/parser"
|
||||
)
|
||||
|
||||
func TestHtml_FindOne(t *testing.T) {
|
||||
p := parser.Html{}
|
||||
|
||||
r := strings.NewReader(`
|
||||
<body>
|
||||
<h1>heading</h1>
|
||||
<a class="test-class" href="href-test" target="_blank">First Link</a>
|
||||
<a class="class" href="href-test" target="_blank">Second Link</a>
|
||||
<a href="href-test" target="_blank">Third Link</a>
|
||||
</body>
|
||||
`)
|
||||
|
||||
res, err := p.FindContent(r, "body .test-class")
|
||||
require.NoErrorf(t, err, "Wasn't expected an error on html parse")
|
||||
|
||||
require.Equal(t, "First Link", res)
|
||||
}
|
||||
|
||||
func TestHtml_FindAttrMap(t *testing.T) {
|
||||
p := parser.Html{}
|
||||
|
||||
r := strings.NewReader(`
|
||||
<body>
|
||||
<img src="image-src">
|
||||
<a class="test-class" href="href-test" target="_blank">First Link</a>
|
||||
<div class="div-class"></div>
|
||||
</body>
|
||||
`)
|
||||
|
||||
queryAttrMap := parser.QueryAttrMap{
|
||||
".test-class": "href",
|
||||
"div": "class",
|
||||
"img": "src",
|
||||
"body": "src",
|
||||
}
|
||||
res := make(parser.QueryResult)
|
||||
|
||||
err := p.FindAttrMap(r, queryAttrMap, res)
|
||||
require.NoErrorf(t, err, "Wasn't expected an error on html parse")
|
||||
require.Equal(
|
||||
t,
|
||||
parser.QueryResult{
|
||||
"image-src": struct{}{},
|
||||
"href-test": struct{}{},
|
||||
"div-class": struct{}{},
|
||||
},
|
||||
res,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package parser
|
||||
|
||||
import "io"
|
||||
|
||||
// QueryAttrMap stores a mapping of parser query and an attribute which data
|
||||
// should be retrieved. Example: QueryAttrMap{"div": "class"}. Here div elements
|
||||
// should be found and the class value of each will be collected.
|
||||
type QueryAttrMap map[string]string
|
||||
|
||||
// QueryResult stores all results parsed using the QueryAttrMap value. For preventing
|
||||
// data duplicates the values are stored in a map structure.
|
||||
type QueryResult map[string]struct{}
|
||||
|
||||
// Parser describes a generic set of parser functions.
|
||||
type Parser interface {
|
||||
// FindContent searches for only one element and returns its text content.
|
||||
FindContent(r io.Reader, query string) (string, error)
|
||||
|
||||
// FindAttrMap searches by multiple queries and retrieves corresponding attributes
|
||||
// of found elements. All queries and related attributes stores within QueryAttrMap.
|
||||
// All results will be stored in QueryResult.
|
||||
FindAttrMap(io.Reader, QueryAttrMap, QueryResult) error
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package reactor_crw
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Headers defines a simple wrapper for client headers.
|
||||
type Headers map[string]string
|
||||
|
||||
// HttpTransport allows making a network request over HTTP protocol. It is a
|
||||
// wrapper over HTTP.Client with a set of default params required by the crawler.
|
||||
type HttpTransport struct {
|
||||
headers Headers
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewHttpTransport creates a new *HttpTransport with provided client and custom
|
||||
// headers. Custom headers can override the default ones.
|
||||
func NewHttpTransport(c *http.Client, h Headers) *HttpTransport {
|
||||
var defaultHeaders = Headers{
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:92.0) Gecko/20100101 Firefox/92.0",
|
||||
"Referer": "http://joyreactor.cc/",
|
||||
"DNT": "1",
|
||||
}
|
||||
|
||||
t := &HttpTransport{
|
||||
headers: defaultHeaders,
|
||||
client: c,
|
||||
}
|
||||
|
||||
for k, v := range h {
|
||||
t.headers[k] = v
|
||||
}
|
||||
|
||||
return t
|
||||
}
|
||||
|
||||
// FetchData makes an HTTP request using provided URL and returns the response
|
||||
// body as io.ReadCloser interface. Each request will be prepared with provided
|
||||
// headers list. The end client is responsible for closing the response body.
|
||||
func (t *HttpTransport) FetchData(url string) (io.ReadCloser, error) {
|
||||
req, err := t.prepareRequest(http.MethodGet, url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot make request to %s: %w", url, err)
|
||||
}
|
||||
|
||||
return res.Body, nil
|
||||
}
|
||||
|
||||
func (t *HttpTransport) prepareRequest(method, url string) (*http.Request, error) {
|
||||
req, err := http.NewRequest(method, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot prepare request to %s: %w", url, err)
|
||||
}
|
||||
|
||||
for k, v := range t.headers {
|
||||
req.Header.Add(k, v)
|
||||
}
|
||||
|
||||
return req, nil
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
//go:build unit
|
||||
// +build unit
|
||||
|
||||
package reactor_crw_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reactor-crw"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestHttpTransport_FetchData(t *testing.T) {
|
||||
t.Log("Given the need to fetch the data.")
|
||||
{
|
||||
t.Log("When request params are valid.")
|
||||
{
|
||||
expected := "response"
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "test-val", r.Header.Get("test-key"))
|
||||
_, _ = fmt.Fprintf(w, expected)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
httpTransport := reactor_crw.NewHttpTransport(
|
||||
http.DefaultClient,
|
||||
reactor_crw.Headers{"test-key": "test-val"},
|
||||
)
|
||||
|
||||
data, err := httpTransport.FetchData(srv.URL)
|
||||
require.NoErrorf(t, err, "Wasn't expected an error during http call")
|
||||
|
||||
response, _ := ioutil.ReadAll(data)
|
||||
require.Equal(t, expected, string(response))
|
||||
}
|
||||
|
||||
t.Log("When request cannot be prepared.")
|
||||
{
|
||||
httpTransport := reactor_crw.NewHttpTransport(http.DefaultClient, nil)
|
||||
|
||||
_, err := httpTransport.FetchData("\u007F")
|
||||
require.Error(t, err, "Expected an error during request")
|
||||
}
|
||||
|
||||
t.Log("When endpoint is invalid.")
|
||||
{
|
||||
httpTransport := reactor_crw.NewHttpTransport(http.DefaultClient, nil)
|
||||
|
||||
_, err := httpTransport.FetchData(" ")
|
||||
require.Error(t, err, "Expected an error during request")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user