classifieds_cli_app 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +50 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +4 -0
- data/Gemfile.lock +32 -0
- data/LICENSE.txt +21 -0
- data/README.md +38 -0
- data/Rakefile +2 -0
- data/bin/classifieds +3 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/classifieds_cli_app.gemspec +39 -0
- data/config/environment.rb +14 -0
- data/lib/classified.rb +2 -0
- data/lib/classifieds/auto.rb +35 -0
- data/lib/classifieds/auto_scraper.rb +139 -0
- data/lib/classifieds/boat.rb +34 -0
- data/lib/classifieds/boat_scraper.rb +190 -0
- data/lib/classifieds/cli.rb +118 -0
- data/lib/classifieds/item.rb +69 -0
- data/lib/classifieds/listing.rb +124 -0
- data/lib/classifieds/seller.rb +52 -0
- data/lib/classifieds/vehicle.rb +10 -0
- data/lib/classifieds/version.rb +3 -0
- data/spec.md +9 -0
- metadata +128 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA1:
|
|
3
|
+
metadata.gz: 7e55d1ca44e97ba4d0669686fde104c89d80eaa0
|
|
4
|
+
data.tar.gz: 1d08f774c879eb72c6415b0e316c546307826a33
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: bae09c79008cb2e988da7dddd9ee53652f9e594c54630caec8e7e668a8d77e4606f893463bd31d8bb5d2a90827f3977276cd8d8850b8cc3fd23b049bbefbdcb5
|
|
7
|
+
data.tar.gz: 92862e9b67c72ee5bf6c89b496d8a822dc936803a6e15ad891f643809632c07f1f77a945e5a1518d3dbf949675002a3d14dbc0c31b40ea1d2e1a56ab1ca7ebec
|
data/.gitignore
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
*.gem
|
|
2
|
+
*.rbc
|
|
3
|
+
/.config
|
|
4
|
+
/coverage/
|
|
5
|
+
/InstalledFiles
|
|
6
|
+
/pkg/
|
|
7
|
+
/spec/reports/
|
|
8
|
+
/spec/examples.txt
|
|
9
|
+
/test/tmp/
|
|
10
|
+
/test/version_tmp/
|
|
11
|
+
/tmp/
|
|
12
|
+
|
|
13
|
+
# Used by dotenv library to load environment variables.
|
|
14
|
+
# .env
|
|
15
|
+
|
|
16
|
+
## Specific to RubyMotion:
|
|
17
|
+
.dat*
|
|
18
|
+
.repl_history
|
|
19
|
+
build/
|
|
20
|
+
*.bridgesupport
|
|
21
|
+
build-iPhoneOS/
|
|
22
|
+
build-iPhoneSimulator/
|
|
23
|
+
|
|
24
|
+
## Specific to RubyMotion (use of CocoaPods):
|
|
25
|
+
#
|
|
26
|
+
# We recommend against adding the Pods directory to your .gitignore. However
|
|
27
|
+
# you should judge for yourself, the pros and cons are mentioned at:
|
|
28
|
+
# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
|
|
29
|
+
#
|
|
30
|
+
# vendor/Pods/
|
|
31
|
+
|
|
32
|
+
## Documentation cache and generated files:
|
|
33
|
+
/.yardoc/
|
|
34
|
+
/_yardoc/
|
|
35
|
+
/doc/
|
|
36
|
+
/rdoc/
|
|
37
|
+
|
|
38
|
+
## Environment normalization:
|
|
39
|
+
/.bundle/
|
|
40
|
+
/vendor/bundle
|
|
41
|
+
/lib/bundler/man/
|
|
42
|
+
|
|
43
|
+
# for a library or gem, you might want to ignore these files since the code is
|
|
44
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
45
|
+
# Gemfile.lock
|
|
46
|
+
# .ruby-version
|
|
47
|
+
# .ruby-gemset
|
|
48
|
+
|
|
49
|
+
# unless supporting rvm < 1.11.0 or doing something fancy, ignore this:
|
|
50
|
+
.rvmrc
|
data/CODE_OF_CONDUCT.md
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Contributor Covenant Code of Conduct
|
|
2
|
+
|
|
3
|
+
## Our Pledge
|
|
4
|
+
|
|
5
|
+
In the interest of fostering an open and welcoming environment, we as
|
|
6
|
+
contributors and maintainers pledge to making participation in our project and
|
|
7
|
+
our community a harassment-free experience for everyone, regardless of age, body
|
|
8
|
+
size, disability, ethnicity, gender identity and expression, level of experience,
|
|
9
|
+
nationality, personal appearance, race, religion, or sexual identity and
|
|
10
|
+
orientation.
|
|
11
|
+
|
|
12
|
+
## Our Standards
|
|
13
|
+
|
|
14
|
+
Examples of behavior that contributes to creating a positive environment
|
|
15
|
+
include:
|
|
16
|
+
|
|
17
|
+
* Using welcoming and inclusive language
|
|
18
|
+
* Being respectful of differing viewpoints and experiences
|
|
19
|
+
* Gracefully accepting constructive criticism
|
|
20
|
+
* Focusing on what is best for the community
|
|
21
|
+
* Showing empathy towards other community members
|
|
22
|
+
|
|
23
|
+
Examples of unacceptable behavior by participants include:
|
|
24
|
+
|
|
25
|
+
* The use of sexualized language or imagery and unwelcome sexual attention or
|
|
26
|
+
advances
|
|
27
|
+
* Trolling, insulting/derogatory comments, and personal or political attacks
|
|
28
|
+
* Public or private harassment
|
|
29
|
+
* Publishing others' private information, such as a physical or electronic
|
|
30
|
+
address, without explicit permission
|
|
31
|
+
* Other conduct which could reasonably be considered inappropriate in a
|
|
32
|
+
professional setting
|
|
33
|
+
|
|
34
|
+
## Our Responsibilities
|
|
35
|
+
|
|
36
|
+
Project maintainers are responsible for clarifying the standards of acceptable
|
|
37
|
+
behavior and are expected to take appropriate and fair corrective action in
|
|
38
|
+
response to any instances of unacceptable behavior.
|
|
39
|
+
|
|
40
|
+
Project maintainers have the right and responsibility to remove, edit, or
|
|
41
|
+
reject comments, commits, code, wiki edits, issues, and other contributions
|
|
42
|
+
that are not aligned to this Code of Conduct, or to ban temporarily or
|
|
43
|
+
permanently any contributor for other behaviors that they deem inappropriate,
|
|
44
|
+
threatening, offensive, or harmful.
|
|
45
|
+
|
|
46
|
+
## Scope
|
|
47
|
+
|
|
48
|
+
This Code of Conduct applies both within project spaces and in public spaces
|
|
49
|
+
when an individual is representing the project or its community. Examples of
|
|
50
|
+
representing a project or community include using an official project e-mail
|
|
51
|
+
address, posting via an official social media account, or acting as an appointed
|
|
52
|
+
representative at an online or offline event. Representation of a project may be
|
|
53
|
+
further defined and clarified by project maintainers.
|
|
54
|
+
|
|
55
|
+
## Enforcement
|
|
56
|
+
|
|
57
|
+
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
|
58
|
+
reported by contacting the project team at jpfingst@outlook.com. All
|
|
59
|
+
complaints will be reviewed and investigated and will result in a response that
|
|
60
|
+
is deemed necessary and appropriate to the circumstances. The project team is
|
|
61
|
+
obligated to maintain confidentiality with regard to the reporter of an incident.
|
|
62
|
+
Further details of specific enforcement policies may be posted separately.
|
|
63
|
+
|
|
64
|
+
Project maintainers who do not follow or enforce the Code of Conduct in good
|
|
65
|
+
faith may face temporary or permanent repercussions as determined by other
|
|
66
|
+
members of the project's leadership.
|
|
67
|
+
|
|
68
|
+
## Attribution
|
|
69
|
+
|
|
70
|
+
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
|
|
71
|
+
available at [http://contributor-covenant.org/version/1/4][version]
|
|
72
|
+
|
|
73
|
+
[homepage]: http://contributor-covenant.org
|
|
74
|
+
[version]: http://contributor-covenant.org/version/1/4/
|
data/Gemfile
ADDED
data/Gemfile.lock
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
PATH
|
|
2
|
+
remote: .
|
|
3
|
+
specs:
|
|
4
|
+
classifieds (0.1.0)
|
|
5
|
+
nokogiri
|
|
6
|
+
|
|
7
|
+
GEM
|
|
8
|
+
remote: https://rubygems.org/
|
|
9
|
+
specs:
|
|
10
|
+
coderay (1.1.1)
|
|
11
|
+
method_source (0.8.2)
|
|
12
|
+
mini_portile2 (2.1.0)
|
|
13
|
+
nokogiri (1.6.8.1)
|
|
14
|
+
mini_portile2 (~> 2.1.0)
|
|
15
|
+
pry (0.10.4)
|
|
16
|
+
coderay (~> 1.1.0)
|
|
17
|
+
method_source (~> 0.8.1)
|
|
18
|
+
slop (~> 3.4)
|
|
19
|
+
rake (10.4.2)
|
|
20
|
+
slop (3.6.0)
|
|
21
|
+
|
|
22
|
+
PLATFORMS
|
|
23
|
+
ruby
|
|
24
|
+
|
|
25
|
+
DEPENDENCIES
|
|
26
|
+
bundler (~> 1.13)
|
|
27
|
+
classifieds!
|
|
28
|
+
pry
|
|
29
|
+
rake (~> 10.0)
|
|
30
|
+
|
|
31
|
+
BUNDLED WITH
|
|
32
|
+
1.13.6
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2016 John Pfingst
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# classifieds-cli-app
|
|
2
|
+
|
|
3
|
+
Display classified ads from the current online edition of Newsday.com and BoatTrader.com.
|
|
4
|
+
This gem was created to meet the requirements of the learn.co CLI Data Gem Project.
|
|
5
|
+
|
|
6
|
+
## Installation
|
|
7
|
+
|
|
8
|
+
Add this line to your application's Gemfile:
|
|
9
|
+
|
|
10
|
+
```ruby
|
|
11
|
+
gem 'classifieds-cli-app'
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
And then execute:
|
|
15
|
+
|
|
16
|
+
$ bundle
|
|
17
|
+
|
|
18
|
+
Or install it yourself as:
|
|
19
|
+
|
|
20
|
+
$ gem install classifieds-cli-app
|
|
21
|
+
|
|
22
|
+
## Usage
|
|
23
|
+
|
|
24
|
+
$ classifieds
|
|
25
|
+
|
|
26
|
+
## Development
|
|
27
|
+
|
|
28
|
+
After checking out the repo, run `bin/setup` to install dependencies. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
|
29
|
+
|
|
30
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
|
31
|
+
|
|
32
|
+
## Contributing
|
|
33
|
+
|
|
34
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/jcpny1/classifieds-cli-app. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
|
|
35
|
+
|
|
36
|
+
## License
|
|
37
|
+
|
|
38
|
+
The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
data/bin/classifieds
ADDED
data/bin/console
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require_relative "../config/environment"
|
|
5
|
+
|
|
6
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
|
7
|
+
# with your gem easier. You can also use a different console, if you like.
|
|
8
|
+
|
|
9
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
|
10
|
+
# require "pry"
|
|
11
|
+
# Pry.start
|
|
12
|
+
|
|
13
|
+
require "irb"
|
|
14
|
+
IRB.start
|
data/bin/setup
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# coding: utf-8
|
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
|
4
|
+
require 'classifieds/version'
|
|
5
|
+
|
|
6
|
+
Gem::Specification.new do |spec|
|
|
7
|
+
spec.name = "classifieds_cli_app"
|
|
8
|
+
spec.version = Classifieds::VERSION
|
|
9
|
+
spec.authors = ["John Pfingst"]
|
|
10
|
+
spec.email = ["jpfingst@outlook.com"]
|
|
11
|
+
spec.date = '2016-12-13'
|
|
12
|
+
|
|
13
|
+
spec.summary = %q{Classified Listings Display}
|
|
14
|
+
spec.description = %q{Scrapes online classifieds ads and provides a command line interface with which to view them.}
|
|
15
|
+
spec.homepage = "http://rubygems.org/gems/classifieds_cli_app"
|
|
16
|
+
spec.license = "MIT"
|
|
17
|
+
|
|
18
|
+
# Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host'
|
|
19
|
+
# to allow pushing to a single host or delete this section to allow pushing to any host.
|
|
20
|
+
# if spec.respond_to?(:metadata)
|
|
21
|
+
# spec.metadata['allowed_push_host'] = "TODO: Set to 'http://mygemserver.com'"
|
|
22
|
+
# else
|
|
23
|
+
# raise "RubyGems 2.0 or newer is required to protect against " \
|
|
24
|
+
# "public gem pushes."
|
|
25
|
+
# end
|
|
26
|
+
|
|
27
|
+
spec.files = `git ls-files -z`.split("\x0").reject do |f|
|
|
28
|
+
f.match(%r{^(test|spec|features)/})
|
|
29
|
+
end
|
|
30
|
+
spec.bindir = "bin"
|
|
31
|
+
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
|
32
|
+
spec.require_paths = ["lib"]
|
|
33
|
+
|
|
34
|
+
spec.add_development_dependency "bundler", "~> 1.13"
|
|
35
|
+
spec.add_development_dependency "rake", "~> 10.0"
|
|
36
|
+
spec.add_development_dependency "pry", "~> 0"
|
|
37
|
+
|
|
38
|
+
spec.add_runtime_dependency "nokogiri", "~> 1.6"
|
|
39
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
require 'pry'
|
|
2
|
+
require 'nokogiri'
|
|
3
|
+
require 'open-uri'
|
|
4
|
+
|
|
5
|
+
require_relative '../lib/classifieds/version.rb'
|
|
6
|
+
require_relative '../lib/classifieds/cli.rb'
|
|
7
|
+
require_relative '../lib/classifieds/item.rb'
|
|
8
|
+
require_relative '../lib/classifieds/listing.rb'
|
|
9
|
+
require_relative '../lib/classifieds/seller.rb'
|
|
10
|
+
require_relative '../lib/classifieds/vehicle.rb'
|
|
11
|
+
require_relative '../lib/classifieds/auto.rb'
|
|
12
|
+
require_relative '../lib/classifieds/boat.rb'
|
|
13
|
+
require_relative '../lib/classifieds/auto_scraper.rb'
|
|
14
|
+
require_relative '../lib/classifieds/boat_scraper.rb'
|
data/lib/classified.rb
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
class Classifieds::Auto < Classifieds::Vehicle # describes a Vehicle type of: Automobile
|
|
2
|
+
|
|
3
|
+
@@ATTR_COLUMN_WIDTHS = [12, 10]
|
|
4
|
+
@@SUMMARY_COL_FORMATS = [[24,'l'], [7,'r'], [8,'r']] # [width, justification]
|
|
5
|
+
|
|
6
|
+
def initialize(year, make, model, mileage, price, condition, detail_link)
|
|
7
|
+
super(year, make, model, price, condition, detail_link)
|
|
8
|
+
@mileage = mileage
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Return attribute field width for given column
|
|
12
|
+
def attr_width(col)
|
|
13
|
+
@@ATTR_COLUMN_WIDTHS[col-1]
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Creates listings from summary web page
|
|
17
|
+
def self.scrape_results_page(results_url, results_url_file, results_doc)
|
|
18
|
+
Classifieds::AutoScraper.scrape_results_page(results_url, results_url_file, results_doc, self)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Returns detail attributes and values in detail_values hash
|
|
22
|
+
def self.scrape_results_detail_page(detail_doc, item_condition, detail_values)
|
|
23
|
+
Classifieds::AutoScraper.scrape_results_detail_page(detail_doc, item_condition, detail_values)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Returns a summary listing data row
|
|
27
|
+
def summary_detail
|
|
28
|
+
Classifieds::Listing.fmt_cols([@title, @mileage, @price], @@SUMMARY_COL_FORMATS)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Returns the summary listing title row
|
|
32
|
+
def self.summary_header
|
|
33
|
+
Classifieds::Listing.fmt_cols(['Vehicle', 'Mileage', 'Price '], @@SUMMARY_COL_FORMATS)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
class Classifieds::AutoScraper # converts automobile classified listings into objects
|
|
2
|
+
# Currently coded for Newsday.com only
|
|
3
|
+
|
|
4
|
+
private_class_method :new
|
|
5
|
+
|
|
6
|
+
# Creates listings from summary web page
|
|
7
|
+
def self.scrape_results_page(results_url, results_url_file, results_doc, item_class)
|
|
8
|
+
results_doc.css('.aiResultsWrapper').each { |result|
|
|
9
|
+
id = listing_id(result)
|
|
10
|
+
title_parts = title_parts(result) # => [year, make, model]
|
|
11
|
+
|
|
12
|
+
description_pod_div = result.css('.aiDescriptionPod')
|
|
13
|
+
start_date = start_date(description_pod_div)
|
|
14
|
+
mileage = mileage(description_pod_div)
|
|
15
|
+
sale_price = sale_price(description_pod_div)
|
|
16
|
+
|
|
17
|
+
contact_div = result.css('.contactLinks')
|
|
18
|
+
seller_name = seller_name(contact_div)
|
|
19
|
+
seller_location = seller_location(contact_div)
|
|
20
|
+
seller_phone = seller_phone(contact_div, id, results_url_file)
|
|
21
|
+
|
|
22
|
+
detail_url = detail_url(results_url, result)
|
|
23
|
+
item_condition = item_condition(detail_url)
|
|
24
|
+
|
|
25
|
+
item = item_class.new(title_parts[0], title_parts[1], title_parts[2], mileage, sale_price, item_condition, detail_url)
|
|
26
|
+
seller = Classifieds::Seller.find_or_create(seller_name, seller_location, seller_phone)
|
|
27
|
+
Classifieds::Listing.new(id, item, seller, start_date)
|
|
28
|
+
}
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Returns detail attributes and values in detail_values hash
|
|
32
|
+
def self.scrape_results_detail_page(detail_doc, item_condition, detail_values)
|
|
33
|
+
# Create some entries manually.
|
|
34
|
+
detail_values['Description'.to_sym] = detail_doc.css('.aiDetailsDescription')[0].children[2].text.strip
|
|
35
|
+
detail_values['Condition'.to_sym] = item_condition
|
|
36
|
+
detail_values['Certified'.to_sym] = ''
|
|
37
|
+
# Create the rest from scraping the html's detail attrribute/value table.
|
|
38
|
+
detail_cells = detail_cells(detail_doc)
|
|
39
|
+
index = 0
|
|
40
|
+
while index < detail_cells.size
|
|
41
|
+
if ("\u00A0" == detail_cells[index].text) && ('aiCPOiconDetail' == detail_cells[index].children[0].attributes['class'].content)
|
|
42
|
+
detail_values[:Certified] = 'Yes' # The table row containing attribute Certified Icon does not have a value column.
|
|
43
|
+
index += 1
|
|
44
|
+
else # Grab the attribute and value from the html table.
|
|
45
|
+
attribute = detail_cells[index].text.chomp(':')
|
|
46
|
+
value = detail_cells[index+1].text
|
|
47
|
+
detail_values[attribute.to_sym] = value
|
|
48
|
+
index += 2
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
## PRIVATE METHODS
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# Returns the item condition, as encoded in the detail page url
|
|
57
|
+
def self.item_condition(url)
|
|
58
|
+
url.match(/[a-z0-9]-(certified|new|used)-[0-9]/)[1].capitalize
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Returns the detail table
|
|
62
|
+
def self.detail_cells(doc)
|
|
63
|
+
doc.css('.aiDetailAdDetails td')
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Returns a summary record's detail page url
|
|
67
|
+
def self.detail_url(url, doc) # detail link is given as relative to the summary page's domain
|
|
68
|
+
uri = URI.parse(url)
|
|
69
|
+
"#{uri.scheme}://#{uri.host}#{doc.css('.aiResultTitle h3 a')[0]['href']}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Returns the listing's id
|
|
73
|
+
def self.listing_id(doc) # e.g. from "aiResultsMainDiv547967889"
|
|
74
|
+
doc.css('.aiResultsMainDiv')[0]['id'].match(/\d+/).to_s
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Returns the listing's mileage value
|
|
78
|
+
def self.mileage(doc)
|
|
79
|
+
value = doc.css('.listingType').text # e.g. 'Mileage: xx,xxx'
|
|
80
|
+
(value.include? 'Available') ? 'NA' : value.match(/Mileage: (\d*,{,1}\d+)/)[1]
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Returns the listing's sale price
|
|
84
|
+
def self.sale_price(doc)
|
|
85
|
+
value = doc.css('.price').text
|
|
86
|
+
(value.include? 'Call') ? 'Call' : value
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Returns the seller's address
|
|
90
|
+
def self.seller_location(doc)
|
|
91
|
+
doc.css('strong')[1].text.strip
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Returns the seller's name
|
|
95
|
+
def self.seller_name(doc)
|
|
96
|
+
doc.css('strong')[0].text.strip
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
PHONE_PATTERN = '\(\d\d\d\) \d\d\d-\d\d\d\d'
|
|
100
|
+
|
|
101
|
+
# Returns the seller's phone number, if it exists
|
|
102
|
+
def self.seller_phone(doc, id, url_file)
|
|
103
|
+
span = doc.css('span')
|
|
104
|
+
if 1 < span.size
|
|
105
|
+
match_data = span[0].text.match(/#{PHONE_PATTERN}/)
|
|
106
|
+
match_data ? match_data.to_s : ''
|
|
107
|
+
else
|
|
108
|
+
seller_phone_private(url_file, id)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Returns the seller's phone number, if found in the raw HTML text
|
|
113
|
+
def self.seller_phone_private(url_file, id)
|
|
114
|
+
match_data = nil
|
|
115
|
+
open(url_file).detect { |line| match_data = line.match(/#{PHONE_PATTERN}/) if line.include? ('aiGetPhoneNumber'+id) }
|
|
116
|
+
match_data ? match_data.to_s : ''
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Returns listing start date
|
|
120
|
+
def self.start_date(doc)
|
|
121
|
+
doc.css('.listingDate').text
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Returns the summary listing's title
|
|
125
|
+
def self.title(doc)
|
|
126
|
+
doc.css('.aiResultTitle h3').text # '2010 Ford Explorer XL'
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Returns the year, make, and model from the title string
|
|
130
|
+
# NOTE: It is assumed that Make will be one word.
|
|
131
|
+
# Otherwise, likely will need a database of Make names to match against.
|
|
132
|
+
def self.title_parts(doc)
|
|
133
|
+
title_parts = title(doc).split(' ')
|
|
134
|
+
year = title_parts[0] # year
|
|
135
|
+
make = title_parts[1] # make
|
|
136
|
+
model = title_parts.last(title_parts.size - 2).join(' ') # model
|
|
137
|
+
[year, make, model]
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
class Classifieds::Boat < Classifieds::Vehicle # describes a Vehicle type of: Boat
|
|
2
|
+
|
|
3
|
+
@@ATTR_COLUMN_WIDTHS = [15, 14]
|
|
4
|
+
@@SUMMARY_COL_FORMATS = [[24,'l'], [8,'r']] # [width, justification]
|
|
5
|
+
|
|
6
|
+
def initialize(year, make, model, price, condition, detail_link)
|
|
7
|
+
super(year, make, model, price, condition, detail_link)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# Return attribute field width for given column
|
|
11
|
+
def attr_width(col)
|
|
12
|
+
@@ATTR_COLUMN_WIDTHS[col-1]
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Creates listings from summary web page
|
|
16
|
+
def self.scrape_results_page(results_url, results_url_file, results_doc)
|
|
17
|
+
Classifieds::BoatScraper.scrape_results_page(results_url, results_url_file, results_doc, self)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Returns detail attributes and values in detail_values hash
|
|
21
|
+
def self.scrape_results_detail_page(detail_doc, item_condition, detail_values)
|
|
22
|
+
Classifieds::BoatScraper.scrape_results_detail_page(detail_doc, item_condition, detail_values)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Returns a summary listing data row
|
|
26
|
+
def summary_detail
|
|
27
|
+
Classifieds::Listing.fmt_cols([@title, @price], @@SUMMARY_COL_FORMATS)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Returns the summary listing title row
|
|
31
|
+
def self.summary_header
|
|
32
|
+
Classifieds::Listing.fmt_cols(['Boat', 'Price '], @@SUMMARY_COL_FORMATS)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
class Classifieds::BoatScraper # converts Boattrader.com power boat classified listings into objects
|
|
2
|
+
# Currently coded for BoatTrader.com only
|
|
3
|
+
|
|
4
|
+
private_class_method :new
|
|
5
|
+
|
|
6
|
+
# Creates listings from summary web page
|
|
7
|
+
def self.scrape_results_page(results_url, results_url_file, results_doc, item_class)
|
|
8
|
+
results_doc.css('.boat-listings li').each { |result|
|
|
9
|
+
id = listing_id(result)
|
|
10
|
+
next if id.nil?
|
|
11
|
+
|
|
12
|
+
descr_div = result.css('.description')
|
|
13
|
+
title_parts = split_title(descr_div) # => [year, make, model]
|
|
14
|
+
start_date = '' # Listing start_date currently not available from web page.
|
|
15
|
+
sale_price = sale_price(descr_div)
|
|
16
|
+
seller_name = seller_name(descr_div)
|
|
17
|
+
seller_location = seller_location(descr_div)
|
|
18
|
+
seller_phone = '' # Seller phone currently not available from web page.
|
|
19
|
+
|
|
20
|
+
detail_url = detail_url(results_url, result)
|
|
21
|
+
item_condition = 'Used' # Condition currently not available from web page.
|
|
22
|
+
|
|
23
|
+
item = item_class.new(title_parts[0], title_parts[1], title_parts[2], sale_price, item_condition, detail_url)
|
|
24
|
+
seller = Classifieds::Seller.find_or_create(seller_name, seller_location, seller_phone)
|
|
25
|
+
Classifieds::Listing.new(id, item, seller, start_date)
|
|
26
|
+
}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Returns detail attributes and values in detail_values hash
|
|
30
|
+
def self.scrape_results_detail_page(detail_doc, item_condition, detail_values)
|
|
31
|
+
boat_details_doc = detail_doc.css('.boat-details')
|
|
32
|
+
|
|
33
|
+
if boat_details_doc.empty?
|
|
34
|
+
do_alt_processing(detail_doc, item_condition, detail_values)
|
|
35
|
+
else
|
|
36
|
+
do_normal_processing(detail_doc, boat_details_doc, item_condition, detail_values)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
## PRIVATE METHODS
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
# Returns the detail table
|
|
44
|
+
def self.detail_cells(doc)
|
|
45
|
+
doc.css('#collapsible-content-areas tr')
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Returns the detail table
|
|
49
|
+
def self.detail_cells_alt(doc)
|
|
50
|
+
doc.css('#ad_detail-table tr')
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Returns a summary record's detail page url
|
|
54
|
+
def self.detail_url(url, doc) # detail link is given as relative to the summary page's domain
|
|
55
|
+
uri = URI.parse(url)
|
|
56
|
+
"#{uri.scheme}://#{uri.host}#{doc.css('.inner a')[0]['href']}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Returns detail attributes and values in detail_values hash
|
|
60
|
+
def self.do_alt_processing(doc, item_condition, detail_values)
|
|
61
|
+
# Create some entries manually.
|
|
62
|
+
main_content = doc.css('#main-content')
|
|
63
|
+
detail_values['Description'.to_sym] = main_content.css('p').text.strip
|
|
64
|
+
detail_values['Condition'.to_sym] = item_condition
|
|
65
|
+
|
|
66
|
+
# Create the rest from scraping the html's detail attrribute/value table.
|
|
67
|
+
detail_cells = detail_cells_alt(main_content)
|
|
68
|
+
(0...detail_cells.size).each { |index|
|
|
69
|
+
dl_tag = detail_cells[index].children
|
|
70
|
+
(0...dl_tag.size).step(2) { |child|
|
|
71
|
+
attribute = dl_tag[child].text
|
|
72
|
+
value = dl_tag[child+1].text
|
|
73
|
+
detail_values[attribute.to_sym] = value
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
detail_values['Phone'.to_sym] = seller_phone(main_content) # NOTE: keep phone number last in list for display consistency.
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Returns detail attributes and values in detail_values hash
|
|
81
|
+
def self.do_normal_processing(doc, boat_doc, item_condition, detail_values)
|
|
82
|
+
# Create some entries manually.
|
|
83
|
+
detail_values['Description'.to_sym] = doc.css('#main-details').text.strip
|
|
84
|
+
detail_values['Condition'.to_sym] = item_condition
|
|
85
|
+
|
|
86
|
+
# Create the rest from scraping the html's detail attrribute/value table.
|
|
87
|
+
detail_cells = detail_cells(boat_doc)
|
|
88
|
+
(0...detail_cells.size).each { |index|
|
|
89
|
+
attribute_tag = detail_cells[index].children[1]
|
|
90
|
+
|
|
91
|
+
if attribute_tag
|
|
92
|
+
dl_tag = attribute_tag.css('dl')
|
|
93
|
+
|
|
94
|
+
if 0 < dl_tag.size # need to do alternate normal processing.
|
|
95
|
+
process_detail_list_alt(dl_tag, detail_values)
|
|
96
|
+
else
|
|
97
|
+
attribute = attribute_tag.text
|
|
98
|
+
value_tag = detail_cells[index].children[3]
|
|
99
|
+
|
|
100
|
+
if value_tag
|
|
101
|
+
text_value = (attribute == 'Owner Video' ? 'Yes' : value_tag.text) # substitute Yes for the video link.
|
|
102
|
+
detail_values[attribute.to_sym] = text_value
|
|
103
|
+
else
|
|
104
|
+
process_detail_list(detail_cells[index].css('dl').children, detail_values)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
detail_values['Phone'.to_sym] = doc.css('.phone').text # NOTE: keep phone number last in list for display consistency.
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Returns the listing's id. If the listing doesn't have an id, it's not really a listing.
|
|
114
|
+
def self.listing_id(doc)
|
|
115
|
+
value = doc.attributes['data-reporting-impression-product-id']
|
|
116
|
+
value.text if value
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Parse description list data into hash
|
|
120
|
+
def self.process_detail_list(doc, detail_values)
|
|
121
|
+
(0...doc.size).step(4) { |index|
|
|
122
|
+
attribute = doc[index+1]
|
|
123
|
+
next if attribute.nil?
|
|
124
|
+
attr_text = attribute.text
|
|
125
|
+
value_tag = doc[index+3]
|
|
126
|
+
detail_values[attr_text.to_sym] = value_tag.text if value_tag
|
|
127
|
+
}
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Parse description list data into hash
|
|
131
|
+
def self.process_detail_list_alt(doc, detail_values)
|
|
132
|
+
(0...doc.size).each { |index|
|
|
133
|
+
children = doc[index].children
|
|
134
|
+
child_index = 1
|
|
135
|
+
while child_index < children.size
|
|
136
|
+
attribute = children[child_index].text
|
|
137
|
+
value = children[child_index+2]
|
|
138
|
+
if value.nil? || value.text.strip.size == 0
|
|
139
|
+
value = children[child_index+1]
|
|
140
|
+
child_index += 3
|
|
141
|
+
else
|
|
142
|
+
child_index += 4
|
|
143
|
+
end
|
|
144
|
+
detail_values[attribute.to_sym] = value.text.sub('✓', 'Y') if value
|
|
145
|
+
end
|
|
146
|
+
}
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Returns the listing's sale price
|
|
150
|
+
def self.sale_price(doc)
|
|
151
|
+
value = doc.css('.price .data').text
|
|
152
|
+
value != '' ? value : doc.css('.price .txt')[0].children[1].text
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Returns the seller's address
|
|
156
|
+
def self.seller_location(doc)
|
|
157
|
+
value = doc.css('.location .data').text
|
|
158
|
+
value != '' ? value : doc.css('.location .txt')[0].children[1].text
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Returns the seller's name
|
|
162
|
+
def self.seller_name(doc)
|
|
163
|
+
value = doc.css('.offered-by .data').text
|
|
164
|
+
value != '' ? value : doc.css('.offered-by .txt')[0].children[1].text
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Returns the Seller's phone number
|
|
168
|
+
def self.seller_phone(doc)
|
|
169
|
+
phone = doc.css('.seller-info .phone').text
|
|
170
|
+
phone.reverse! # alt listing phone has css format 'direction: rtl'
|
|
171
|
+
phone[0] = '('; phone[4] = ')'
|
|
172
|
+
phone
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Returns the year, make, and model from the title string
|
|
176
|
+
# NOTE: It is assumed that Make will be one word.
|
|
177
|
+
# Otherwise, likely will need a database of Make names to match against.
|
|
178
|
+
def self.split_title(doc)
|
|
179
|
+
title_parts = title(doc).split(' ')
|
|
180
|
+
year = title_parts[0] # year
|
|
181
|
+
make = title_parts[1] # make
|
|
182
|
+
model = title_parts.last(title_parts.size - 2).join(' ') # model
|
|
183
|
+
[year, make, model]
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Returns the summary listing's title
|
|
187
|
+
def self.title(doc)
|
|
188
|
+
doc.css('.name').text # '2000 JASON 25 Downeaster' (also Grady White, Grady-White, Sea Ray, ...)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
class Classifieds::CLI # is the command line interface for the classified app
|
|
2
|
+
|
|
3
|
+
def initialize
|
|
4
|
+
@start_index = 0 # indexes for summary listing array access.
|
|
5
|
+
@end_index = 0
|
|
6
|
+
@item_class = nil
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
# Begin executing command line interface
|
|
10
|
+
def run(page_size:10)
|
|
11
|
+
@page_size = page_size
|
|
12
|
+
user_input = ''
|
|
13
|
+
select_item_type
|
|
14
|
+
|
|
15
|
+
while true # do CLI Loop
|
|
16
|
+
@start_index = @end_index + 1 # Advance to next page of listings.
|
|
17
|
+
@start_index = 0 if @start_index == Classifieds::Listing.all.size # Go back to beginning if end is reached.
|
|
18
|
+
|
|
19
|
+
begin # Command input loop
|
|
20
|
+
@end_index = @start_index + @page_size-1 # if page size changes inside this loop, adjust end index.
|
|
21
|
+
@end_index = Classifieds::Listing.all.size-1 if @end_index >= Classifieds::Listing.all.size
|
|
22
|
+
|
|
23
|
+
Classifieds::Listing.print_summary(@item_class, @start_index, @end_index) if user_input != 'h' # Display a page of listings.
|
|
24
|
+
user_input = Classifieds::CLI.prompt 'Command (or h for help): ' # Get user input.
|
|
25
|
+
exit if !process_user_input(user_input) # Process user input.
|
|
26
|
+
end until user_input == '' # then display next page of summaries.
|
|
27
|
+
end # do CLI loop
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
## PRIVATE METHODS
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
# Display command help
|
|
34
|
+
def self.display_help
|
|
35
|
+
puts ' To continue scrolling, press Enter.',
|
|
36
|
+
' For listing detail, enter listing number and press Enter',
|
|
37
|
+
' To change item type, type i and press Enter',
|
|
38
|
+
' To terminate program, type q and press Enter.',
|
|
39
|
+
# ' For a seller list, type s and press Enter.',
|
|
40
|
+
' To display this help, type h and press Enter.'
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Returns whether or not to continue executing program
|
|
44
|
+
def process_user_input(user_input)
|
|
45
|
+
continue_program = true
|
|
46
|
+
|
|
47
|
+
case user_input
|
|
48
|
+
when 'h'
|
|
49
|
+
Classifieds::CLI.display_help
|
|
50
|
+
when 'i'
|
|
51
|
+
select_item_type
|
|
52
|
+
when 'p'
|
|
53
|
+
tmp_page_size = Classifieds::CLI.prompt('Enter new page size: ').to_i
|
|
54
|
+
@page_size = tmp_page_size if 0 < tmp_page_size
|
|
55
|
+
when 'q'
|
|
56
|
+
continue_program = false
|
|
57
|
+
# when 's'
|
|
58
|
+
# # list sellers instead of items
|
|
59
|
+
else
|
|
60
|
+
item_number = user_input.to_i
|
|
61
|
+
if 0 < item_number
|
|
62
|
+
index = item_number - 1
|
|
63
|
+
if index < Classifieds::Listing.all.size
|
|
64
|
+
Classifieds::Listing.all[index].print_detail(item_number)
|
|
65
|
+
else
|
|
66
|
+
STDERR.puts Classifieds::CLI.red('Invalid selection')
|
|
67
|
+
end
|
|
68
|
+
Classifieds::CLI.prompt 'Press Enter to continue...'
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
continue_program
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Display prompt string and return user's input
|
|
75
|
+
def self.prompt(string)
|
|
76
|
+
print Classifieds::CLI.yellow(string)
|
|
77
|
+
gets.chomp
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Format string to display in the color red
|
|
81
|
+
def self.red(string)
|
|
82
|
+
"\e[31m#{string}\e[0m"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def select_item_type
|
|
86
|
+
begin
|
|
87
|
+
valid_input = false
|
|
88
|
+
|
|
89
|
+
puts 'Available item types:',
|
|
90
|
+
' 1. Automobile',
|
|
91
|
+
' 2. Boat'
|
|
92
|
+
user_input = Classifieds::CLI.prompt 'Enter your selection number: '
|
|
93
|
+
|
|
94
|
+
case user_input.to_i
|
|
95
|
+
when 1
|
|
96
|
+
valid_input = true
|
|
97
|
+
@item_class = Classifieds::Auto
|
|
98
|
+
@summary_url = 'http://long-island-cars.newsday.com/motors/results/car?maxYear=2010&radius=0&min_price=1000&view=List_Detail&sort=Price+desc%2C+Priority+desc&rows=50'
|
|
99
|
+
when 2
|
|
100
|
+
valid_input = true
|
|
101
|
+
@item_class = Classifieds::Boat
|
|
102
|
+
@summary_url = 'http://www.boattrader.com/search-results/NewOrUsed-used/Type-power/Category-all/Zip-10030/Radius-100/Price-5000,150000/Sort-Price:DESC/Page-1,50?'
|
|
103
|
+
else
|
|
104
|
+
STDERR.puts Classifieds::CLI.red('Invalid selection')
|
|
105
|
+
end
|
|
106
|
+
end while valid_input == false
|
|
107
|
+
|
|
108
|
+
Classifieds::Listing.clear_all
|
|
109
|
+
Classifieds::Listing.scrape_listings(@item_class, @summary_url)
|
|
110
|
+
@start_index = 0 # summary display start item
|
|
111
|
+
@end_index = -1 # summary display end item
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Format string to display in the color yellow
|
|
115
|
+
def self.yellow(string)
|
|
116
|
+
"\e[33m#{string}\e[0m"
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
class Classifieds::Item # describes the thing in a listing that is for sale
|
|
2
|
+
# It is expected to be subclassed based on the type of item for sale (e.g., vehicle, clothing, furniture, etc.)
|
|
3
|
+
# To improve response time, an item's detail_values are only loaded on demand (from the detail url)
|
|
4
|
+
|
|
5
|
+
def initialize(title, price, condition, detail_url)
|
|
6
|
+
@title = title
|
|
7
|
+
@price = price
|
|
8
|
+
@detail_url = detail_url
|
|
9
|
+
@condition = condition
|
|
10
|
+
@detail_values = {}
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Empty list of created objects
|
|
14
|
+
def self.clear
|
|
15
|
+
# all.clear
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
COLUMN_SEPARATION = 5
|
|
19
|
+
|
|
20
|
+
# Return an item's detail data formatted for display
|
|
21
|
+
def details_to_string(addon_details)
|
|
22
|
+
Classifieds::Listing.scrape_listing_details(self.class, @detail_url, @condition, @detail_values) if @detail_values.empty?
|
|
23
|
+
|
|
24
|
+
detail_values_array = @detail_values.to_a
|
|
25
|
+
addon_details.delete(:Phone) if detail_phone # do not use seller phone if item details has a phone.
|
|
26
|
+
detail_values_array.concat(addon_details.to_a)
|
|
27
|
+
offset = detail_values_array.size / 2 # prepare for two column output.
|
|
28
|
+
mod2 = detail_values_array.size % 2 # and account for an odd number of details.
|
|
29
|
+
col1_ljust = max_col1_width(detail_values_array, offset+mod2) + COLUMN_SEPARATION
|
|
30
|
+
result = ''
|
|
31
|
+
|
|
32
|
+
(0...offset+mod2).each { |index|
|
|
33
|
+
# column 1
|
|
34
|
+
attribute = detail_values_array[index][0].to_s
|
|
35
|
+
value = detail_values_array[index][1]
|
|
36
|
+
result << " #{Classifieds::Listing.format_detail(attribute, attr_width(1), value).ljust(col1_ljust)}"
|
|
37
|
+
|
|
38
|
+
# column 2
|
|
39
|
+
if 'Description' == attribute.to_s # Have Description be on its own line.
|
|
40
|
+
result << "\n"
|
|
41
|
+
elsif (index + offset) < detail_values_array.size
|
|
42
|
+
attribute = detail_values_array[index+offset][0].to_s
|
|
43
|
+
value = detail_values_array[index+offset][1]
|
|
44
|
+
result << "#{Classifieds::Listing.format_detail(attribute, attr_width(2), value)}\n"
|
|
45
|
+
end
|
|
46
|
+
}
|
|
47
|
+
result
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def detail_phone
|
|
51
|
+
@detail_values[:Phone]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
## PRIVATE METHODS
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
# Find width of widest col1 data
|
|
58
|
+
def max_col1_width(detail_values_array, end_val)
|
|
59
|
+
max_width = 0
|
|
60
|
+
(0...end_val).each { |index|
|
|
61
|
+
attribute = detail_values_array[index][0].to_s
|
|
62
|
+
next if 'Description' == attribute # Description spans all cols, so don't count its width.
|
|
63
|
+
value = detail_values_array[index][1]
|
|
64
|
+
detail_width = Classifieds::Listing.format_detail(attribute, attr_width(1), value).size
|
|
65
|
+
max_width = detail_width if detail_width > max_width
|
|
66
|
+
}
|
|
67
|
+
max_width
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
class Classifieds::Listing # describes a classified advertisement
|
|
2
|
+
# A Listing has one item and one seller, along with listing-based attributes such as the listing start date
|
|
3
|
+
|
|
4
|
+
@@all_listings = []
|
|
5
|
+
|
|
6
|
+
def initialize(id, item, seller, start_date)
|
|
7
|
+
@id = id
|
|
8
|
+
@item = item
|
|
9
|
+
@seller = seller
|
|
10
|
+
@start_date = start_date
|
|
11
|
+
Classifieds::Listing.all << self
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Empty list of created objects
|
|
15
|
+
def self.clear_all
|
|
16
|
+
Classifieds::Item.clear
|
|
17
|
+
Classifieds::Seller.clear
|
|
18
|
+
self.clear
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Prints a detail listing for a single item
|
|
22
|
+
def print_detail(item_number)
|
|
23
|
+
addon_details = {:Phone => @seller.phone, :'Listing #' => @id}
|
|
24
|
+
puts '',
|
|
25
|
+
self.summary_detail_row(item_number),
|
|
26
|
+
@item.details_to_string(addon_details)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Prints the specified summary listings for the specified item subclass
|
|
30
|
+
def self.print_summary(item_class, start_index, end_index)
|
|
31
|
+
puts " #{item_class.summary_header} #{Classifieds::Seller.summary_header} #{lfmt('List Date', 10)}"
|
|
32
|
+
all[start_index..end_index].each_with_index { |listing, index| puts listing.summary_detail_row(start_index+index+1) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Creates listings from summary web page
|
|
36
|
+
def self.scrape_listings(item_class, results_url)
|
|
37
|
+
results_url_file = open(results_url, :read_timeout=>10)
|
|
38
|
+
results_doc = Nokogiri::HTML(results_url_file)
|
|
39
|
+
item_class.scrape_results_page(results_url, results_url_file, results_doc)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Returns detail attributes and values in detail_values hash
|
|
43
|
+
def self.scrape_listing_details(item_class, detail_url, item_condition, detail_values)
|
|
44
|
+
detail_doc = Nokogiri::HTML(open(detail_url, :read_timeout=>10))
|
|
45
|
+
item_class.scrape_results_detail_page(detail_doc, item_condition, detail_values)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Prints a summary detail row
|
|
49
|
+
def summary_detail_row(item_number)
|
|
50
|
+
"#{(item_number).to_s.rjust(2)}. #{@item.summary_detail} #{@seller.summary_detail} #{Classifieds::Listing.lfmt(@start_date, 10)}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Formats an array of strings according to an array of formats
|
|
54
|
+
def self.fmt_cols(values, formats)
|
|
55
|
+
result = ''
|
|
56
|
+
values.each_with_index { |value, index| result << "#{fmt_col(value, formats[index][0], formats[index][1])} " }
|
|
57
|
+
result
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
MAX_LINE_LENGTH = 100
|
|
61
|
+
|
|
62
|
+
# Format a detail attribute
|
|
63
|
+
def self.fmt_detail_attr(string, width)
|
|
64
|
+
"#{lfmt(string, width)}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Format a detail item
|
|
68
|
+
def self.format_detail(attr, attr_width, val)
|
|
69
|
+
"#{fmt_detail_attr(attr, attr_width)}: #{fmt_detail_val(val, attr_width)}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Format a detail value (with wrap if necessary)
|
|
73
|
+
def self.fmt_detail_val(string, wrap_indent)
|
|
74
|
+
new_string = string.strip
|
|
75
|
+
if new_string.size > MAX_LINE_LENGTH
|
|
76
|
+
new_string = ''
|
|
77
|
+
new_line = "\n #{' '*wrap_indent} " # Indent size of Attribute display width.
|
|
78
|
+
line_len = 0
|
|
79
|
+
string.split(' ').each { |word|
|
|
80
|
+
new_string << "#{word} "
|
|
81
|
+
if (line_len += word.size + 1) > MAX_LINE_LENGTH
|
|
82
|
+
new_string << new_line
|
|
83
|
+
line_len = 0
|
|
84
|
+
end
|
|
85
|
+
}
|
|
86
|
+
end
|
|
87
|
+
new_string
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
## PRIVATE METHODS
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
# Returns array of all listings
|
|
94
|
+
def self.all
|
|
95
|
+
@@all_listings
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Empty list of created objects
|
|
99
|
+
def self.clear
|
|
100
|
+
all.clear
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Format a column
|
|
104
|
+
def self.fmt_col(str, width, justify)
|
|
105
|
+
case justify
|
|
106
|
+
when 'l'
|
|
107
|
+
"#{lfmt(str, width)}"
|
|
108
|
+
when 'r'
|
|
109
|
+
"#{rfmt(str, width)}"
|
|
110
|
+
else
|
|
111
|
+
"error"
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Left justify string and pad or trim to size
|
|
116
|
+
def self.lfmt(string, size)
|
|
117
|
+
string.slice(0,size).ljust(size)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Right justify string and pad or trim to size
|
|
121
|
+
def self.rfmt(string, size)
|
|
122
|
+
string.slice(0,size).rjust(size)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
class Classifieds::Seller # describes the entity that is selling the .Item in a .Listing
|
|
2
|
+
# A seller is uniquely identified by name + location + phone
|
|
3
|
+
|
|
4
|
+
@@SUMMARY_COL_FORMATS = [[28,'l'], [32,'l']] # [width, justification]
|
|
5
|
+
|
|
6
|
+
@@all_sellers = []
|
|
7
|
+
|
|
8
|
+
attr_accessor :phone
|
|
9
|
+
attr_reader :name, :location, :phone
|
|
10
|
+
|
|
11
|
+
private_class_method :new # only allow #new from #find_or_create
|
|
12
|
+
|
|
13
|
+
def initialize(name, location, phone)
|
|
14
|
+
@name = name
|
|
15
|
+
@location = location
|
|
16
|
+
@phone = phone
|
|
17
|
+
Classifieds::Seller.all << self
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Empty list of created objects
|
|
21
|
+
def self.clear
|
|
22
|
+
all.clear
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Returns the specified seller, or creates a new one if not found in @@all
|
|
26
|
+
def self.find_or_create(name, location, phone)
|
|
27
|
+
(seller = find_seller(name, location, phone)) != nil ? seller : new(name, location, phone)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Returns the specified seller from @all or nil if not found
|
|
31
|
+
def self.find_seller(name, location, phone)
|
|
32
|
+
all.find { |seller| seller.name == name && seller.location == location && seller.phone == phone }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Return a summary listing detail row
|
|
36
|
+
def summary_detail
|
|
37
|
+
Classifieds::Listing.fmt_cols([@name, @location], @@SUMMARY_COL_FORMATS)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Return the summary listing summary title row
|
|
41
|
+
def self.summary_header
|
|
42
|
+
Classifieds::Listing.fmt_cols(['Seller', 'Location'], @@SUMMARY_COL_FORMATS)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
## PRIVATE METHODS
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
# Returns array of all sellers
|
|
49
|
+
def self.all
|
|
50
|
+
@@all_sellers
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
class Classifieds::Vehicle < Classifieds::Item # describes an Item of type Vehicle
|
|
2
|
+
# It is expected to be subclassed based on the type of vehicle (e.g., automobile, boat, motorcycle, etc.)
|
|
3
|
+
|
|
4
|
+
def initialize(year, make, model, price, condition, detail_link)
|
|
5
|
+
super("#{year} #{make} #{model}", price, condition, detail_link)
|
|
6
|
+
@year = year
|
|
7
|
+
@make = make
|
|
8
|
+
@model = model
|
|
9
|
+
end
|
|
10
|
+
end
|
data/spec.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Specifications for the CLI Assessment
|
|
2
|
+
|
|
3
|
+
Specs:
|
|
4
|
+
- [X] Have a CLI for interfacing with the application
|
|
5
|
+
CLI class called from bin/classifieds
|
|
6
|
+
- [X] Pull data from an external source
|
|
7
|
+
Nokogiri parsing of Newsday.com auto classifieds and BoatTrader.com boat classifieds using hardcoded URLs.
|
|
8
|
+
- [X] Implement both list and detail views
|
|
9
|
+
Classified listings are displayed in a summary list and can be selected for details.
|
metadata
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: classifieds_cli_app
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.1
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- John Pfingst
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2016-12-13 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: bundler
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '1.13'
|
|
20
|
+
type: :development
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '1.13'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: rake
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '10.0'
|
|
34
|
+
type: :development
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '10.0'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: pry
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '0'
|
|
48
|
+
type: :development
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '0'
|
|
55
|
+
- !ruby/object:Gem::Dependency
|
|
56
|
+
name: nokogiri
|
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - "~>"
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '1.6'
|
|
62
|
+
type: :runtime
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - "~>"
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '1.6'
|
|
69
|
+
description: Scrapes online classifieds ads and provides a command line interface
|
|
70
|
+
with which to view them.
|
|
71
|
+
email:
|
|
72
|
+
- jpfingst@outlook.com
|
|
73
|
+
executables:
|
|
74
|
+
- classifieds
|
|
75
|
+
- console
|
|
76
|
+
- setup
|
|
77
|
+
extensions: []
|
|
78
|
+
extra_rdoc_files: []
|
|
79
|
+
files:
|
|
80
|
+
- ".gitignore"
|
|
81
|
+
- CODE_OF_CONDUCT.md
|
|
82
|
+
- Gemfile
|
|
83
|
+
- Gemfile.lock
|
|
84
|
+
- LICENSE.txt
|
|
85
|
+
- README.md
|
|
86
|
+
- Rakefile
|
|
87
|
+
- bin/classifieds
|
|
88
|
+
- bin/console
|
|
89
|
+
- bin/setup
|
|
90
|
+
- classifieds_cli_app.gemspec
|
|
91
|
+
- config/environment.rb
|
|
92
|
+
- lib/classified.rb
|
|
93
|
+
- lib/classifieds/auto.rb
|
|
94
|
+
- lib/classifieds/auto_scraper.rb
|
|
95
|
+
- lib/classifieds/boat.rb
|
|
96
|
+
- lib/classifieds/boat_scraper.rb
|
|
97
|
+
- lib/classifieds/cli.rb
|
|
98
|
+
- lib/classifieds/item.rb
|
|
99
|
+
- lib/classifieds/listing.rb
|
|
100
|
+
- lib/classifieds/seller.rb
|
|
101
|
+
- lib/classifieds/vehicle.rb
|
|
102
|
+
- lib/classifieds/version.rb
|
|
103
|
+
- spec.md
|
|
104
|
+
homepage: http://rubygems.org/gems/classifieds_cli_app
|
|
105
|
+
licenses:
|
|
106
|
+
- MIT
|
|
107
|
+
metadata: {}
|
|
108
|
+
post_install_message:
|
|
109
|
+
rdoc_options: []
|
|
110
|
+
require_paths:
|
|
111
|
+
- lib
|
|
112
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
113
|
+
requirements:
|
|
114
|
+
- - ">="
|
|
115
|
+
- !ruby/object:Gem::Version
|
|
116
|
+
version: '0'
|
|
117
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
118
|
+
requirements:
|
|
119
|
+
- - ">="
|
|
120
|
+
- !ruby/object:Gem::Version
|
|
121
|
+
version: '0'
|
|
122
|
+
requirements: []
|
|
123
|
+
rubyforge_project:
|
|
124
|
+
rubygems_version: 2.6.8
|
|
125
|
+
signing_key:
|
|
126
|
+
specification_version: 4
|
|
127
|
+
summary: Classified Listings Display
|
|
128
|
+
test_files: []
|