about summary refs log tree commit diff stats
diff options
context:
space:
mode:
authorKelly Rauchenberger <fefferburbia@gmail.com>2018-03-01 16:03:16 -0500
committerKelly Rauchenberger <fefferburbia@gmail.com>2018-03-01 16:03:16 -0500
commit473b327ceed3afb5e5683002b39fd9c1947cb25a (patch)
tree0dd2eac68605ad8cf34a2e2e54c6a44ad3ab14c1
parentd85fed8541a9580e820a907d83a2184b020572ba (diff)
downloadlunatic-473b327ceed3afb5e5683002b39fd9c1947cb25a.tar.gz
lunatic-473b327ceed3afb5e5683002b39fd9c1947cb25a.tar.bz2
lunatic-473b327ceed3afb5e5683002b39fd9c1947cb25a.zip
Redesigned persistent data formta
This is the start of a project to add imagery to the bot's output. We began by rewriting the scraper to use a SQLite datafile instead of dumping achievement names to a text file. This allows storage of additional information about each achievement, and allows for more sophisticated scraping.

Profiles to be scraped can be added on the command line using the scraper script, instead of being specified in a config file. The scraper can conduct full or delta scrapes; in a delta scrape, only each profile's recent games are scraped, whereas they are all scraped in a full scrape.

When a game is scraped for the first time, images from the store page of that game are saved locally to be used by the bot. The bot has been altered to not use Twitter, and instead generate a pixelated image based on an image from the game of the chosen achievement. This is just for development purposes. It also crashes occasionally due to picking an achievement from a game that does not have any images saved.

Sprites of the moons from Odyssey have been included in the repository. A short message denoting their copyright is included.
-rw-r--r--CMakeLists.txt11
-rw-r--r--Gemfile5
-rw-r--r--Gemfile.lock19
-rw-r--r--database.cpp122
-rw-r--r--database.h53
-rw-r--r--lunatic.cpp50
-rw-r--r--moons/README.md1
-rw-r--r--moons/blue.pngbin0 -> 37618 bytes
-rw-r--r--moons/brown.pngbin0 -> 38150 bytes
-rw-r--r--moons/cyan.pngbin0 -> 33139 bytes
-rw-r--r--moons/green.pngbin0 -> 36760 bytes
-rw-r--r--moons/orange.pngbin0 -> 36465 bytes
-rw-r--r--moons/pink.pngbin0 -> 30838 bytes
-rw-r--r--moons/purple.pngbin0 -> 32331 bytes
-rw-r--r--moons/red.pngbin0 -> 37165 bytes
-rw-r--r--moons/star.pngbin0 -> 27829 bytes
-rw-r--r--moons/white.pngbin0 -> 29878 bytes
-rw-r--r--moons/yellow.pngbin0 -> 32939 bytes
-rw-r--r--schema.sql32
-rw-r--r--scrape.rb181
20 files changed, 442 insertions, 32 deletions
diff --git a/CMakeLists.txt b/CMakeLists.txt index 55a671b..00c0ad9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt
@@ -5,12 +5,17 @@ add_subdirectory(vendor/libtwittercpp)
5 5
6find_package(PkgConfig) 6find_package(PkgConfig)
7pkg_check_modules(yaml-cpp yaml-cpp REQUIRED) 7pkg_check_modules(yaml-cpp yaml-cpp REQUIRED)
8pkg_check_modules(GraphicsMagick GraphicsMagick++ REQUIRED)
9pkg_check_modules(sqlite3 sqlite3>=3.8.3 REQUIRED)
8 10
9include_directories( 11include_directories(
10 vendor/libtwittercpp/src 12 vendor/libtwittercpp/src
11 ${yaml-cpp_INCLUDE_DIRS}) 13 ${yaml-cpp_INCLUDE_DIRS}
14 ${GraphicsMagick_INCLUDE_DIRS})
12 15
13add_executable(lunatic lunatic.cpp) 16link_directories(${GraphicsMagick_LIBRARY_DIRS})
17
18add_executable(lunatic lunatic.cpp database.cpp)
14set_property(TARGET lunatic PROPERTY CXX_STANDARD 11) 19set_property(TARGET lunatic PROPERTY CXX_STANDARD 11)
15set_property(TARGET lunatic PROPERTY CXX_STANDARD_REQUIRED ON) 20set_property(TARGET lunatic PROPERTY CXX_STANDARD_REQUIRED ON)
16target_link_libraries(lunatic ${yaml-cpp_LIBRARIES} twitter++) \ No newline at end of file 21target_link_libraries(lunatic ${yaml-cpp_LIBRARIES} ${GraphicsMagick_LIBRARIES} ${sqlite3_LIBRARIES} twitter++) \ No newline at end of file
diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..57a02a2 --- /dev/null +++ b/Gemfile
@@ -0,0 +1,5 @@
1source 'https://rubygems.org'
2
3gem 'sqlite3'
4gem 'sequel'
5gem 'nokogiri'
diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..58ec121 --- /dev/null +++ b/Gemfile.lock
@@ -0,0 +1,19 @@
1GEM
2 remote: https://rubygems.org/
3 specs:
4 mini_portile2 (2.3.0)
5 nokogiri (1.8.2)
6 mini_portile2 (~> 2.3.0)
7 sequel (5.5.0)
8 sqlite3 (1.3.13)
9
10PLATFORMS
11 ruby
12
13DEPENDENCIES
14 nokogiri
15 sequel
16 sqlite3
17
18BUNDLED WITH
19 1.16.1
diff --git a/database.cpp b/database.cpp new file mode 100644 index 0000000..f8b5016 --- /dev/null +++ b/database.cpp
@@ -0,0 +1,122 @@
1#include "database.h"
2#include <sqlite3.h>
3#include <stdexcept>
4
5database::database(std::string path)
6{
7 if (sqlite3_open_v2(
8 path.c_str(),
9 &ppdb_,
10 SQLITE_OPEN_READONLY,
11 NULL) != SQLITE_OK)
12 {
13 // We still have to free the resources allocated. In the event that
14 // allocation failed, ppdb will be null and sqlite3_close_v2 will just
15 // ignore it.
16 std::string errmsg(sqlite3_errmsg(ppdb_));
17 sqlite3_close_v2(ppdb_);
18
19 throw std::logic_error(errmsg);
20 }
21}
22
23database::database(database&& other) : database()
24{
25 swap(*this, other);
26}
27
28database& database::operator=(database&& other)
29{
30 swap(*this, other);
31
32 return *this;
33}
34
35void swap(database& first, database& second)
36{
37 std::swap(first.ppdb_, second.ppdb_);
38}
39
40database::~database()
41{
42 sqlite3_close_v2(ppdb_);
43}
44
45achievement database::getRandomAchievement() const
46{
47 std::string queryString = "SELECT achievements.achievement_id, achievements.game_id, achievements.title, games.moon_image FROM achievements INNER JOIN games ON games.game_id = achievements.game_id ORDER BY RANDOM() LIMIT 1";
48
49 sqlite3_stmt* ppstmt;
50 if (sqlite3_prepare_v2(
51 ppdb_,
52 queryString.c_str(),
53 queryString.length(),
54 &ppstmt,
55 NULL) != SQLITE_OK)
56 {
57 std::string errorMsg = sqlite3_errmsg(ppdb_);
58 sqlite3_finalize(ppstmt);
59
60 throw std::logic_error(errorMsg);
61 }
62
63 if (sqlite3_step(ppstmt) != SQLITE_ROW)
64 {
65 std::string errorMsg = sqlite3_errmsg(ppdb_);
66 sqlite3_finalize(ppstmt);
67
68 throw std::logic_error(errorMsg);
69 }
70
71 achievement result;
72
73 result.achievementId = sqlite3_column_int(ppstmt, 0);
74 result.gameId = sqlite3_column_int(ppstmt, 1);
75 result.title = reinterpret_cast<const char*>(sqlite3_column_text(ppstmt, 2));
76 result.moonImage = reinterpret_cast<const char*>(sqlite3_column_text(ppstmt, 3));
77
78 sqlite3_finalize(ppstmt);
79
80 return result;
81}
82
83std::string database::getRandomImageForGame(int gameId) const
84{
85 std::string queryString = "SELECT filename FROM images WHERE game_id = ? ORDER BY RANDOM() LIMIT 1";
86
87 sqlite3_stmt* ppstmt;
88 if (sqlite3_prepare_v2(
89 ppdb_,
90 queryString.c_str(),
91 queryString.length(),
92 &ppstmt,
93 NULL) != SQLITE_OK)
94 {
95 std::string errorMsg = sqlite3_errmsg(ppdb_);
96 sqlite3_finalize(ppstmt);
97
98 throw std::logic_error(errorMsg);
99 }
100
101 if (sqlite3_bind_int(ppstmt, 1, gameId) != SQLITE_OK)
102 {
103 std::string errorMsg = sqlite3_errmsg(ppdb_);
104 sqlite3_finalize(ppstmt);
105
106 throw std::logic_error(errorMsg);
107 }
108
109 if (sqlite3_step(ppstmt) != SQLITE_ROW)
110 {
111 std::string errorMsg = sqlite3_errmsg(ppdb_);
112 sqlite3_finalize(ppstmt);
113
114 throw std::logic_error(errorMsg);
115 }
116
117 std::string result = reinterpret_cast<const char*>(sqlite3_column_text(ppstmt, 0));
118
119 sqlite3_finalize(ppstmt);
120
121 return result;
122}
diff --git a/database.h b/database.h new file mode 100644 index 0000000..560eeda --- /dev/null +++ b/database.h
@@ -0,0 +1,53 @@
1#ifndef DATABASE_H_75C3CE0F
2#define DATABASE_H_75C3CE0F
3
4#include <string>
5
6struct sqlite3;
7
8struct achievement {
9 int achievementId;
10 int gameId;
11 std::string title;
12 std::string moonImage;
13};
14
15class database {
16public:
17
18 // Constructor
19
20 explicit database(std::string path);
21
22 // Disable copying
23
24 database(const database& other) = delete;
25 database& operator=(const database& other) = delete;
26
27 // Move constructor and move assignment
28
29 database(database&& other);
30 database& operator=(database&& other);
31
32 // Swap
33
34 friend void swap(database& first, database& second);
35
36 // Destructor
37
38 ~database();
39
40 // Accessors
41
42 achievement getRandomAchievement() const;
43
44 std::string getRandomImageForGame(int gameId) const;
45
46private:
47
48 database() = default;
49
50 sqlite3* ppdb_ = nullptr;
51};
52
53#endif /* end of include guard: DATABASE_H_75C3CE0F */
diff --git a/lunatic.cpp b/lunatic.cpp index 09dcc41..ca3140a 100644 --- a/lunatic.cpp +++ b/lunatic.cpp
@@ -5,6 +5,8 @@
5#include <twitter.h> 5#include <twitter.h>
6#include <chrono> 6#include <chrono>
7#include <thread> 7#include <thread>
8#include <Magick++.h>
9#include "database.h"
8 10
9int main(int argc, char** argv) 11int main(int argc, char** argv)
10{ 12{
@@ -14,6 +16,8 @@ int main(int argc, char** argv)
14 return -1; 16 return -1;
15 } 17 }
16 18
19 Magick::InitializeMagick(nullptr);
20
17 std::string configfile(argv[1]); 21 std::string configfile(argv[1]);
18 YAML::Node config = YAML::LoadFile(configfile); 22 YAML::Node config = YAML::LoadFile(configfile);
19 23
@@ -23,7 +27,9 @@ int main(int argc, char** argv)
23 auth.setAccessKey(config["access_key"].as<std::string>()); 27 auth.setAccessKey(config["access_key"].as<std::string>());
24 auth.setAccessSecret(config["access_secret"].as<std::string>()); 28 auth.setAccessSecret(config["access_secret"].as<std::string>());
25 29
26 twitter::client client(auth); 30 //twitter::client client(auth);
31
32 database db(config["database"].as<std::string>());
27 33
28 std::random_device randomDevice; 34 std::random_device randomDevice;
29 std::mt19937 rng(randomDevice()); 35 std::mt19937 rng(randomDevice());
@@ -32,6 +38,40 @@ int main(int argc, char** argv)
32 { 38 {
33 std::cout << "Generating tweet" << std::endl; 39 std::cout << "Generating tweet" << std::endl;
34 40
41 achievement ach = db.getRandomAchievement();
42 std::string imageName = db.getRandomImageForGame(ach.gameId);
43 std::string imagePath = config["images"].as<std::string>()
44 + "/" + imageName;
45
46
47 try
48 {
49 Magick::Image image;
50 image.read(imagePath);
51 image.transform("1600x900");
52 image.scale("160x90");
53 image.scale("1600x900");
54 image.magick("png");
55 image.write("output.png");
56 } catch (const Magick::WarningCoder& ex)
57 {
58 // Ok
59 }
60
61
62
63
64
65
66
67
68
69
70
71
72
73 /*
74
35 // Reload achievements list every time in case it has been updated 75 // Reload achievements list every time in case it has been updated
36 std::vector<std::string> achievements; 76 std::vector<std::string> achievements;
37 std::ifstream datafile(config["achievements"].as<std::string>()); 77 std::ifstream datafile(config["achievements"].as<std::string>());
@@ -53,7 +93,7 @@ int main(int argc, char** argv)
53 } 93 }
54 94
55 std::uniform_int_distribution<int> dist(0, achievements.size() - 1); 95 std::uniform_int_distribution<int> dist(0, achievements.size() - 1);
56 std::string achievement = achievements[dist(rng)]; 96 std::string achievement = achievements[dist(rng)];*/
57 97
58 std::string header; 98 std::string header;
59 if (std::bernoulli_distribution(1.0 / 50.0)(rng)) 99 if (std::bernoulli_distribution(1.0 / 50.0)(rng))
@@ -63,16 +103,16 @@ int main(int argc, char** argv)
63 header = "YOU GOT A MOON!"; 103 header = "YOU GOT A MOON!";
64 } 104 }
65 105
66 std::string action = header + "\n" + achievement; 106 std::string action = header + "\n" + ach.title;
67 action.resize(140); 107 action.resize(140);
68 108
69 try 109 /*try
70 { 110 {
71 client.updateStatus(action); 111 client.updateStatus(action);
72 } catch (const twitter::twitter_error& e) 112 } catch (const twitter::twitter_error& e)
73 { 113 {
74 std::cout << "Twitter error: " << e.what() << std::endl; 114 std::cout << "Twitter error: " << e.what() << std::endl;
75 } 115 }*/
76 116
77 std::cout << action << std::endl; 117 std::cout << action << std::endl;
78 std::cout << "Waiting" << std::endl; 118 std::cout << "Waiting" << std::endl;
diff --git a/moons/README.md b/moons/README.md new file mode 100644 index 0000000..876f0db --- /dev/null +++ b/moons/README.md
@@ -0,0 +1 @@
The images in this directory are originally from the game Super Mario Odyssey, which is copyrighted by Nintendo. \ No newline at end of file
diff --git a/moons/blue.png b/moons/blue.png new file mode 100644 index 0000000..5e4ca02 --- /dev/null +++ b/moons/blue.png
Binary files differ
diff --git a/moons/brown.png b/moons/brown.png new file mode 100644 index 0000000..6a31b16 --- /dev/null +++ b/moons/brown.png
Binary files differ
diff --git a/moons/cyan.png b/moons/cyan.png new file mode 100644 index 0000000..8984126 --- /dev/null +++ b/moons/cyan.png
Binary files differ
diff --git a/moons/green.png b/moons/green.png new file mode 100644 index 0000000..5e7d311 --- /dev/null +++ b/moons/green.png
Binary files differ
diff --git a/moons/orange.png b/moons/orange.png new file mode 100644 index 0000000..06ca595 --- /dev/null +++ b/moons/orange.png
Binary files differ
diff --git a/moons/pink.png b/moons/pink.png new file mode 100644 index 0000000..3b1521b --- /dev/null +++ b/moons/pink.png
Binary files differ
diff --git a/moons/purple.png b/moons/purple.png new file mode 100644 index 0000000..9eeacf9 --- /dev/null +++ b/moons/purple.png
Binary files differ
diff --git a/moons/red.png b/moons/red.png new file mode 100644 index 0000000..2c0ef77 --- /dev/null +++ b/moons/red.png
Binary files differ
diff --git a/moons/star.png b/moons/star.png new file mode 100644 index 0000000..29263d5 --- /dev/null +++ b/moons/star.png
Binary files differ
diff --git a/moons/white.png b/moons/white.png new file mode 100644 index 0000000..fae666b --- /dev/null +++ b/moons/white.png
Binary files differ
diff --git a/moons/yellow.png b/moons/yellow.png new file mode 100644 index 0000000..8c012df --- /dev/null +++ b/moons/yellow.png
Binary files differ
diff --git a/schema.sql b/schema.sql new file mode 100644 index 0000000..61fdc45 --- /dev/null +++ b/schema.sql
@@ -0,0 +1,32 @@
1CREATE TABLE `profiles` (
2 `profile_id` INTEGER PRIMARY KEY,
3 `profile_path` VARCHAR(255) NOT NULL
4);
5
6CREATE UNIQUE INDEX `profile_by_path` ON `profiles`(`profile_path`);
7
8CREATE TABLE `games` (
9 `game_id` INTEGER PRIMARY KEY,
10 `steam_appid` INTEGER NOT NULL,
11 `moon_image` VARCHAR(255) NOT NULL
12);
13
14CREATE UNIQUE INDEX `game_by_appid` ON `games`(`steam_appid`);
15
16CREATE TABLE `achievements` (
17 `achievement_id` INTEGER PRIMARY KEY,
18 `game_id` INTEGER NOT NULL,
19 `title` VARCHAR(255) NOT NULL
20);
21
22CREATE TABLE `dids` (
23 `profile_id` INTEGER NOT NULL,
24 `achievement_id` INTEGER NOT NULL,
25 `achieved_at` DATETIME NOT NULL
26);
27
28CREATE TABLE `images` (
29 `image_id` INTEGER PRIMARY KEY,
30 `game_id` INTEGER NOT NULL,
31 `filename` VARCHAR(255) NOT NULL
32);
diff --git a/scrape.rb b/scrape.rb index a28f4c5..6f3a8e4 100644 --- a/scrape.rb +++ b/scrape.rb
@@ -1,42 +1,175 @@
1require 'json' 1require 'json'
2require 'nokogiri'
3require 'open-uri' 2require 'open-uri'
4require 'yaml' 3require 'yaml'
5 4
6config = YAML.load(open(ARGV[0])) 5require 'rubygems'
7usernames = config["usernames"] 6require 'bundler/setup'
7Bundler.require :default
8 8
9achieves = usernames.map do |username| 9@config = YAML.load(open(ARGV[0]))
10 page = Nokogiri::HTML(open("https://steamcommunity.com/#{username}/games/?tab=all")) 10db_existed = File.exists?(@config["database"])
11db = Sequel.connect("sqlite://" + @config["database"])
12
13if ARGV[1] == "init"
14 if db_existed
15 raise "Datafile already exists"
16 end
17
18 schema = File.read("schema.sql")
19
20 db.run schema
21
22 puts "Initialized datafile"
23
24 exit
25end
26
27class Profile < Sequel::Model
28 many_to_many :achievements, join_table: :dids
29end
30
31class Game < Sequel::Model
32 one_to_many :achievements
33 one_to_many :images
34end
35
36class Achievement < Sequel::Model
37 many_to_one :game
38 many_to_many :profiles, join_table: :dids
39end
40
41class Image < Sequel::Model
42 many_to_one :game
43end
44
45class Did < Sequel::Model
46 many_to_one :profile
47 many_to_one :achievement
48end
49
50@moonimgs = Dir.entries(@config["moon_images"]).select do |img|
51 img.end_with? ".png"
52end
53
54def scrape_profile(profile, full)
55 if full
56 url = "https://steamcommunity.com/#{profile.profile_path}/games/?tab=all"
57 else
58 url = "https://steamcommunity.com/#{profile.profile_path}/games/"
59 end
60
61 page = Nokogiri::HTML(open(url))
11 script = page.css(".responsive_page_template_content script").text[18..-1] 62 script = page.css(".responsive_page_template_content script").text[18..-1]
12 data = JSON.parse(script[0..script.index(";\r\n\t\t")-1]) 63 data = JSON.parse(script[0..script.index(";\r\n\t\t")-1])
13 ids = data.map { |d| d["appid"] } 64 ids = data.map { |d| d["appid"] }
14 65
15 index = 0 66 index = 0
16 ids.map do |id| 67 ids.each do |id|
17 index += 1 68 index += 1
18 puts "#{username} - #{index}/#{ids.count}" 69 puts "#{profile.profile_path} - #{index}/#{ids.count}"
19 70
20 achsp = Nokogiri::HTML(open("https://steamcommunity.com/#{username}/stats/#{id}/")) 71 achsp = Nokogiri::HTML(
21 achsp.css(".achieveTxt .achieveUnlockTime + h3").map { |d| d.text } 72 open("https://steamcommunity.com/#{profile.profile_path}/stats/#{id}/"))
22 end
23end.flatten
24 73
25if File.exists?(config["achievements"]) 74 achsp.css(".achieveTxt").each do |node|
26 already = File.read(config["achievements"]).split("\n") 75 unless node.css(".achieveUnlockTime").empty?
27 all_achieves = achieves + already 76 if Game.where(steam_appid: id).count > 0
28else 77 game = Game.where(steam_appid: id).first
29 all_achieves = achieves 78 else
30end 79 moon_index = Random.rand(@moonimgs.size)
80
81 game = Game.new(steam_appid: id, moon_image: @moonimgs[moon_index])
82 game.save
83
84 storepage = Nokogiri::HTML(
85 open("http://store.steampowered.com/app/#{id}"))
86
87 img_id = 0
88 storepage.css(".highlight_screenshot_link").each do |node|
89 begin
90 imagepage = open(node["href"]).read
91
92 img_id += 1
93 img_filename = "#{id}-#{img_id}.jpg"
94 img_filepath = File.join(@config["images"], img_filename)
95
96 img_file = File.open(img_filepath, "w")
97 img_file.write(imagepage)
98 img_file.close
99
100 image = Image.new(game: game, filename: img_filename)
101 image.save
102 rescue OpenURI::HTTPError
103 puts "Error downloading an image"
104 end
105
106 sleep 2
107 end
108 end
109
110 title = node.at_css("h3").text
31 111
32all_achieves.sort! 112 if game.achievements_dataset.where(title: title).count > 0
33all_achieves.uniq! 113 achievement = game.achievements_dataset.where(title: title).first
114 else
115 achievement = Achievement.new(game: game, title: title)
116 achievement.save
117 end
34 118
35if config.key? "blacklist" 119 unless Did.where(profile: profile, achievement: achievement).count > 0
36 blacklist = File.read(config["blacklist"]).split("\n") 120 begin
37 all_achieves.reject! { |l| blacklist.include? l } 121 unlock = DateTime.strptime(
122 node.css(".achieveUnlockTime").text.lstrip[9..-1],
123 "%b %d, %Y @ %l:%M%P")
124 rescue ArgumentError
125 unlock = DateTime.strptime(
126 node.css(".achieveUnlockTime").text.lstrip[9..-1],
127 "%b %d @ %l:%M%P")
128 end
129
130 join = Did.new(
131 profile: profile,
132 achievement: achievement,
133 achieved_at: unlock)
134 join.save
135 end
136 end
137 end
138 end
38end 139end
39 140
40File.open(config["achievements"], "w") do |f| 141if ARGV[1] == "add"
41 f << all_achieves.join("\n") 142 userpath = ARGV[2]
143
144 if Profile.where(profile_path: userpath).count > 0
145 raise "Profile " + userpath + " already exists"
146 end
147
148 profile = Profile.new(profile_path: userpath)
149 profile.save
150
151 scrape_profile profile, true
152elsif ARGV[1] == "update"
153 if ARGV.size == 3
154 scrape_profile Profile.where(profile_path: ARGV[2]).first, false
155 else
156 Profile.all.each do |profile|
157 scrape_profile profile, false
158 end
159 end
160elsif ARGV[1] == "full"
161 if ARGV.size == 3
162 scrape_profile Profile.where(profile_path: ARGV[2]).first, true
163 else
164 Profile.all.each do |profile|
165 scrape_profile profile, true
166 end
167 end
168elsif ARGV[1] == "recolor"
169 Game.all.each do |game|
170 moon_index = Random.rand(@moonimgs.size)
171
172 game.moon_image = @moonimgs[moon_index]
173 game.save
174 end
42end 175end