├── .gitignore ├── LICENSE ├── README.md ├── app ├── .htaccess ├── AppCache.php ├── AppKernel.php ├── Resources │ └── views │ │ ├── base.html.twig │ │ ├── default │ │ ├── contact-success.html.twig │ │ ├── index.html.twig │ │ └── report.html.twig │ │ ├── emails │ │ ├── contact.html.twig │ │ └── report.html.twig │ │ ├── forms │ │ ├── article.html.twig │ │ ├── contact.html.twig │ │ ├── email-report-success.html.twig │ │ ├── email-report.html.twig │ │ └── subscriber.html.twig │ │ └── snippets │ │ ├── flickr-ajax-snippet.html.twig │ │ ├── flickr-images.html.twig │ │ ├── loading-animation.html.twig │ │ └── pinterest.html.twig ├── autoload.php └── config │ ├── config.yml │ ├── config_dev.yml │ ├── config_prod.yml │ ├── config_test.yml │ ├── example-parameters.yml │ ├── routing.yml │ ├── routing_dev.yml │ ├── security.yml │ └── services.yml ├── bin ├── console └── symfony_requirements ├── composer.json ├── composer.lock ├── doc └── img │ └── symfony-optimizer-splash.png ├── phpunit.xml.dist ├── runTests.sh ├── src ├── .htaccess └── AppBundle │ ├── AppBundle.php │ ├── Classes │ ├── AnalysisHelper.php │ ├── Analyzer.php │ ├── Bitly.php │ ├── CurlHelper.php │ └── Flickr.php │ ├── Controller │ └── DefaultController.php │ ├── DependencyInjection │ └── ConfigValidator.php │ ├── Entity │ ├── Article.php │ ├── ContactRequest.php │ ├── ReportEmailRecipient.php │ └── Subscriber.php │ └── Twig │ └── AppExtension.php ├── tests └── AppBundle │ └── Controller │ └── DefaultControllerTest.php ├── var ├── SymfonyRequirements.php ├── cache │ └── .gitkeep ├── logs │ └── .gitkeep └── sessions │ └── .gitkeep └── web ├── .htaccess ├── app.php ├── app_dev.php ├── apple-touch-icon.png ├── config.php ├── css ├── base │ ├── bootstrap.min.css │ ├── bootstrap.min.css.map │ ├── font-awesome.min.css │ └── reset.css ├── index │ ├── bubbles.css │ ├── how-it-works.css │ ├── index.css │ └── loading.css ├── main.css └── report │ └── report.css ├── favicon.ico ├── fonts ├── FontAwesome.otf ├── fontawesome-webfont.eot ├── fontawesome-webfont.svg ├── fontawesome-webfont.ttf ├── fontawesome-webfont.woff ├── fontawesome-webfont.woff2 ├── glyphicons-halflings-regular.eot ├── glyphicons-halflings-regular.svg ├── glyphicons-halflings-regular.ttf ├── glyphicons-halflings-regular.woff └── glyphicons-halflings-regular.woff2 ├── img ├── ads │ ├── MarketSamuraiBanner.jpg │ ├── bluehost │ │ └── 300x250 │ │ │ ├── bh_300x250_01.gif │ │ │ ├── bh_300x250_02.gif │ │ │ ├── bh_300x250_03.gif │ │ │ ├── bh_300x250_04.jpg │ │ │ └── bh_300x250_05.gif │ ├── elegantthemes │ │ └── 300x250.gif │ └── semrush_banner_300_250.png ├── article-optimizer-example-report.png ├── article-optimizer-logo.png ├── backgrounds │ ├── groovepaper.png │ └── navy_blue.png ├── badges │ ├── 100-percent-optimized │ │ ├── blue-oval-100-percent-optimized.png │ │ ├── brown-100-percent-optimized.png │ │ ├── brown-leather-100-percent-optimized.png │ │ ├── excellent-article-award.png │ │ ├── excellent-content-award.png │ │ ├── excellent-content.png │ │ ├── fantastic-article-award.png │ │ ├── fantastic-content-badge.png │ │ ├── green-100-percent-optimized.png │ │ ├── optimal-article.png │ │ ├── optimized-content-badge.png │ │ ├── optimized.png │ │ ├── perfect-article-award.png │ │ ├── perfect-article.png │ │ ├── seo-optimized-article.png │ │ ├── silver-circle-100-percent-optimized.png │ │ ├── super-article-award.png │ │ ├── super-content.png │ │ ├── superb-content-award.png │ │ ├── well-done-article-award.png │ │ ├── winning-article-badge.png │ │ ├── winning-content-badge.png │ │ └── winning-writing.png │ └── categories │ │ ├── arts-entertainment │ │ └── art-entertainment-award.png │ │ ├── business │ │ └── business-article-award.png │ │ ├── computer-internet │ │ └── computers-internet-award.png │ │ ├── culture-politics │ │ └── culture-politics-badge.png │ │ ├── gaming │ │ └── gaming-award.png │ │ ├── health │ │ └── health-content-award.png │ │ ├── law-crime │ │ └── law-and-crime-badge.png │ │ ├── recreation │ │ └── recreation-badge.png │ │ ├── religion │ │ └── religion-badge.png │ │ ├── science-technology │ │ └── science.png │ │ ├── sports │ │ └── sports-award.png │ │ ├── unknown │ │ └── default-article-badge.png │ │ └── weather │ │ └── weather-badge.png ├── favicon.png └── loading.gif ├── js ├── bootstrap.min.js ├── clipboard.min.js ├── index.js ├── jquery-2.2.4.min.js ├── optimizer-client.js ├── report-client.js └── report.js └── robots.txt /.gitignore: -------------------------------------------------------------------------------- 1 | .phpintel/ 2 | /app/config/parameters.yml 3 | /build/ 4 | /phpunit.xml 5 | /var/* 6 | !/var/cache 7 | /var/cache/* 8 | !var/cache/.gitkeep 9 | !/var/logs 10 | /var/logs/* 11 | !var/logs/.gitkeep 12 | !/var/sessions 13 | /var/sessions/* 14 | !var/sessions/.gitkeep 15 | !var/SymfonyRequirements.php 16 | /vendor/ 17 | /web/bundles/ 18 | /web/saved-reports/* 19 | /web/.sass-cache 20 | *.sublime-project 21 | *.sublime-workspace -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Zack Proser 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [Article Optimizer](https://www.article-optimize.com) 2 | 3 | ![Article Optimizer Screenshot](doc/img/symfony-optimizer-splash.png) 4 | 5 | ## Overview 6 | 7 | This application allows users to submit text articles on any subject and have their content analyzed. This application is live and free to use at [www.article-optimize.com](https://www.article-optimize.com). For a more in-depth write-up of this application, read [my technical blog post about it here](https://www.zackproser.com/blog/article/I-Open-Sourced-My-Content-Analysis-Tool). 8 | 9 | The resulting report will make suggestions on keyword, phrase and sentiment optimizations that the author can make to improve the legibility and hopefully the overall rankings of their work. The report will also search for high quality on-topic images that are 10 | copyright-free, allowing the author to quickly drop them into their content to improve its media richness. 11 | 12 | This project was open-sourced in order to: 13 | * give back to the open-source community 14 | * provide a demonstration of a non-trivial Symfony web app 15 | * demonstrate a use case for IBM's Watson linguistic processing API 16 | 17 | This application, its assets and all associated documentation was created by Zack Proser: 18 | 19 | * [Portfolio site](https://www.zackproser.com) 20 | * [Email](mailto:zackproser@gmail.com) 21 | * [Github](https://www.github.com/zackproser) 22 | 23 | ## Application at a Glance 24 | 25 | ### Content Analysis 26 | The Article Optimizer is a search engine optimization tool for content authors. It provides a useful free service in providing authors with detailed analysis of their unique content, leveraging IBM's Watson linguistic analysis functionality, along with suggestions for improving long term search rankings. 27 | 28 | ### Persistent Reports 29 | Article reports are written to the server as soon as they are complete, so that they can be looked up in perpetuity and served quickly, since they are static HTML pages. 30 | 31 | ### Targeted Content Marketing 32 | In exchange, the application can gather user email addresses and subscribe them to a mailchimp list for longterm engagement and marketing of related products, if the user provides their consent to receive such communications. 33 | 34 | ### Display Advertising 35 | In addition, the application can be configured to display two separate advertising modules, each featuring 2 separate advertisements, integrated into the user's content report for the purposes of affiliate marketing or direct marketing of the application's owners' products or services. 36 | 37 | ### Social Media Sharing 38 | To maximize social sharing of reports, each report gets its own programmatically generated Bitly link so that sharing and traffic can be monitored at a granular report level. Users can also share their report (and thereby the application itself) via Twitter, Pinterest, Facebook or by emailing the report directly to a friend or colleague, all with one click. 39 | 40 | ### Analytics 41 | The application allows you to drop-in the Google Analytics property id you would like associated, allowing you to track usage patterns and traffic across all pages and reports with no additional effort or custom development. 42 | 43 | ### Services Integrated 44 | 45 | The Article Optimizer leverages 46 | 47 | * [Alchemy API - now part of IBM's Watson cloud](https://www.ibm.com/watson/developercloud/alchemy-language.html) 48 | * [Mailchimp](https://mailchimp.com/) 49 | * [Flickr](https://www.flickr.com/) 50 | * [Google Analytics](https://google.com/analytics) 51 | * [Bitly](https://bitly.com) 52 | * [Pinterest](https://pinterest.com) 53 | * [Facebook](https://www.facebook.com) 54 | * [Twitter](https://www.twitter.com) 55 | 56 | ### Tech Stack 57 | This application was built using: 58 | 59 | * [Symfony 3.1](https://symfony.com) 60 | * [PHP 7](https://php.net) 61 | * [Bootstrap](https://getbootstrap.com) 62 | * [jQuery](https://jquery.com) 63 | * [nginx](https://nginx.com) 64 | * [Linode VPS](https://linode.com) 65 | -------------------------------------------------------------------------------- /app/.htaccess: -------------------------------------------------------------------------------- 1 | 2 | Require all denied 3 | 4 | 5 | Order deny,allow 6 | Deny from all 7 | 8 | -------------------------------------------------------------------------------- /app/AppCache.php: -------------------------------------------------------------------------------- 1 | getEnvironment(), ['dev', 'test'], true)) { 23 | $bundles[] = new Symfony\Bundle\DebugBundle\DebugBundle(); 24 | $bundles[] = new Symfony\Bundle\WebProfilerBundle\WebProfilerBundle(); 25 | $bundles[] = new Sensio\Bundle\DistributionBundle\SensioDistributionBundle(); 26 | $bundles[] = new Sensio\Bundle\GeneratorBundle\SensioGeneratorBundle(); 27 | } 28 | 29 | return $bundles; 30 | } 31 | 32 | public function getRootDir() 33 | { 34 | return __DIR__; 35 | } 36 | 37 | public function getCacheDir() 38 | { 39 | return dirname(__DIR__).'/var/cache/'.$this->getEnvironment(); 40 | } 41 | 42 | public function getLogDir() 43 | { 44 | return dirname(__DIR__).'/var/logs'; 45 | } 46 | 47 | public function registerContainerConfiguration(LoaderInterface $loader) 48 | { 49 | $loader->load($this->getRootDir().'/config/config_'.$this->getEnvironment().'.yml'); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /app/Resources/views/base.html.twig: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | {# jQuery and Validation & Loading Scripts #} 14 | {% javascripts 15 | 'js/jquery-2.2.4.min.js' 16 | 'js/bootstrap.min.js' 17 | %} 18 | 19 | {% endjavascripts %} 20 | 21 | {% stylesheets 'css/base/*.css' %} 22 | {# Render all stylesheets common to both index and report views #} 23 | 24 | {% endstylesheets %} 25 | 26 | {{ appTitle }} 27 | 28 | {% block title %}Welcome!{% endblock %} 29 | {% block stylesheets %}{% endblock %} 30 | 31 | 32 | {% block header %}{% endblock %} 33 | 34 | {% if google_analytics_enabled is defined and google_analytics_enabled %} 35 | 45 | {% endif %} 46 | 47 | 48 | 49 |
50 | {% block body %}{% endblock %} 51 |
52 | {% block javascripts %}{% endblock %} 53 | 54 | 55 | -------------------------------------------------------------------------------- /app/Resources/views/default/contact-success.html.twig: -------------------------------------------------------------------------------- 1 | {% block stylesheets %} 2 | {% stylesheets 'css/index/*.css' %} 3 | {# Render all index page-specific stylesheets #} 4 | 5 | {% endstylesheets %} 6 | {% endblock %} 7 | 8 | {% block body %} 9 | 10 |

{{ contact_success_response }}

11 | 12 | {% endblock %} -------------------------------------------------------------------------------- /app/Resources/views/default/index.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | 3 | {% block javascripts %} 4 | 5 | 6 | {% endblock %} 7 | 8 | {% block stylesheets %} 9 | {% stylesheets 'css/index/*.css' %} 10 | {# Render all index page-specific stylesheets #} 11 | 12 | {% endstylesheets %} 13 | {% endblock %} 14 | 15 | {% block body %} 16 | 17 | {# Handle returning user vs new user logic #} 18 | {% if returningUser is defined and returningUser == 1 %} 19 | 20 | {% else %} 21 | 22 | {% endif %} 23 | 24 | 25 | 26 |
27 | 28 | {# Include loading animation overlay #} 29 | {{ include('snippets/loading-animation.html.twig') }} 30 | 31 |
32 | 33 | {# Left Gutter #} 34 |
35 | {# End Left Gutter #} 36 | 37 |
38 | 39 | {# Begin Main Logo #} 40 |
41 |
42 | {# Create the expected 27 bubbles in a loop #} 43 | {# CSS expects bubbles to have a class in format: x22 #} 44 | {% for i in 1..27 %} 45 |
46 | {% endfor %} 47 |
48 | 49 | 50 | 51 |
52 | 53 | {# End Main Logo #} 54 | 55 | {# Begin Navbar #} 56 |
57 | 62 |
63 | {# End Navbar #} 64 | 65 |
66 |
67 | 68 | {{ render(controller('AppBundle:Default:articleForm')) }} 69 |
70 |
71 | 72 | {# Begin How it Works Panel #} 73 |
74 |

Almost done with an article but want to receive tips for optimizing its search rankings and performance?

75 | 76 |

Simply paste the entirety of your article into the form on the Optimize tab and submit it to receive a detailed analysis that includes keyword recommendations, copyright-free images, and more.

77 | 78 |

Your report will be saved so that you can share it with clients or friends and it will look like this:

79 | 80 |
81 | {# End How it Works Panel #} 82 | 83 |
84 | 85 | 86 | {{ render(controller('AppBundle:Default:contactForm')) }} 87 |
88 | 89 |
90 | 91 | {# Begin Signature Panel #} 92 | 95 | {# End End Signature Panel #} 96 | 97 | {# Begin Sign-up Modal #} 98 | 112 | {# End Sign-up Modal #} 113 | 114 |
115 | 116 | {# Right Gutter #} 117 |
118 | {# End Right Gutter #} 119 | 120 |
121 | 122 | 123 | 124 | 125 | {% endblock %} 126 | 127 | -------------------------------------------------------------------------------- /app/Resources/views/default/report.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | 3 | {% block stylesheets %} 4 | {% stylesheets 'css/report/*.css' %} 5 | {# Render all report page-specific stylesheets #} 6 | 7 | {% endstylesheets %} 8 | {% endblock %} 9 | 10 | {% block javascripts %} 11 | 12 | 13 | 14 | {% endblock %} 15 | 16 | {% block header %} 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | {% endblock %} 26 | 27 | {% block body %} 28 | 29 |
30 | 31 | {% if analysis.success is defined and analysis.success %} 32 | 33 | {# Render the first ribbon, passing the second paramter 'first' #} 34 | {# which will result in the 'body' padding class most ribbons use being omitted #} 35 | {{ ribbon('Your ' ~ analysis.category ~ ' Article Analysis', 'first') }} 36 | 37 | {# Begin initial presentation and sharing section #} 38 |
39 | 40 | {# Begin Badge & Text Pedestal Row #} 41 |
42 |
43 |
44 | {# If the analysis was successful, render the report data #} 45 | {% if analysis.badge is defined and analysis.badge %} 46 | 47 | {% endif %} 48 |
49 | 50 | {% if analysis.articleBody is defined %} 51 |

Your Article:

52 |
53 | 54 | {{ analysis.articleBody }} 55 | 56 |
57 | {% endif %} 58 |
59 | 60 | 61 |
62 | {# We have a successful analysis - render sharing info #} 63 |
64 |

65 | This is your permanent article report. You can share the link with friends or e-mail it to clients to show them the quality of your work. 66 |

67 |
68 |
69 | 70 | 71 | {# End Badge & Text Pedestal Row #} 72 | 73 | {# Begin Sharing Row #} 74 |
75 | 76 |
77 | 78 | 79 | 80 | {# Begin Shortlink Section #} 81 | 82 | 88 | {# End Shortlink Section #} 89 | 90 |
91 | 92 |
93 | 94 | 101 | 102 |
103 | 104 |
{# End Sharing Row #} 105 | 106 |
{# End container-fluid #} 107 | 108 |
{# End Jumbotron First Report Backing #} 109 | 110 | {{ ribbon('Feature Analysis') }} 111 | 112 | {# Begin Article Stats Section #} 113 |
114 | 115 |
116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 134 | 135 | 140 | 151 | 152 | {% if analysis.categorySucceeded is defined and analysis.categorySucceeded %} 153 | 154 | 155 | 156 | 157 | 158 | {% endif %} 159 | 160 |
FeatureSearch Engines See AsAnalysis
Total Word Count 136 | {% if analysis.wordCount is defined %} 137 | {{ analysis.wordCount }} 138 | {% endif %} 139 | 141 | {% if analysis.wordCount is defined %} 142 | {% if analysis.wordCount <= 250 %} 143 | Poor 144 | {% elseif analysis.wordCount <= 500 %} 145 | Okay 146 | {% elseif analysis.wordCount > 500 %} 147 | Great 148 | {% endif %} 149 | {% endif %} 150 |
Content Category{{ analysis.category }}
161 |
162 |
163 | {# If concepts analysis succeeded, render concept table #} 164 | {% if analysis.conceptsSucceeded is defined and analysis.conceptsSucceeded %} 165 |

166 | Roughly speaking, these are the concepts that search engines will consider most important in your article. 167 |

168 | 169 |

170 | It can be useful to scope out content that is already published and competing for the same keywords. Click the "View Competing URLs" link to see the top-performing content for each concept. 171 |

172 |
173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | {% for concept in analysis.concepts %} 181 | 182 | 187 | 192 | 197 | 198 | {% endfor %} 199 | 200 |
ConceptRelevanceCompeting URLs
183 | {% if concept.text is defined %} 184 | {{ concept.text }} 185 | {% endif %} 186 | 188 | {% if concept.relevance is defined %} 189 | {{ concept.relevance }} 190 | {% endif %} 191 | 193 | {% if concept.text is defined %} 194 | View Competing URLs 195 | {% endif %} 196 |
201 |
202 | {% endif %} 203 | {# End concepts table #} 204 | 205 |
{# End Jumbotron Report Backing #} 206 | 207 | {# Begin Keywords Section #} 208 | {% if analysis.keywordsSucceeded is defined and analysis.keywordsSucceeded %} 209 | {{ ribbon('Keyword Sentiment Analysis') }} 210 | 211 |
212 |

213 | This section breaks down whether the top keywords in your article have positive, negative or neutral sentiment or attitude attached to them. 214 |

215 | 216 |

217 | It can be useful to spot-check this section to ensure that your descriptions of your concepts are reflective of your intention. 218 |

219 | 220 | {{ legend('Keyword Sentiment Legend', ['positive', 'neutral', 'negative']) }} 221 | 222 |
223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | {% for keyword in analysis.keywords %} 231 | 242 | 243 | 244 | 254 | 255 | {% endfor %} 256 | 257 |
KeywordRelevanceSentiment
{% if keyword.text is defined %}{{ keyword.text }}{% endif %}{% if keyword.relevance is defined %}{{ keyword.relevance }}{% endif %} 245 | {% for type, score in keyword.sentiment %} 246 | {% if type is defined %} 247 | {{ type|replace({ ('type'): '' }) }}   248 | {% endif %} 249 | {% if score is defined %} 250 | {{ score|replace({ ('score'): '' }) }} 251 | {% endif %} 252 | {% endfor %} 253 |
258 |
259 | {# Begin Suggested Keywords Section #} 260 | {% if analysis.suggestedKeywordsSucceeded is defined and analysis.suggestedKeywordsSucceeded %} 261 |

You can increase traffic to your article by working in these high-traffic keywords in a natural and non-spammy way.

262 | 267 | {% endif %} 268 | {# End Suggested Keywords Section #} 269 |
270 | {% endif %} 271 | {# End Keywords Section #} 272 | 273 | 274 | 275 | {# Begin Advertisement Block #1 #} 276 | {% if analysis.adsEnabled is defined and analysis.adsEnabled %} 277 | 278 | {{ ribbon('Recommended Tools') }} 279 | 280 | {{ advertisement(analysis.ads[0], analysis.ads[1]) }} 281 | 282 | {% endif %} 283 | {# End Advertisement Block #1 #} 284 | 285 | {# Begin Entities Analysis #} 286 | {% if analysis.entitiesSucceeded is defined and analysis.entitiesSucceeded %} 287 | {{ ribbon('Entities Sentiment Analysis') }} 288 | 289 | {# Begin Entities Section #} 290 |
291 |

292 | This section breaks down the positive, negative, and neutral associations your article attaches to its top entities. 293 |

294 |

295 | Make sure these square with your intended message. 296 |

297 | 298 | {{ legend('Entities Sentiment Legend', ['positive', 'neutral', 'negative']) }} 299 |
300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | {% for entity in analysis.entities %} 310 | 321 | 326 | 331 | 336 | 348 | 353 | 354 | {% endfor %} 355 | 356 |
EntityTypeRelevanceSentimentCount
322 | {% if entity.text is defined %} 323 | {{ entity.text }} 324 | {% endif %} 325 | 327 | {% if entity.type is defined %} 328 | {{ entity.type }} 329 | {% endif %} 330 | 332 | {% if entity.relevance is defined %} 333 | {{ entity.relevance }} 334 | {% endif %} 335 | 337 | {% if entity.sentiment is defined %} 338 | {% for type, score in entity.sentiment %} 339 | {% if type is defined %} 340 | {{ type|replace({ ('type'): '' }) }}   341 | {% endif %} 342 | {% if score is defined %} 343 | {{ score|replace({ ('score'): '' }) }} 344 | {% endif %} 345 | {% endfor %} 346 | {% endif %} 347 | 349 | {% if entity.count is defined %} 350 | {{ entity.count }} 351 | {% endif %} 352 |
357 |
358 |
359 | {% endif %} 360 | {# End Entities Analysis Section #} 361 | 362 | {# Begin Phrase Density Section #} 363 | {% if analysis.phraseDensitySucceeded is defined and analysis.phraseDensitySucceeded %} 364 | {{ ribbon('Phrase Density') }} 365 |
366 |

367 | Phrase density describes how often the key phrases in your article appear. Your writing should be natural: search engines can detect "keyword stuffing", or artificially including a keyword or phrase too frequently. 368 |

369 | 370 |

371 | You should strongly consider using synonyms for any phrase highlighted in red here. 372 |

373 | 374 | {{ legend('Density Legend', ['Good', 'Low', 'Stuffing']) }} 375 |
376 | 377 | 378 | 379 | 380 | 381 | 382 | {% for level, array in analysis.phraseDensity %} 383 | {% for phrase, percentage in array %} 384 | 385 | 386 | 387 | 388 | {% endfor %} 389 | {% endfor %} 390 | 391 |
PhraseFrequency
{{ phrase }}{{ percentage }}
392 |
393 |
394 | {% endif %} 395 | {# End Phrase Density Section #} 396 | 397 | {# Begin Advertisement Block #2 #} 398 | {% if analysis.adsEnabled is defined and analysis.adsEnabled %} 399 | 400 | {{ ribbon('Content Boosting Services') }} 401 | 402 | {{ advertisement(analysis.ads[2], analysis.ads[3]) }} 403 | 404 | {% endif %} 405 | {# End Advertisement Block #2 #} 406 | 407 | {# Begin Images Section #} 408 | {{ ribbon('Copyright-Free Images') }} 409 | 410 |
411 | {# Render the images description loading panel if we have searchable keywords #} 412 | {% if analysis.flickrKeywords is defined and analysis.flickrKeywords|length > 0 %} 413 | 414 |

415 | These images have been selected because they were published with creative usage licenses that allow you to include them in your article. 416 |

417 |

418 | Including high-quality on-topic images in your content is a great way to stand out, increase inbound links to your content, and improve your article's search engine ranking. 419 |

420 |
421 | 422 |
423 |
424 |

Searching for Copyright Free Images...

425 | 426 |
427 | {% else %} 428 | {# Otherwise, render an error #} 429 |
Sorry! We weren't able to find any images for this topic!
430 | {% endif %} 431 |
432 | {# End Images Section #} 433 | 434 | {# Send Report by Email Modal #} 435 | 450 | {# End Repot by Email Modal #} 451 | 452 | {# Load Pinit.js for creating a Pinterest Pin It button #} 453 | {{ include('snippets/pinterest.html.twig') }} 454 | 455 | {# Load Flickr Ajax Snippet to fetch images based on concepts #} 456 | {{ include('snippets/flickr-ajax-snippet.html.twig', {keywords: analysis.flickrKeywords }) }} 457 | {% else %} 458 | {# Otherwise, analysis failef for some reason - render an error state #} 459 |
460 | 461 |

Oops! Something went horribly wrong!

462 |

We apologize, but we are unable to process your report right now.

463 |

Please ensure you have submitted the entire body text of your article, or please try again later.

464 | Go Back and Try Again 465 |
466 | {% endif %} 467 | 468 | {% endblock %} -------------------------------------------------------------------------------- /app/Resources/views/emails/contact.html.twig: -------------------------------------------------------------------------------- 1 | {{ message }} 2 | 3 | {{requestor}} 4 | -------------------------------------------------------------------------------- /app/Resources/views/emails/report.html.twig: -------------------------------------------------------------------------------- 1 |

Someone sent you an article report!

2 | 3 |

Article Optimizer is a free tool for:

4 | 5 | -------------------------------------------------------------------------------- /app/Resources/views/forms/article.html.twig: -------------------------------------------------------------------------------- 1 | {# Begin Main Article Input Form #} 2 | {{ form_start(articleForm, {'name': 'articleForm'}) }} 3 |
4 | {{ form_row(articleForm.body) }} 5 | {{ form_end(articleForm) }} 6 | {# End Main Article Input Form #} -------------------------------------------------------------------------------- /app/Resources/views/forms/contact.html.twig: -------------------------------------------------------------------------------- 1 | {# Begin Contact Us Form #} 2 | {{ form_start(contactForm, {'name': 'contactForm'}) }} 3 |
4 | {{ form_end(contactForm) }} 5 |
6 | {# End Contact Us Form #} -------------------------------------------------------------------------------- /app/Resources/views/forms/email-report-success.html.twig: -------------------------------------------------------------------------------- 1 | {{ successMessage }} -------------------------------------------------------------------------------- /app/Resources/views/forms/email-report.html.twig: -------------------------------------------------------------------------------- 1 | {# Begin Email Report Form #} 2 | {{ form_start(emailReportForm) }} 3 |
4 | {{ form_end(emailReportForm) }} 5 |
6 | {# End Email Report Form #} -------------------------------------------------------------------------------- /app/Resources/views/forms/subscriber.html.twig: -------------------------------------------------------------------------------- 1 | {# Begin Subscriber Form #} 2 | {{ form(subscriberForm, { attr: {'id': 'subscriberForm'}}) }} 3 |
4 | {{ form_widget(subscriberForm.address) }} 5 |
6 |
7 | 8 | 9 |
10 | {# End Subscriber Form #} -------------------------------------------------------------------------------- /app/Resources/views/snippets/flickr-ajax-snippet.html.twig: -------------------------------------------------------------------------------- 1 | {# Closure to run on pageload to make ajax requests #} 2 | {# to backend for flickr images based on the top concepts #} 3 | -------------------------------------------------------------------------------- /app/Resources/views/snippets/flickr-images.html.twig: -------------------------------------------------------------------------------- 1 | {# format: http://farm{farm-id}.static.flickr.com/{server-id}/{id}_{secret}.jpg #} 2 |
3 |
    4 | {% for img in photos %} 5 |
  • 6 |
    7 |
    8 |
    9 | 10 |
    11 |
    12 |
    13 |
  • 14 | {% endfor %} 15 |
16 |
-------------------------------------------------------------------------------- /app/Resources/views/snippets/loading-animation.html.twig: -------------------------------------------------------------------------------- 1 | {# Begin Loading Overlay Animation #} 2 | 6 | {# End Loading Overlay Animation #} -------------------------------------------------------------------------------- /app/Resources/views/snippets/pinterest.html.twig: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/autoload.php: -------------------------------------------------------------------------------- 1 | tag that will appear in the tab 63 | app_title: "SEO Content Optimizer" 64 | ###################### 65 | # Contact Requests 66 | ###################### 67 | # The subject line that will appear on notification emails 68 | # when users submit feedback via the Contact form 69 | contact_email_subject: "Contact Request - Article Optimizer" 70 | # The email address that contact requests should be sent to 71 | # If you are the sole administrator of this application, 72 | # then this should be set to the email address at which 73 | # you want to receive user feedback, requests, complaints, etc. 74 | contact_email_delivery_address: "zackproser@gmail.com" 75 | # The customizable message that will appear to users 76 | # after they successfully submit feedback to you via 77 | # the contact form 78 | contact_success_response: "Thanks! We'll be in touch" 79 | ####################### 80 | # Report Emailing 81 | ####################### 82 | # The customizable subject line that will appear to the end recipient 83 | # any time that a user of this app sends a processed article analysis report 84 | # to an external user via the "Email this Report" functionality 85 | report_email_subject: "You've received an article optimization report" 86 | # The customizable message that will be displayed to the user 87 | # after they successfully submit a contact message to the app 88 | report_email_success_message: "Success! Your report was sent." 89 | ####################### 90 | # Alchemy API 91 | ####################### 92 | # Alchemy provides semantic analysis functions 93 | # Obtain your api key from www.alchemyapi.com 94 | alchemy_api_key: 'zgdhgg299999a0d5e93ef7518efr4f36s' 95 | # Alchemy API base URL: 96 | alchemy_api_base_url: https://gateway-a.watsonplatform.net/calls 97 | # Limit number of named entities to extract - defaults to 10 98 | alchemy_max_entities_retrieve: 15 99 | # Whether or not alchemy responses should include sentiment analysis - defaults to 1 (true) 100 | alchemy_include_sentiment: 1 101 | ####################### 102 | # Bitly 103 | ####################### 104 | # Bitly shortens URLs and provides metrics on its shortlinks 105 | # Obtain your token from www.bitly.com 106 | bitly_token: 'splrtrsdfh698288bf67999334' 107 | ####################### 108 | # Flickr API 109 | ####################### 110 | # Flickr provides photos to be used with articles 111 | flickr_api_key: 'zskleid44af23b666664c63' 112 | ####################### 113 | # Article Reports 114 | ####################### 115 | # The directory containing all of the badges for 100% optimized articles 116 | optimal_badge_directory: 100-percent-optimized 117 | # The directory containing all of the category badges (sub-optimal) 118 | category_badge_directory: categories 119 | ######################## 120 | # Advertising 121 | ######################## 122 | # Whether or not advertisement blocks should be rendered, using the following data 123 | ads_enabled: true 124 | advertisements: 125 | - cta: Download Perfect Wordpress Themes for 48 cents each 126 | img_path: ads/elegantthemes/300x250.gif 127 | url: http://www.elegantthemes.com/affiliates/idevaffiliate.php?id=9762_0_1_10 128 | - cta: Host your own article site and keep all the revenue 129 | img_path: ads/bluehost/300x250/bh_300x250_02.gif 130 | url: http://www.bluehost.com/track/zackproser/CODE43 131 | - cta: Automatically find keywords that earn money 132 | img_path: ads/MarketSamuraiBanner.jpg 133 | url: http://www.marketsamurai.com/c/articleoptimizer 134 | - cta: Outrank your competitors for competitive keywords 135 | img_path: ads/semrush_banner_300_250.png 136 | url: http://www.semrush.com/sem.html?ref=487183848 -------------------------------------------------------------------------------- /app/config/routing.yml: -------------------------------------------------------------------------------- 1 | _welcome: 2 | path: / 3 | defaults: { _controller: AppBundle:Default:index } 4 | 5 | article_process: 6 | path: /report 7 | defaults: { _controller: AppBundle:Default:articleForm } 8 | 9 | flickr_serve: 10 | path: /flickr-serve 11 | defaults: { _controller: Flickr:searchAction } 12 | 13 | bitly_shorten: 14 | path: /bitly-shorten 15 | defaults: { _controller: Bitly:shortenUrl } 16 | 17 | subscribe_process: 18 | path: /subscribe 19 | defaults: { _controller: AppBundle:Default:subscribe } 20 | 21 | contact_process: 22 | path: /contact 23 | defaults: { _controller: AppBundle:Default:contactForm } 24 | 25 | email_report_process: 26 | path: /email-report 27 | defaults: { _controller: AppBundle:Default:emailReport } 28 | 29 | -------------------------------------------------------------------------------- /app/config/routing_dev.yml: -------------------------------------------------------------------------------- 1 | _wdt: 2 | resource: "@WebProfilerBundle/Resources/config/routing/wdt.xml" 3 | prefix: /_wdt 4 | 5 | _profiler: 6 | resource: "@WebProfilerBundle/Resources/config/routing/profiler.xml" 7 | prefix: /_profiler 8 | 9 | _errors: 10 | resource: "@TwigBundle/Resources/config/routing/errors.xml" 11 | prefix: /_error 12 | 13 | _main: 14 | resource: routing.yml 15 | -------------------------------------------------------------------------------- /app/config/security.yml: -------------------------------------------------------------------------------- 1 | # To get started with security, check out the documentation: 2 | # http://symfony.com/doc/current/book/security.html 3 | security: 4 | 5 | # http://symfony.com/doc/current/book/security.html#where-do-users-come-from-user-providers 6 | providers: 7 | in_memory: 8 | memory: ~ 9 | 10 | firewalls: 11 | # disables authentication for assets and the profiler, adapt it according to your needs 12 | dev: 13 | pattern: ^/(_(profiler|wdt)|css|images|js)/ 14 | security: false 15 | 16 | main: 17 | anonymous: ~ 18 | # activate different ways to authenticate 19 | 20 | # http_basic: ~ 21 | # http://symfony.com/doc/current/book/security.html#a-configuring-how-your-users-will-authenticate 22 | 23 | # form_login: ~ 24 | # http://symfony.com/doc/current/cookbook/security/form_login_setup.html 25 | -------------------------------------------------------------------------------- /app/config/services.yml: -------------------------------------------------------------------------------- 1 | parameters: 2 | #parameter: value 3 | services: 4 | analyzer: 5 | class: AppBundle\Classes\Analyzer 6 | arguments: ["%alchemy_api_key%", "@curl", "@logger", "@service_container"] 7 | analysis_helper: 8 | class: AppBundle\Classes\AnalysisHelper 9 | arguments: ["@logger", "@service_container"] 10 | curl: 11 | class: AppBundle\Classes\CurlHelper 12 | arguments: ["%curl_timeout%", "@logger"] 13 | flickr: 14 | class: AppBundle\Classes\Flickr 15 | arguments: ["@service_container", "%flickr_api_key%", "@curl", "@logger"] 16 | bitly: 17 | class: AppBundle\Classes\Bitly 18 | arguments: ["%hostname%", "%bitly_token%", "@curl", "@logger"] 19 | app.twig_extension: 20 | class: AppBundle\Twig\AppExtension 21 | public: false 22 | tags: 23 | - { name: twig.extension } -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | getParameterOption(['--env', '-e'], getenv('SYMFONY_ENV') ?: 'dev'); 23 | $debug = getenv('SYMFONY_DEBUG') !== '0' && !$input->hasParameterOption(['--no-debug', '']) && $env !== 'prod'; 24 | 25 | if ($debug) { 26 | Debug::enable(); 27 | } 28 | 29 | $kernel = new AppKernel($env, $debug); 30 | $application = new Application($kernel); 31 | $application->run($input); 32 | -------------------------------------------------------------------------------- /bin/symfony_requirements: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | getPhpIniConfigPath(); 9 | 10 | echo_title('Symfony Requirements Checker'); 11 | 12 | echo '> PHP is using the following php.ini file:'.PHP_EOL; 13 | if ($iniPath) { 14 | echo_style('green', ' '.$iniPath); 15 | } else { 16 | echo_style('warning', ' WARNING: No configuration file (php.ini) used by PHP!'); 17 | } 18 | 19 | echo PHP_EOL.PHP_EOL; 20 | 21 | echo '> Checking Symfony requirements:'.PHP_EOL.' '; 22 | 23 | $messages = array(); 24 | foreach ($symfonyRequirements->getRequirements() as $req) { 25 | /** @var $req Requirement */ 26 | if ($helpText = get_error_message($req, $lineSize)) { 27 | echo_style('red', 'E'); 28 | $messages['error'][] = $helpText; 29 | } else { 30 | echo_style('green', '.'); 31 | } 32 | } 33 | 34 | $checkPassed = empty($messages['error']); 35 | 36 | foreach ($symfonyRequirements->getRecommendations() as $req) { 37 | if ($helpText = get_error_message($req, $lineSize)) { 38 | echo_style('yellow', 'W'); 39 | $messages['warning'][] = $helpText; 40 | } else { 41 | echo_style('green', '.'); 42 | } 43 | } 44 | 45 | if ($checkPassed) { 46 | echo_block('success', 'OK', 'Your system is ready to run Symfony projects'); 47 | } else { 48 | echo_block('error', 'ERROR', 'Your system is not ready to run Symfony projects'); 49 | 50 | echo_title('Fix the following mandatory requirements', 'red'); 51 | 52 | foreach ($messages['error'] as $helpText) { 53 | echo ' * '.$helpText.PHP_EOL; 54 | } 55 | } 56 | 57 | if (!empty($messages['warning'])) { 58 | echo_title('Optional recommendations to improve your setup', 'yellow'); 59 | 60 | foreach ($messages['warning'] as $helpText) { 61 | echo ' * '.$helpText.PHP_EOL; 62 | } 63 | } 64 | 65 | echo PHP_EOL; 66 | echo_style('title', 'Note'); 67 | echo ' The command console could use a different php.ini file'.PHP_EOL; 68 | echo_style('title', '~~~~'); 69 | echo ' than the one used with your web server. To be on the'.PHP_EOL; 70 | echo ' safe side, please check the requirements from your web'.PHP_EOL; 71 | echo ' server using the '; 72 | echo_style('yellow', 'web/config.php'); 73 | echo ' script.'.PHP_EOL; 74 | echo PHP_EOL; 75 | 76 | exit($checkPassed ? 0 : 1); 77 | 78 | function get_error_message(Requirement $requirement, $lineSize) 79 | { 80 | if ($requirement->isFulfilled()) { 81 | return; 82 | } 83 | 84 | $errorMessage = wordwrap($requirement->getTestMessage(), $lineSize - 3, PHP_EOL.' ').PHP_EOL; 85 | $errorMessage .= ' > '.wordwrap($requirement->getHelpText(), $lineSize - 5, PHP_EOL.' > ').PHP_EOL; 86 | 87 | return $errorMessage; 88 | } 89 | 90 | function echo_title($title, $style = null) 91 | { 92 | $style = $style ?: 'title'; 93 | 94 | echo PHP_EOL; 95 | echo_style($style, $title.PHP_EOL); 96 | echo_style($style, str_repeat('~', strlen($title)).PHP_EOL); 97 | echo PHP_EOL; 98 | } 99 | 100 | function echo_style($style, $message) 101 | { 102 | // ANSI color codes 103 | $styles = array( 104 | 'reset' => "\033[0m", 105 | 'red' => "\033[31m", 106 | 'green' => "\033[32m", 107 | 'yellow' => "\033[33m", 108 | 'error' => "\033[37;41m", 109 | 'success' => "\033[37;42m", 110 | 'title' => "\033[34m", 111 | ); 112 | $supports = has_color_support(); 113 | 114 | echo($supports ? $styles[$style] : '').$message.($supports ? $styles['reset'] : ''); 115 | } 116 | 117 | function echo_block($style, $title, $message) 118 | { 119 | $message = ' '.trim($message).' '; 120 | $width = strlen($message); 121 | 122 | echo PHP_EOL.PHP_EOL; 123 | 124 | echo_style($style, str_repeat(' ', $width).PHP_EOL); 125 | echo_style($style, str_pad(' ['.$title.']', $width, ' ', STR_PAD_RIGHT).PHP_EOL); 126 | echo_style($style, str_pad($message, $width, ' ', STR_PAD_RIGHT).PHP_EOL); 127 | echo_style($style, str_repeat(' ', $width).PHP_EOL); 128 | } 129 | 130 | function has_color_support() 131 | { 132 | static $support; 133 | 134 | if (null === $support) { 135 | if (DIRECTORY_SEPARATOR == '\\') { 136 | $support = false !== getenv('ANSICON') || 'ON' === getenv('ConEmuANSI'); 137 | } else { 138 | $support = function_exists('posix_isatty') && @posix_isatty(STDOUT); 139 | } 140 | } 141 | 142 | return $support; 143 | } 144 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "arcanesanctum/articleoptimizer", 3 | "license": "proprietary", 4 | "type": "project", 5 | "autoload": { 6 | "psr-4": { 7 | "": "src/" 8 | }, 9 | "classmap": [ 10 | "app/AppKernel.php", 11 | "app/AppCache.php" 12 | ] 13 | }, 14 | "autoload-dev": { 15 | "psr-4": { 16 | "Tests\\": "tests/" 17 | } 18 | }, 19 | "require": { 20 | "php": ">=5.5.9", 21 | "symfony/symfony": "3.1.*", 22 | "doctrine/orm": "^2.5", 23 | "doctrine/doctrine-bundle": "^1.6", 24 | "doctrine/doctrine-cache-bundle": "^1.2", 25 | "symfony/swiftmailer-bundle": "^2.3", 26 | "symfony/monolog-bundle": "^2.8", 27 | "symfony/polyfill-apcu": "^1.0", 28 | "sensio/distribution-bundle": "^5.0", 29 | "sensio/framework-extra-bundle": "^3.0.2", 30 | "incenteev/composer-parameter-handler": "^2.0", 31 | "symfony/assetic-bundle": "2.8", 32 | "justinrainbow/json-schema": "~2.0" 33 | }, 34 | "require-dev": { 35 | "sensio/generator-bundle": "^3.0", 36 | "symfony/phpunit-bridge": "^3.0" 37 | }, 38 | "scripts": { 39 | "post-install-cmd": [ 40 | "Incenteev\\ParameterHandler\\ScriptHandler::buildParameters", 41 | "Sensio\\Bundle\\DistributionBundle\\Composer\\ScriptHandler::buildBootstrap", 42 | "Sensio\\Bundle\\DistributionBundle\\Composer\\ScriptHandler::clearCache", 43 | "Sensio\\Bundle\\DistributionBundle\\Composer\\ScriptHandler::installAssets", 44 | "Sensio\\Bundle\\DistributionBundle\\Composer\\ScriptHandler::installRequirementsFile", 45 | "Sensio\\Bundle\\DistributionBundle\\Composer\\ScriptHandler::prepareDeploymentTarget" 46 | ], 47 | "post-update-cmd": [ 48 | "Incenteev\\ParameterHandler\\ScriptHandler::buildParameters", 49 | "Sensio\\Bundle\\DistributionBundle\\Composer\\ScriptHandler::buildBootstrap", 50 | "Sensio\\Bundle\\DistributionBundle\\Composer\\ScriptHandler::clearCache", 51 | "Sensio\\Bundle\\DistributionBundle\\Composer\\ScriptHandler::installAssets", 52 | "Sensio\\Bundle\\DistributionBundle\\Composer\\ScriptHandler::installRequirementsFile", 53 | "Sensio\\Bundle\\DistributionBundle\\Composer\\ScriptHandler::prepareDeploymentTarget" 54 | ] 55 | }, 56 | "extra": { 57 | "symfony-app-dir": "app", 58 | "symfony-bin-dir": "bin", 59 | "symfony-var-dir": "var", 60 | "symfony-web-dir": "web", 61 | "symfony-tests-dir": "tests", 62 | "symfony-assets-install": "relative", 63 | "incenteev-parameters": { 64 | "file": "app/config/parameters.yml" 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /doc/img/symfony-optimizer-splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/doc/img/symfony-optimizer-splash.png -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | tests 18 | 19 | 20 | 21 | 22 | 23 | src 24 | 25 | src/*Bundle/Resources 26 | src/*/*Bundle/Resources 27 | src/*/Bundle/*Bundle/Resources 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /runTests.sh: -------------------------------------------------------------------------------- 1 | phpunit; -------------------------------------------------------------------------------- /src/.htaccess: -------------------------------------------------------------------------------- 1 | 2 | Require all denied 3 | 4 | 5 | Order deny,allow 6 | Deny from all 7 | 8 | -------------------------------------------------------------------------------- /src/AppBundle/AppBundle.php: -------------------------------------------------------------------------------- 1 | logger = $logger; 20 | 21 | $this->container = $container; 22 | } 23 | 24 | /** 25 | * Sanitize and return the article body 26 | * 27 | * @param String $articleText The text of the article 28 | * @return String $articleText The santitized text of the article 29 | */ 30 | public function sanitizeArticleBody($articleText) 31 | { 32 | //Convert all html entities to their applicable characters - e.g. 33 | $articleText = html_entity_decode($articleText); 34 | 35 | //Clean submitted article body of any HTML or PHP tags 36 | $articleText = strip_tags($articleText); 37 | 38 | //Drop all non-alphanumeric characters, but preserve single spaces 39 | $articleText = preg_replace('/[^A-Za-z0-9\-\s]/', '', $articleText); 40 | 41 | //Replace all hyphens with spaces 42 | $articleText = preg_replace('/\-/', ' ', $articleText); 43 | 44 | return $articleText; 45 | } 46 | 47 | /** 48 | * Parses the Alchemy API response and stores the data in the analysis array 49 | * 50 | * Modifes the analysis array in-place 51 | * 52 | * @param Array $alchemyResponse The response from Alchemy API after processing the article 53 | * @param Array &$analysis The current analysis 54 | */ 55 | public function parseAlchemyResponse($alchemyResponse, &$analysis) 56 | { 57 | //Decode Alchemy's response JSON into an associative array 58 | try { 59 | $alchemyResponse = json_decode($alchemyResponse, true); 60 | } catch (\Exception $e) { 61 | //TODO: handle this 62 | $this->logger->error('Error decoding Alchemy API response JSON: ' . $alchemyResponse); 63 | } 64 | 65 | $analysis['flickrKeywords'] = array(); 66 | 67 | //The properties of a typical Alchemy API response that we are interested in 68 | $props = array( 69 | 'keywords', 70 | 'concepts', 71 | 'entities', 72 | 'taxonomy', 73 | 'category' 74 | ); 75 | //Loop through and inspect each of these properties in the Alchemy API response 76 | //and store them in analysis if they are set 77 | if (isset($alchemyResponse['status']) && $alchemyResponse['status'] === 'OK') { 78 | 79 | foreach($props as $prop) { 80 | //Initialize success/failure key in the analysis array for each property 81 | $analysis[$prop .'Succeeded'] = false; 82 | 83 | if (isset($alchemyResponse[$prop]) && count($alchemyResponse[$prop]) > 0) { 84 | //var_dump($alchemyResponse[$prop]); 85 | //If the given property is set and has at least one entry, save it to analysis 86 | $analysis[$prop] = $alchemyResponse[$prop]; 87 | //Skim a sampling of all properties that aren't taxonomy to build 88 | //a diverse and accurate sampling of topics for searching via Flickr 89 | if ($prop != 'taxonomy' && isset($analysis[$prop][0]['text'])) { 90 | $analysis['flickrKeywords'][] = strtolower($analysis[$prop][0]['text']); 91 | } 92 | $analysis[$prop . 'Succeeded'] = true; 93 | } 94 | } 95 | 96 | //Parse the Alchemy taxonomy information to extract a generic article category 97 | //if possible. Otherwise, fall back to a generic category name 'General Interest' 98 | $this->parseAlchemyTaxonomy($analysis); 99 | 100 | } else { 101 | $this->logger->error('Received Alchemy API failure: ' . $alchemyResponse['status'] ?? ''); 102 | } 103 | } 104 | 105 | /** 106 | * Examines Alchemy API assigned taxonomy information 107 | * 108 | * Extracts the most descriptive root taxonomy and formats it so that 109 | * it is appropriate for displaying to the end user 110 | * 111 | * Falls back to generic "General Interest" should processsing the taxonomy fail 112 | * 113 | * Modifies the analysis array in-place 114 | * 115 | * @param Array &$analysis The associative array representing all analysis completed so far 116 | */ 117 | private function parseAlchemyTaxonomy(&$analysis) 118 | { 119 | if (isset($analysis['taxonomy']) && count($analysis['taxonomy']) > 0 && isset($analysis['taxonomy'][0]['label'])) { 120 | $categories = explode('/', $analysis['taxonomy'][0]['label']); 121 | $analysis['category'] = (isset($categories[1]) && gettype($categories[1] === 'string')) ? ucwords($categories[1]) : 'General Interest'; 122 | $analysis['categorySucceeded'] = true; 123 | } 124 | } 125 | 126 | /** 127 | * Maps the Alchemy-provided taxonomy to a 128 | * category badge directory. 129 | * 130 | * Category badges are 'consolation' badges 131 | * specific to the content categroy of a given 132 | * article which was not totally optimized 133 | * 134 | * Totally optimized articles receive a 135 | * completely different type of badge 136 | * 137 | * @param String $category The Alchemy-provided 138 | * @return String $directory The name of the directory from which a badge should be selected 139 | */ 140 | public function mapCategoryToBadge($category) 141 | { 142 | switch($category) { 143 | case 'Art And Entertainment': 144 | case 'Education': 145 | return 'arts-entertainment'; 146 | case 'Health And Fitness': 147 | return 'health'; 148 | case 'Hobbies And Interests': 149 | case 'Automotive': 150 | case 'Home And Garden': 151 | return 'recreation'; 152 | case 'Business': 153 | case 'Careers': 154 | return 'business'; 155 | case 'Family And Parenting': 156 | case 'Law, Govt And Politics': 157 | case 'Style And Fashion': 158 | return 'culture-politics'; 159 | case 'Health And Fitness': 160 | case 'Food And Drink': 161 | return 'health'; 162 | case 'General Interest': 163 | default: 164 | return 'unknown'; 165 | } 166 | } 167 | 168 | /** 169 | * Generates a unique path for a given report 170 | * 171 | * @return String - the relative path where this report will be saved 172 | */ 173 | public function generateReportUrl() 174 | { 175 | $today = date('M-d-o-g-i-s'); 176 | $random_token = md5(uniqid(rand(), true)); 177 | return 'saved-reports/'.$today.'-'.$random_token.'.html'; 178 | } 179 | 180 | /** 181 | * Subscribe a user to the configured mailchimp list associated with this app 182 | * 183 | * @param String $emailAddress The email address to subscribe 184 | */ 185 | public function subscribe($emailAddress) { 186 | 187 | //Mailchimp's base API url - N.B: you must include the correct datacenter for your account 188 | //e.g: us7 or us2 189 | $mailchimpAPIRoot = $this->container->getParameter('mailchimp_api_root'); 190 | $listId = $this->container->getParameter('mailchimp_list_id'); 191 | //Mailchimp expects user email addresses to be hashed with the md5 algorithm 192 | $hashedEmailAddress = md5($emailAddress); 193 | 194 | //Build URL for making the subscription request to Mailchimp 195 | $subscribeUserUrl = sprintf("%slists/%s/members", $mailchimpAPIRoot, $listId); 196 | 197 | $payload = array(); 198 | 199 | $payload['email_address'] = $emailAddress; 200 | //Tell mailchimp we want them to send a confirmation email to the user's address 201 | //This will ultimately increase list quality and reduce fraud and abuse 202 | $payload['status'] = 'pending'; 203 | 204 | $headers = array(); 205 | 206 | //Mailchimp expects you to supply your API key via HTTP Basic Auth 207 | //@See: http://developer.mailchimp.com/documentation/mailchimp/guides/manage-subscribers-with-the-mailchimp-api 208 | $options = array( 209 | 'CURLOPT_USERPWD' => 'articleoptimizer:' . $this->container->getParameter('mailchimp_api_key'), 210 | 'CURLOPT_HTTPAUTH' => CURLAUTH_BASIC 211 | ); 212 | 213 | return $this->container->get('curl')->post($subscribeUserUrl, json_encode($payload), $headers, $options); 214 | } 215 | } 216 | 217 | ?> -------------------------------------------------------------------------------- /src/AppBundle/Classes/Analyzer.php: -------------------------------------------------------------------------------- 1 | alchemyAPIKey = $alchemyApiKey; 36 | 37 | //Set up the logger 38 | $this->logger = $logger; 39 | 40 | //Curl helper service 41 | $this->curl = $curl; 42 | 43 | //Store a reference to the service container 44 | $this->container = $container; 45 | 46 | //Analysis Helper is a class that contains helper functions used by this Analyzer 47 | $this->analysisHelper = $this->container->get('analysis_helper'); 48 | 49 | //Set the names of the optimized and category badge directories 50 | $this->optimizedBadgeDirectory = $container->getParameter('optimal_badge_directory'); 51 | 52 | $this->categoryBadgeDirectory = $container->getParameter('category_badge_directory'); 53 | } 54 | 55 | /** 56 | * Perform analysis functions on article text 57 | * 58 | * As well as pass text to Alchemy API for linguistic analysis 59 | * 60 | * Builds up an associative array containing all analaysis information 61 | * 62 | * To be rendered on the report 63 | * 64 | * @param Article $article the body of the article that should be analyzed 65 | * @return Analysis array the associative array containing all analysis information 66 | */ 67 | public function analyze(Article $article) 68 | { 69 | //Main array that will contain all analysis data 70 | $analysis = array( 71 | 'success' => false, 72 | 'wordCount' => 0 73 | ); 74 | 75 | //Options to pass to Alchemy API: drop unnecessary metadata about decision sources to speed up processing 76 | $options = array( 77 | //'linkedData' => 1 78 | ); 79 | 80 | //Pull the article body off the article object 81 | $articleText = $article->getBody(); 82 | 83 | //Store the text in the analysis for later rendering on the report next to the badge 84 | $analysis['articleBody'] = $article->getBody(); 85 | 86 | if (isset($articleText) && is_string($articleText) && strlen($articleText) > 1) { 87 | $analysis['wordCount'] = str_word_count($articleText); 88 | $analysis['success'] = true; 89 | } else { 90 | $analysis['success'] = false; 91 | $this->logger->info(sprintf('Invalid article of length: %s after sanitization', strlen($articleText))); 92 | } 93 | 94 | //Sanitize the article text 95 | $articleText = $this->analysisHelper->sanitizeArticleBody($articleText); 96 | 97 | //Prepare and execute request to Alchemy API 98 | $alchemyResponse = $this->alchemizeText($articleText, $analysis); 99 | 100 | //Validates and gathers required data from an Alchemy response 101 | //Formats response data neatly so it can be sanely accessed by twig 102 | $this->analysisHelper->parseAlchemyResponse($alchemyResponse, $analysis); 103 | 104 | //Make determinations on how often the key phrases were repeated throughout 105 | $this->analyzePhraseDensity($analysis); 106 | 107 | //Select the badge image that will be used for this report 108 | $this->selectReportBadge($analysis); 109 | 110 | //Generate advertisements 111 | $this->fetchAdvertisements($analysis); 112 | 113 | return $analysis; 114 | } 115 | 116 | /** 117 | * Prepares and executes request to Alchemy API for further processing 118 | * 119 | * @param String $articleText The sanitized article body to send to Alchemy 120 | */ 121 | public function alchemizeText(String $articleText, &$analysis) 122 | { 123 | //Form the target URL to call, passing our Alchemy API key as a query string parameter 124 | $targetUrl = $this->container->getParameter('alchemy_api_base_url') . "/text/TextGetCombinedData?outputMode=json&apikey=" . $this->alchemyAPIKey; 125 | 126 | //Use the alchemy parameters if they are set, otherwise sensible defaults 127 | $data = sprintf("maxRetrieve=%d&sentiment=%d&text=%s", 128 | $this->container->getParameter('alchemy_max_entities_retrieve') ?? 10, 129 | $this->container->getParameter('alchemy_include_sentiment') ?? 1, 130 | urlencode($articleText) 131 | ); 132 | 133 | //In order to be able to POST a body to Alchemy API, this header must be set exactly as so: 134 | $headers = array( 135 | 'Content-Type: application/x-www-form-urlencoded' 136 | ); 137 | 138 | //Use curl service to POST request to Alchemy API 139 | $alchemyResponse = $this->curl->post($targetUrl, $data, $headers); 140 | 141 | //If we received a 200 from Alchemy, we have a successful analysis - otherwise we don't 142 | if (200 === $alchemyResponse->getStatusCode()) { 143 | return $alchemyResponse->getContent(); 144 | } else { 145 | $analysis['success'] = false; 146 | $this->logger->error(sprintf("Error retrieving results from Alchemy API: %s and status code: %d", $alchemyResponse->getContent(), $alchemyResponse->getStatusCode())); 147 | return; 148 | } 149 | } 150 | 151 | /** 152 | * Fetches advertisement blocks defined in parameters.yml 153 | * for rendering in the report 154 | * 155 | * @param Array &$analysis The current analysis 156 | */ 157 | private function fetchAdvertisements(&$analysis) 158 | { 159 | $analysis['adsEnabled'] = $this->container->getParameter('ads_enabled'); 160 | if ($analysis['adsEnabled'] === true) { 161 | $analysis['ads'] = $this->container->getParameter('advertisements'); 162 | } 163 | } 164 | 165 | /** 166 | * Makes determinations on the frequency with which key phrases are repeated 167 | * which are used to make recommendations on the report page 168 | * 169 | * Modifies the analysis array in place 170 | * 171 | * @param Array &$analysis The current analysis 172 | */ 173 | private function analyzePhraseDensity(&$analysis) 174 | { 175 | if (isset($analysis['entitiesSucceeded']) && $analysis['entitiesSucceeded'] === true) { 176 | //Initialize density array 177 | $density = array( 178 | 'stuffing' => array(), 179 | 'good' => array(), 180 | 'low' => array() 181 | ); 182 | foreach($analysis['entities'] as $entity) { 183 | $count = (int)$entity['count']; 184 | $entity = $entity['text']; 185 | $p = round((($count / $analysis['wordCount']) * 100), 2); 186 | switch(true) { 187 | case ($p >= 0.20 && $p <= 1.50): 188 | $density['good'][$entity] = $p; 189 | break; 190 | case ($p < 0.20): 191 | $density['low'][$entity] = $p; 192 | break; 193 | case ($p > 1.50): 194 | $density['stuffing'][$entity] = $p; 195 | break; 196 | default: 197 | $density['good'][$entity] = $p; 198 | break; 199 | } 200 | } 201 | $analysis['phraseDensitySucceeded'] = false; 202 | foreach($density as $level => $children) { 203 | if (is_array($level) && count($level) > 0) { 204 | $analysis['phraseDensitySucceeded'] = true; 205 | } 206 | } 207 | if ($analysis['phraseDensitySucceeded'] = true) { 208 | $analysis['phraseDensity'] = $density; 209 | } 210 | } 211 | } 212 | 213 | /** 214 | * Chooses a report badge based on the state of the analysis 215 | * 216 | * If the article is long enough and has at least one keyword 217 | * in the "good" range as determined by the analyzePhraseDensity 218 | * method, it receives an optimized badge 219 | * 220 | * Otherwise it receives a badge based on its category 221 | * 222 | * @param Array &$analysis The current analysis 223 | */ 224 | private function selectReportBadge(&$analysis) 225 | { 226 | //If the article is greater than 151 words, that's one positive check toward its quality 227 | $wordCountCheck = ($analysis['wordCount'] >= 151) ? true : false; 228 | $keywordsCheck = (isset($analysis['phraseDensity']['good']) && count($analysis['phraseDensity']['good']) >= 1) ? true : false; 229 | 230 | $analysis['wordCountCheck'] = $wordCountCheck; 231 | $analysis['keywordsCheck'] = $keywordsCheck; 232 | 233 | if (true === $wordCountCheck && true === $keywordsCheck) { 234 | 235 | //We have a high quality article - render an optimized badge 236 | $analysis['badge'] = $this->getBadgeUrl('optimized', null); 237 | } else { 238 | //Use a category badge instead 239 | $badgeCategory = isset($analysis['category']) ? $this->analysisHelper->mapCategoryToBadge($analysis['category']) : 'unknown'; 240 | $analysis['badge'] = $this->getBadgeUrl('category', $badgeCategory); 241 | } 242 | } 243 | 244 | /** 245 | * Builds the system path to the /web directory of this Symfony project 246 | * 247 | * @return string path - the system path to the /web directory 248 | */ 249 | private function getWebDirectory() 250 | { 251 | return dirname($this->container->get('kernel')->getRootDir()) . '/web'; 252 | } 253 | 254 | /** 255 | * Obtain an optimized or a category badge URL, depending on the mode passed to this method 256 | * 257 | * @var string mode - either 'optimized' or 'category' for specifying the type of badge that should be found 258 | * 259 | * @return string url - the relative URL to the badge that was selected for this report. Used by report.html.twig to display the badge 260 | */ 261 | //private function getBadgeUrl($mode, string $category = null) 262 | private function getBadgeUrl($mode, $category = null) 263 | { 264 | $badges = array(); 265 | 266 | //If an optimized badge is being requested, choose one at random from its directory 267 | //Category badges requring dropping down into the specific category directory before selecting a badge 268 | $pathRoot = '/img/badges/' . ($mode === 'optimized' ? $this->optimizedBadgeDirectory : $this->categoryBadgeDirectory . '/' . $category) . '/'; 269 | 270 | //Instantiate Symfony file finder object 271 | $finder = new Finder(); 272 | //Only pick up jpegs, (or .jpg), .png, .gif or .gifv files 273 | $finder->files()->name('/(:?.*\.jpe?g$|.*\.png$|.*\.gifv?$)/')->in($this->getWebDirectory() . $pathRoot); 274 | 275 | foreach ($finder as $file) { 276 | //Build the full path to the badge image and store it in the array of possible badges 277 | array_push($badges, ($pathRoot . $file->getRelativePathname())); 278 | } 279 | 280 | //Choose one of the optimized badges at random 281 | return $badges[array_rand($badges)]; 282 | } 283 | } 284 | 285 | ?> -------------------------------------------------------------------------------- /src/AppBundle/Classes/Bitly.php: -------------------------------------------------------------------------------- 1 | hostname = $hostname; 37 | 38 | $this->token = $token; 39 | 40 | $this->curl = $curl; 41 | 42 | $this->logger = $logger; 43 | } 44 | 45 | /** 46 | * Accepts a relative URL, builds it into a fully qualified URL, then shortens that link via Bitly 47 | * 48 | * @param string $url - The relative URL to the report that should be fully qualified and shortened 49 | * @return string $shortUrl - The fully qualified and shortened URL 50 | */ 51 | public function shortenUrl(Request $request) 52 | { 53 | //Build the query parameters 54 | $queryParams = array( 55 | 'access_token' => $this->token, 56 | 'longUrl' => $request->get('url'), 57 | 'format' => 'json' 58 | ); 59 | 60 | //Build the URL to make the Bitly API request 61 | $bitlyTarget = 'https://api-ssl.bitly.com/v3/shorten?' . http_build_query($queryParams); 62 | 63 | //Remove default urlencoding that http_build_query creates, as Bitly will choke on it 64 | $bitlyTarget = urldecode($bitlyTarget); 65 | 66 | $bitlyResponse = $this->curl->get($bitlyTarget); 67 | 68 | return $bitlyResponse; 69 | } 70 | } -------------------------------------------------------------------------------- /src/AppBundle/Classes/CurlHelper.php: -------------------------------------------------------------------------------- 1 | timeout = $timeout; 24 | 25 | $this->logger = $logger; 26 | } 27 | 28 | public function get($url, $headers = array(), $options = array()) 29 | { 30 | if (!isset($url)) { 31 | $this->logger->error('Bad url passed to curl get: ' . $url); 32 | throw new \Exception('Curl get requires a valid URL'); 33 | } 34 | 35 | $ch = curl_init(); 36 | $this->applyDefaultCurlSettings($ch, $url, null, $headers, $options); 37 | 38 | return $this->executeCurlRequest($ch); 39 | } 40 | 41 | public function post($url, $data, $headers = array(), $options = array()) 42 | { 43 | if (!isset($url)) { 44 | $this->logger->error('Bad url passed to curl post: ' . $url); 45 | throw new \Exception('Curl post requires a valid URL'); 46 | } 47 | 48 | $ch = curl_init(); 49 | $this->applyDefaultCurlSettings($ch, $url, $data, $headers, $options); 50 | 51 | curl_setopt($ch, CURLOPT_POST, true); 52 | curl_setopt($ch, CURLOPT_POSTFIELDS, $data ?? array()); 53 | 54 | return $this->executeCurlRequest($ch); 55 | } 56 | 57 | private function applyDefaultCurlSettings(&$ch, $url, $data = null, $headers = array(), $options = null) 58 | { 59 | curl_setopt($ch, CURLOPT_HEADER, $options['CURLOPT_HEADER'] ?? false); 60 | curl_setopt($ch, CURLOPT_HTTPHEADER, $headers ?? array()); 61 | curl_setopt($ch, CURLOPT_RETURNTRANSFER, $options['CURLOPT_RETURNTRANSFER'] ?? true); 62 | curl_setopt($ch, CURLOPT_USERAGENT, $options['CURLOPT_USERAGENT'] ?? 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/31.0.1650.63 Safari/537.36'); 63 | curl_setopt($ch, CURLOPT_URL, $url ?? $options['CURLOPT_URL']); 64 | curl_setopt($ch, CURLOPT_FOLLOWLOCATION, $options['CURLOPT_FOLLOWLOCATION'] ?? true); 65 | if (isset($options['CURLOPT_USERPWD'])) { 66 | curl_setopt($ch, CURLOPT_USERPWD, $options['CURLOPT_USERPWD']); 67 | } 68 | if (isset($options['CURLOPT_HTTPAUTH'])) { 69 | curl_setopt($ch, CURLOPT_HTTPAUTH, $options['CURLOPT_HTTPAUTH']); 70 | } 71 | } 72 | 73 | private function executeCurlRequest(&$ch) 74 | { 75 | $curlReqResult = curl_exec($ch); 76 | $curlReqHttpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); 77 | curl_close($ch); 78 | 79 | $response = new Response(); 80 | $response->setStatusCode($curlReqHttpCode); 81 | $response->setContent($curlReqResult); 82 | 83 | return $response; 84 | } 85 | } 86 | 87 | ?> -------------------------------------------------------------------------------- /src/AppBundle/Classes/Flickr.php: -------------------------------------------------------------------------------- 1 | container = $container; 32 | 33 | $this->apiKey = $apiKey; 34 | 35 | $this->curl = $curl; 36 | 37 | $this->logger = $logger; 38 | 39 | $this->baseFlickrUrl = 'http://flickr.com/services/rest/?'; 40 | 41 | } 42 | 43 | /** 44 | * Obtains copyright-free images from the Flickr API 45 | * 46 | * @param Request $request Symfony request 47 | * @return Response $response Symfony response 48 | */ 49 | public function searchAction(Request $request) 50 | { 51 | //Pull the query off the search 52 | $postString = $request->getContent(); 53 | 54 | //Pull the keywords leading string off 55 | $postString = str_replace('keywords=', '', $postString); 56 | 57 | $postString = urldecode($postString); 58 | 59 | //Form an array of search terms to loop through 60 | $searchTerms = explode(',', $postString); 61 | 62 | //Initialize array that will hold photos returned by Flickr 63 | $photos = array(); 64 | 65 | foreach($searchTerms as $query) { 66 | //Build arguments array 67 | $arguments = array( 68 | 'method' => 'flickr.photos.search', 69 | 'api_key' => $this->apiKey, 70 | 'tags' => urlencode($query), 71 | 'text' => urlencode($query), 72 | 'per_page' => 50, 73 | 'content_type' => 1, //Photos only - not screenshots which tend to be lower quality 74 | 'format' => 'php_serial' 75 | ); 76 | 77 | //Build full Flickr API request URL 78 | $searchUrl = $this->baseFlickrUrl.http_build_query($arguments); 79 | 80 | //Make Flickr API request via curl helper 81 | $flickrResponse = $this->curl->get($searchUrl); 82 | 83 | if ($flickrResponse->getStatusCode() === 200) { 84 | $data = unserialize($flickrResponse->getContent()); 85 | if (isset($data['photos']['photo'])) { 86 | array_push($photos, $data['photos']['photo']); 87 | } 88 | } 89 | } 90 | //Return rendered html containing the images 91 | //flickr-images.html.twig creates correct URLs from 92 | //data returned by the above Flickr API calls 93 | return $this->render('snippets/flickr-images.html.twig', array( 94 | 'photos' => isset($data['photos']['photo']) ? $data['photos']['photo'] : null 95 | )); 96 | } 97 | } 98 | 99 | 100 | ?> -------------------------------------------------------------------------------- /src/AppBundle/Controller/DefaultController.php: -------------------------------------------------------------------------------- 1 | getUserCookieStatus($request); 38 | //Build and return response, including all the app-level configuration params 39 | //That will be used to render owner-specific data in meta tags, title, etc. 40 | $response = $this->render('default/index.html.twig', array( 41 | 'returningUser' => $userStatus, 42 | 'appTitle' => $this->getParameter('app_title'), 43 | 'appAuthor' => $this->getParameter('app_author') 44 | )); 45 | 46 | return $response; 47 | } 48 | 49 | /** 50 | * Render the form that accepts articles 51 | * 52 | * @param Request $request - the Symfony request 53 | * @return Response $response - the Symfony response 54 | */ 55 | public function articleFormAction(Request $request) 56 | { 57 | //Create a new article entity and its accompanying form 58 | $article = new Article(); 59 | //Set the createdDate to the time of the request 60 | $article->setCreatedDate(new \DateTime()); 61 | 62 | $articleForm = $this->createFormBuilder($article) 63 | ->setAction($this->generateUrl('article_process')) 64 | ->add('body', TextareaType::class, array( 65 | 'label' => ' ', 66 | 'attr' => array('class' => 'form-control article-form', 67 | 'placeholder' => 'Step 1: Paste Your Article Here' 68 | ) 69 | ) 70 | ) 71 | ->add('submit', SubmitType::class, array( 72 | 'label' => 'Step 2: Click Here to Optimize!', 73 | 'attr' => array( 74 | 'class' => 'submit-button article-submit btn btn-success' 75 | ) 76 | ) 77 | ) 78 | ->getForm(); 79 | 80 | $articleForm->handleRequest($request); 81 | 82 | if ($articleForm->isSubmitted() && $articleForm->isValid()) { 83 | 84 | //Load Analyzer service 85 | $analyzer = $this->get('analyzer'); 86 | 87 | //Obtain analysis of the article 88 | $analysis = $analyzer->analyze($article); 89 | 90 | //Analysis helper builds the final destination path for the report 91 | $reportDestination = $this->container->get('analysis_helper')->generateReportUrl(); 92 | 93 | //First, render the report and write to the server for later use 94 | $renderedReport = $this->renderView('default/report.html.twig', array( 95 | 'analysis' => $analysis, 96 | 'appTitle' => $this->getParameter('app_title'), 97 | 'appAuthor' => $this->getParameter('app_author') 98 | ) 99 | ); 100 | 101 | //Instantiate new Symfony filesystem component 102 | $fs = new Filesystem(); 103 | 104 | try { 105 | //Write the rendered repport to the file for later access 106 | $fs->dumpFile($reportDestination, $renderedReport); 107 | 108 | } catch (\Exception $e) { 109 | $this->container->get('logger')->error('Error persisting generated report to filesystem: ' . $e); 110 | } 111 | 112 | //Create a new redirect response to the newly written report 113 | $response = new RedirectResponse($reportDestination, 301); 114 | //Set the returning user cookie in the response so that user 115 | //will not be required to pass sign-up flow again 116 | $this->setUserCookie($response); 117 | 118 | //Redirect request to the written report 119 | return $response; 120 | 121 | } 122 | 123 | //Render the article form 124 | return $this->render('forms/article.html.twig', array( 125 | 'articleForm' => $articleForm->createView() 126 | ) 127 | ); 128 | } 129 | 130 | /** 131 | * Render the contact request form and process its submission 132 | * 133 | * Sends a contact request email using the configured mailer on 134 | * 135 | * Submission of a valid contact request form 136 | * 137 | * @param Request $request The request 138 | * @return Response $response The response 139 | */ 140 | public function contactFormAction(Request $request) 141 | { 142 | //Build the contact us form 143 | $contact = new ContactRequest(); 144 | 145 | $contactForm = $this->createFormBuilder($contact) 146 | ->setAction($this->generateUrl('contact_process')) 147 | ->add('body', TextareaType::class, array( 148 | 'label' => ' ', 149 | 'attr' => array('class' => 'form-control contact-form', 150 | 'placeholder' => 'What\'s on your mind?')) 151 | ) 152 | ->add('requestor', TextType::class, array( 153 | 'label' => ' ', 154 | 'attr' => array('class' => 'form-control contact-email', 155 | 'placeholder' => 'Optionally, add your email address here if you want a response')) 156 | ) 157 | ->add('submitContact', SubmitType::class, array( 158 | 'label' => 'Submit Feedback', 159 | 'attr' => array( 160 | 'class' => 'submit-button btn btn-success'))) 161 | ->getForm(); 162 | 163 | $contactForm->handleRequest($request); 164 | 165 | if ($contactForm->isSubmitted() && $contactForm->isValid()) { 166 | 167 | //Set the createdDate to the time of the valid submission 168 | $contact->setCreatedDate(new \DateTime()); 169 | 170 | //Get the Requestor off the contact record - if it exists 171 | $requestorField = $contact->getRequestor(); 172 | $requestor = ($requestorField ?? "anonymous@article-optimize.com"); 173 | 174 | $message = $contact->getBody(); 175 | 176 | //Prepare the contact request email 177 | $contactEmail = \Swift_Message::newInstance() 178 | ->setSubject($this->getParameter('contact_email_subject')) 179 | ->setFrom($requestor) 180 | ->setTo($this->getParameter('contact_email_delivery_address')) 181 | ->setBody( 182 | $this->renderView( 183 | 'emails/contact.html.twig', 184 | array('requestor' => $requestor, 185 | 'message' => $message 186 | ), 187 | 'text/html' 188 | ) 189 | ) 190 | ; 191 | //Send the contact request email 192 | $this->get('mailer')->send($contactEmail); 193 | 194 | //Render the contact success page with customized message 195 | return $this->render('default/contact-success.html.twig', array( 196 | 'contact_success_response' => $this->getParameter('contact_success_response') 197 | ) 198 | ); 199 | } 200 | 201 | //Don't return full form view for ajax requests 202 | if (!$request->isXmlHttpRequest()) { 203 | return $this->render('forms/contact.html.twig', array( 204 | 'contactForm' => $contactForm->createView(), 205 | )); 206 | } else { 207 | 208 | $response = new Response(); 209 | $response->setStatusCode(400); 210 | $response->setContent($this->getFormErrorsAsString($contactForm)); 211 | return $response; 212 | } 213 | } 214 | 215 | /** 216 | * Sends a report via email to a recipient specified by a user 217 | * 218 | * @param Request Symfony request 219 | * @return Response Symfony response 220 | */ 221 | public function emailReportAction(Request $request) 222 | { 223 | $recipient = new ReportEmailRecipient(); 224 | 225 | $recipient->setReportUrl($request->headers->get('referer')); 226 | 227 | $recipientForm = $this->createFormBuilder($recipient, array(), array()) 228 | ->setAction($this->generateUrl('email_report_process')) 229 | ->add('address', TextType::class, array( 230 | 'label' => ' ', 231 | 'attr' => array('class' => 'form-control report-email', 232 | 'placeholder' => 'Enter a valid email address to send this report to')) 233 | ) 234 | ->add('sendReport', SubmitType::class, array( 235 | 'label' => 'Send Report', 236 | 'attr' => array( 237 | 'class' => 'submit-button report-send-button btn btn-success')) 238 | ) 239 | ->getForm(); 240 | 241 | $recipientForm->handleRequest($request); 242 | 243 | if ($recipientForm->isSubmitted() && $recipientForm->isValid()) { 244 | 245 | //Prepare the report-sending email 246 | $recipientEmail = \Swift_Message::newInstance() 247 | ->setSubject($this->getParameter('report_email_subject')) 248 | ->setFrom('report@article-optimize.com') 249 | ->setTo($recipient->getAddress()) 250 | ->setBody( 251 | $this->renderView( 252 | 'emails/report.html.twig', 253 | array('reportUrl' => $request->get('uri')) 254 | ), 255 | 'text/html' 256 | ) 257 | ; 258 | //Send the contact request email 259 | $this->get('mailer')->send($recipientEmail); 260 | 261 | //Render the contact success page with customized message 262 | return $this->render('forms/email-report-success.html.twig', array( 263 | 'successMessage' => $this->getParameter('report_email_success_message') 264 | ) 265 | ); 266 | } 267 | 268 | return $this->render('forms/email-report.html.twig', array( 269 | 'emailReportForm' => $recipientForm->createView(), 270 | ) 271 | ); 272 | } 273 | 274 | /** 275 | * Handles user subscriptions to email list via signup modal on index page 276 | * 277 | * @param Request $request Symfony request 278 | */ 279 | public function subscribeAction(Request $request) 280 | { 281 | $subscriber = new Subscriber(); 282 | 283 | $subscriberForm = $this->createFormBuilder($subscriber) 284 | ->setAction($this->generateUrl('subscribe_process')) 285 | ->add('address', TextType::class, array( 286 | 'label' => ' ', 287 | 'attr' => array('class' => 'form-control report-email', 288 | 'placeholder' => 'Enter a valid email address to subscribe')) 289 | ) 290 | ->getForm(); 291 | 292 | $subscriberForm->handleRequest($request); 293 | 294 | if ($subscriberForm->isSubmitted() && $subscriberForm->isValid()) { 295 | 296 | $mailchimpResponse = $this->get('analysis_helper')->subscribe($subscriber->getAddress()); 297 | 298 | $mailchimpStatusCode = $mailchimpResponse->getStatusCode(); 299 | 300 | if (200 === $mailchimpStatusCode) { 301 | $message = 'Success! You\'ve been subscribed. Please check your email to confirm.'; 302 | } else { 303 | $message = 'Sorry, there was an issue signing you up. Please try again later.'; 304 | $this->container->get('logger')->error(sprintf("Error subscribing user to Mailchimp list: %s in %s %s %s", $mailchimpResponse->getContent(), __CLASS__, __FUNCTION__, __LINE__)); 305 | } 306 | 307 | $response = new Response(); 308 | $response->setStatusCode($mailchimpStatusCode); 309 | $response->setContent($message); 310 | return $response; 311 | } 312 | 313 | 314 | //Don't return full form view for ajax requests 315 | if (!$request->isXmlHttpRequest()) { 316 | return $this->render('forms/subscriber.html.twig', array( 317 | 'subscriberForm' => $subscriberForm->createView(), 318 | )); 319 | } else { 320 | 321 | $response = new Response(); 322 | $response->setStatusCode(400); 323 | $response->setContent($this->getFormErrorsAsString($subscriberForm)); 324 | return $response; 325 | } 326 | } 327 | 328 | /** 329 | * Returns all form errors for supplied form in a user-friendly string 330 | * 331 | * @param Object $form A Symfony form instance 332 | * @return String $errors A user-legible string of concatenated form errors 333 | */ 334 | private function getFormErrorsAsString(&$form) 335 | { 336 | //Parse the form errors and return them as a usable string 337 | $errString = ""; 338 | foreach($form as $fieldName => $formField) { 339 | $fieldErrors = $formField->getErrors(); 340 | $userFriendlyError = stripslashes(trim($fieldErrors)); 341 | $userFriendlyError = str_replace('ERROR:', '', $userFriendlyError); 342 | $userFriendlyError = str_replace(',', '', $userFriendlyError); 343 | $errString .= $userFriendlyError; 344 | } 345 | return $errString; 346 | } 347 | 348 | /** 349 | * Determine if the user is a repeat visitor or not 350 | * 351 | * Returning users will not be challenged with the email submission form 352 | * 353 | * New users will be given the email submission form before they can optimize an article 354 | * 355 | * @param Object - Symfony request 356 | * @return Boolean - true if the user is returning, and false if they are new 357 | */ 358 | private function getUserCookieStatus(Request $request) 359 | { 360 | $cookie = $request->cookies->get($this->getParameter('cookie_name')); 361 | 362 | return (isset($cookie) && $cookie === $this->getParameter('cookie_value')) ? true : false; 363 | } 364 | 365 | /** 366 | * Sets the application's cookie and value as specified in parameters.yml 367 | * 368 | * The presence of this cookie, set to the correct value, will mark the user as recognized and returning 369 | * in the future, so that client code allows them to bypass the sign-up flow 370 | * 371 | * The response will be passed by reference, so it will be modified in-place 372 | * 373 | * @param Object - the Symfony response 374 | */ 375 | private function setUserCookie(Response &$response) 376 | { 377 | //Set a cookie of specified name with the specified value. Requests for the index page are checked for this cookie 378 | $response->headers->setCookie(new Cookie($this->getParameter('cookie_name'), $this->getParameter('cookie_value'))); 379 | } 380 | 381 | } 382 | -------------------------------------------------------------------------------- /src/AppBundle/DependencyInjection/ConfigValidator.php: -------------------------------------------------------------------------------- 1 | root('advertisements'); 14 | 15 | $rootNode 16 | ->arrayNode() 17 | ->children() 18 | ->scalarNode('cta')->isRequired()->end() 19 | ->scalarNode('image_url')->isRequired()->end() 20 | ->scalarNode('url')->isRequired()->end() 21 | ->end() 22 | ->end() 23 | ->end() 24 | ; 25 | return $treeBuilder; 26 | } 27 | } 28 | 29 | ?> -------------------------------------------------------------------------------- /src/AppBundle/Entity/Article.php: -------------------------------------------------------------------------------- 1 | addPropertyConstraint('body', new NotBlank( 29 | array('message' => 'You must submit the full text of your article.') 30 | ) 31 | ); 32 | 33 | $metadata->addPropertyConstraint('body', new Length(array( 34 | 'min' => 150, 35 | 'minMessage' => 'Your article must be at least 150 characters long.', 36 | )) 37 | ); 38 | } 39 | 40 | /** 41 | * Return the body content of the article 42 | * 43 | * @return String The article content 44 | */ 45 | public function getBody() 46 | { 47 | return $this->body; 48 | } 49 | 50 | /** 51 | * Set the body content of the article 52 | * 53 | * @param String $body The article content 54 | */ 55 | public function setBody($body) 56 | { 57 | $this->body = $body; 58 | } 59 | 60 | /** 61 | * Return the Datetime the article was created 62 | * 63 | * @return Datetime The article's creation time 64 | */ 65 | public function getCreatedDate() 66 | { 67 | return $this->createdDate; 68 | } 69 | 70 | /** 71 | * Sets the Datetime the article was created 72 | * 73 | * @param Datetime $createdDate The article's creation time 74 | */ 75 | public function setCreatedDate($createdDate) 76 | { 77 | $this->createdDate = $createdDate; 78 | } 79 | 80 | } 81 | 82 | ?> -------------------------------------------------------------------------------- /src/AppBundle/Entity/ContactRequest.php: -------------------------------------------------------------------------------- 1 | addPropertyConstraint('body', 25 | new NotBlank(array( 26 | 'message' => 'You must have something more to say!' 27 | ) 28 | ) 29 | ); 30 | 31 | $metadata->addPropertyConstraint('body', 32 | new Length(array( 33 | 'min' => 10, 34 | 'max' => 5000, 35 | 'minMessage' => 'Your message must be at least 10 characters long', 36 | 'maxMessage' => 'Your message cannot be longer than {{ limit }} characters' 37 | ) 38 | ) 39 | ); 40 | 41 | $metadata->addPropertyConstraint('requestor', new Email(array( 42 | 'strict' => false, 43 | 'message' => 'Please enter a valid email address', 44 | 'checkMX' => true, 45 | 'checkHost' => true 46 | ) 47 | )); 48 | } 49 | 50 | public function getBody() 51 | { 52 | return $this->body; 53 | } 54 | 55 | public function setBody($body) 56 | { 57 | $this->body = $body; 58 | } 59 | 60 | public function getCreatedDate() 61 | { 62 | return $this->createdDate; 63 | } 64 | 65 | public function setCreatedDate($createdDate) 66 | { 67 | $this->createdDate = $createdDate; 68 | } 69 | 70 | public function getRequestor() 71 | { 72 | return $this->requestor; 73 | } 74 | 75 | public function setRequestor($requestor) 76 | { 77 | $this->requestor = $requestor; 78 | } 79 | } 80 | 81 | ?> -------------------------------------------------------------------------------- /src/AppBundle/Entity/ReportEmailRecipient.php: -------------------------------------------------------------------------------- 1 | addPropertyConstraint('address', new NotBlank(array( 25 | 'message' => 'You must provide a valid email address') 26 | )); 27 | 28 | $metadata->addPropertyConstraint('address', new Email(array( 29 | 'strict' => false, 30 | 'message' => 'Please provide a valid email address', 31 | 'checkMX' => true, 32 | 'checkHost' => true 33 | ) 34 | )); 35 | } 36 | 37 | /** 38 | * Return the recipient email address 39 | * 40 | * @return String The recipient email address 41 | */ 42 | public function getAddress() 43 | { 44 | return $this->address; 45 | } 46 | 47 | /** 48 | * Set the recipient email address 49 | * 50 | * @param String $address The recipient email address 51 | */ 52 | public function setAddress($address) 53 | { 54 | $this->address = $address; 55 | } 56 | 57 | /** 58 | * Return the final url of the article report 59 | * 60 | * @return String The final url of the article report 61 | */ 62 | public function getReportUrl() 63 | { 64 | return $this->reportUrl; 65 | } 66 | 67 | /** 68 | * Set the final url of the article report 69 | * 70 | * @param String The final url of the article report 71 | */ 72 | public function setReportUrl($url) 73 | { 74 | $this->reportUrl = $url; 75 | } 76 | } 77 | 78 | ?> -------------------------------------------------------------------------------- /src/AppBundle/Entity/Subscriber.php: -------------------------------------------------------------------------------- 1 | addPropertyConstraint('address', 22 | new NotBlank(array( 23 | 'message' => 'Please enter your email address.' 24 | ) 25 | ) 26 | ); 27 | 28 | $metadata->addPropertyConstraint('address', new Email(array( 29 | 'strict' => false, 30 | 'message' => 'Please enter a valid email address', 31 | 'checkMX' => true, 32 | 'checkHost' => true 33 | ) 34 | )); 35 | } 36 | 37 | public function getAddress() 38 | { 39 | return $this->address; 40 | } 41 | 42 | public function setAddress($address) 43 | { 44 | $this->address = $address; 45 | } 46 | } 47 | 48 | ?> -------------------------------------------------------------------------------- /src/AppBundle/Twig/AppExtension.php: -------------------------------------------------------------------------------- 1 | array('html'))), 11 | new \Twig_SimpleFunction('legend', array($this ,'generateLegend'), array('is_safe' => array('html'))), 12 | new \Twig_SimpleFunction('advertisement', array($this, 'generateAdBlock'), array('is_safe' => array('html'))) 13 | ); 14 | } 15 | 16 | /** 17 | * Generates an advertisement block of two ads split into two equal columns 18 | * 19 | * Ads have the following format: 20 | * 21 | * array(' 22 | * 'cta' => 'Buy this wonderful product please', 23 | * 'img_path' => 'ads/elegantthemes/300x250.gif', 24 | * 'url' => 'https://www.elegantthemes.com/myaffiliatecode' 25 | * '); 26 | * 27 | * cta is the "Call to Action" - the text that will appear with the ad 28 | * 29 | * img_path is the relative path from web/img/ to the image asset / banner 30 | * 31 | * url is the final url that people who click the ad should be sent to 32 | * this should contain any affiliate tracking information necessary to credit 33 | * your account with conversions that it generates 34 | * 35 | * @param Array $ad1 The associative array representing advertisement 1 36 | * @param Array $ad2 The associative array representing advertisement 2 37 | */ 38 | public function generateAdBlock($ad1, $ad2) 39 | { 40 | 41 | return sprintf("
42 |
43 |
44 |

%s

45 |
46 |
47 |

%s

48 |
49 |
50 |
51 |
52 | 53 | 54 | 55 |
56 |
57 | 58 | 59 | 60 |
61 |
62 |
", $ad1['cta'], $ad2['cta'], $ad1['url'], $ad1['img_path'], $ad2['url'], $ad2['img_path']); 63 | } 64 | 65 | /** 66 | * Generate a section legend mapping to the bootstrap default classes: 67 | * 68 | * e.g: 69 | * $keys = array('positive', 'neutral', 'negative') 70 | * 71 | * will produce the following legend: 72 | * 73 | * positive = success, neutral = info, negative = danger 74 | * 75 | * @param Array $keys The keys that map to each 76 | * @return String The html of the legend 77 | */ 78 | public function generateLegend($title, $keys) 79 | { 80 | if (!isset($title) || !gettype($title) === "string") { 81 | throw new \Exception('generateLegend expects a valid string for a title'); 82 | } 83 | if (count($keys) < 3) { 84 | throw new \Exception('generateLegend expects an array containing 3 strings to map to bootstrap classes.'); 85 | } 86 | return sprintf(" 87 | 88 |
89 | %s 90 | %s 91 | %s 92 |
93 | ",$title, $keys[0], $keys[1], $keys[2]); 94 | } 95 | 96 | /** 97 | * Generates a properly-formatted 'bootstrap ribbon' as used on the report page 98 | * 99 | * @param String $title The title that will appear on top of the ribbon 100 | * @return String $html The final html of the ribbon to be rendered on the report 101 | */ 102 | public function generateRibbon($title, $type = 'body') 103 | { 104 | //Most ribbons are 'body' types, which ensures they are padded correctly. 105 | //If the second parameter passed to this function is anything but body, it's probably the first ribbon 106 | //which means that we should remove the 'body' class from the ribbon 107 | if ($type != 'body') { 108 | $type = null; 109 | } 110 | return sprintf("
111 | 112 |
", $type, $title); 113 | } 114 | 115 | public function getName() 116 | { 117 | return 'app_extension'; 118 | } 119 | } 120 | 121 | ?> -------------------------------------------------------------------------------- /tests/AppBundle/Controller/DefaultControllerTest.php: -------------------------------------------------------------------------------- 1 | request('GET', '/'); 14 | 15 | $this->assertEquals(200, $client->getResponse()->getStatusCode()); 16 | 17 | } 18 | 19 | public function testIndexFormsPresent() 20 | { 21 | $client = static::createClient(); 22 | 23 | $crawler = $client->request('GET', '/'); 24 | 25 | //Ensure the article, subscribe and contact forms are present 26 | $this->assertEquals(3, $crawler->filter('form')->count()); 27 | } 28 | 29 | public function testIndexTabsPresent() 30 | { 31 | $client = static::createClient(); 32 | 33 | $crawler = $client->request('GET', '/'); 34 | 35 | //Ensure the optimize, how it works and contact panel nav tabs are present 36 | $this->assertEquals(3, $crawler->filter('.nav-tabs li a')->count()); 37 | } 38 | 39 | //Tests that the article form provides an error when 40 | //supplied content is too short 41 | public function testArticleFormSubmitShortContent() 42 | { 43 | $client = static::createClient(); 44 | 45 | $crawler = $client->request('GET', '/'); 46 | 47 | $articleForm = $this->getArticleForm($crawler); 48 | 49 | $articleForm['form[body]'] = 'This is deliberately too short'; 50 | 51 | $crawler = $client->submit($articleForm); 52 | 53 | $this->assertContains('Your article must be at least 150 characters long.', $client->getResponse()->getContent()); 54 | } 55 | 56 | //Test that submitting a simple article works and 57 | //results in a redirect response to the written report 58 | public function testArticleFormSubmitArticle() 59 | { 60 | $client = static::createClient(); 61 | 62 | $crawler = $client->request('GET', '/'); 63 | 64 | $articleForm = $this->getArticleForm($crawler); 65 | 66 | $articleForm['form[body]'] = 'What is Symfony? Symfony is a set of PHP Components, a Web Application framework, a Philosophy, and a Community — all working together in harmony. The leading PHP framework to create websites and web applications. Built on top of the Symfony Components. A set of decoupled and reusable components on which the best PHP applications are built, such as Drupal, phpBB, and eZ Publish. A huge community of Symfony fans committed to take PHP to the next level.'; 67 | 68 | $crawler = $client->submit($articleForm); 69 | 70 | $this->assertContains('Redirecting to saved-reports', $client->getResponse()->getContent()); 71 | } 72 | 73 | //Test that submitting empty feedback results in an error 74 | public function testContactFormSubmitEmptyContent() 75 | { 76 | $client = static::createClient(); 77 | 78 | $crawler = $client->request('GET', '/'); 79 | 80 | $contactForm = $this->getContactForm($crawler); 81 | 82 | $contactForm['form[body]'] = ''; 83 | 84 | $crawler = $client->submit($contactForm); 85 | 86 | $this->assertContains('You must have something more to say!', $client->getResponse()->getContent()); 87 | } 88 | 89 | private function getArticleForm($crawler) 90 | { 91 | return $crawler->filter("form[name='articleForm']")->form(); 92 | } 93 | 94 | private function getContactForm($crawler) 95 | { 96 | return $crawler->filter("form[name='contactForm']")->form(); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /var/cache/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/var/cache/.gitkeep -------------------------------------------------------------------------------- /var/logs/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/var/logs/.gitkeep -------------------------------------------------------------------------------- /var/sessions/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/var/sessions/.gitkeep -------------------------------------------------------------------------------- /web/.htaccess: -------------------------------------------------------------------------------- 1 | # Use the front controller as index file. It serves as a fallback solution when 2 | # every other rewrite/redirect fails (e.g. in an aliased environment without 3 | # mod_rewrite). Additionally, this reduces the matching process for the 4 | # start page (path "/") because otherwise Apache will apply the rewriting rules 5 | # to each configured DirectoryIndex file (e.g. index.php, index.html, index.pl). 6 | DirectoryIndex app.php 7 | 8 | # By default, Apache does not evaluate symbolic links if you did not enable this 9 | # feature in your server configuration. Uncomment the following line if you 10 | # install assets as symlinks or if you experience problems related to symlinks 11 | # when compiling LESS/Sass/CoffeScript assets. 12 | # Options FollowSymlinks 13 | 14 | # Disabling MultiViews prevents unwanted negotiation, e.g. "/app" should not resolve 15 | # to the front controller "/app.php" but be rewritten to "/app.php/app". 16 | 17 | Options -MultiViews 18 | 19 | 20 | 21 | RewriteEngine On 22 | 23 | # Determine the RewriteBase automatically and set it as environment variable. 24 | # If you are using Apache aliases to do mass virtual hosting or installed the 25 | # project in a subdirectory, the base path will be prepended to allow proper 26 | # resolution of the app.php file and to redirect to the correct URI. It will 27 | # work in environments without path prefix as well, providing a safe, one-size 28 | # fits all solution. But as you do not need it in this case, you can comment 29 | # the following 2 lines to eliminate the overhead. 30 | RewriteCond %{REQUEST_URI}::$1 ^(/.+)/(.*)::\2$ 31 | RewriteRule ^(.*) - [E=BASE:%1] 32 | 33 | # Sets the HTTP_AUTHORIZATION header removed by Apache 34 | RewriteCond %{HTTP:Authorization} . 35 | RewriteRule ^ - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] 36 | 37 | # Redirect to URI without front controller to prevent duplicate content 38 | # (with and without `/app.php`). Only do this redirect on the initial 39 | # rewrite by Apache and not on subsequent cycles. Otherwise we would get an 40 | # endless redirect loop (request -> rewrite to front controller -> 41 | # redirect -> request -> ...). 42 | # So in case you get a "too many redirects" error or you always get redirected 43 | # to the start page because your Apache does not expose the REDIRECT_STATUS 44 | # environment variable, you have 2 choices: 45 | # - disable this feature by commenting the following 2 lines or 46 | # - use Apache >= 2.3.9 and replace all L flags by END flags and remove the 47 | # following RewriteCond (best solution) 48 | RewriteCond %{ENV:REDIRECT_STATUS} ^$ 49 | RewriteRule ^app\.php(?:/(.*)|$) %{ENV:BASE}/$1 [R=301,L] 50 | 51 | # If the requested filename exists, simply serve it. 52 | # We only want to let Apache serve files and not directories. 53 | RewriteCond %{REQUEST_FILENAME} -f 54 | RewriteRule ^ - [L] 55 | 56 | # Rewrite all other queries to the front controller. 57 | RewriteRule ^ %{ENV:BASE}/app.php [L] 58 | 59 | 60 | 61 | 62 | # When mod_rewrite is not available, we instruct a temporary redirect of 63 | # the start page to the front controller explicitly so that the website 64 | # and the generated links can still be used. 65 | RedirectMatch 302 ^/$ /app.php/ 66 | # RedirectTemp cannot be used instead 67 | 68 | 69 | -------------------------------------------------------------------------------- /web/app.php: -------------------------------------------------------------------------------- 1 | loadClassCache(); 15 | //$kernel = new AppCache($kernel); 16 | 17 | // When using the HttpCache, you need to call the method in your front controller instead of relying on the configuration parameter 18 | //Request::enableHttpMethodParameterOverride(); 19 | $request = Request::createFromGlobals(); 20 | $response = $kernel->handle($request); 21 | $response->send(); 22 | $kernel->terminate($request, $response); 23 | -------------------------------------------------------------------------------- /web/app_dev.php: -------------------------------------------------------------------------------- 1 | loadClassCache(); 29 | $request = Request::createFromGlobals(); 30 | $response = $kernel->handle($request); 31 | $response->send(); 32 | $kernel->terminate($request, $response); 33 | -------------------------------------------------------------------------------- /web/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/web/apple-touch-icon.png -------------------------------------------------------------------------------- /web/config.php: -------------------------------------------------------------------------------- 1 | getFailedRequirements(); 30 | $minorProblems = $symfonyRequirements->getFailedRecommendations(); 31 | 32 | ?> 33 | 34 | 35 | 36 | 37 | 38 | Symfony Configuration Checker 39 | 40 | 41 | 118 | 119 | 120 |
121 |
122 | 125 | 126 | 146 |
147 | 148 |
149 |
150 |
151 |

Configuration Checker

152 |

153 | This script analyzes your system to check whether is 154 | ready to run Symfony applications. 155 |

156 | 157 | 158 |

Major problems

159 |

Major problems have been detected and must be fixed before continuing:

160 |
    161 | 162 |
  1. getHelpHtml() ?>
  2. 163 | 164 |
165 | 166 | 167 | 168 |

Recommendations

169 |

170 | Additionally, toTo enhance your Symfony experience, 171 | it’s recommended that you fix the following: 172 |

173 |
    174 | 175 |
  1. getHelpHtml() ?>
  2. 176 | 177 |
178 | 179 | 180 | hasPhpIniConfigIssue()): ?> 181 |

* 182 | getPhpIniConfigPath()): ?> 183 | Changes to the php.ini file must be done in "getPhpIniConfigPath() ?>". 184 | 185 | To change settings, create a "php.ini". 186 | 187 |

188 | 189 | 190 | 191 |

All checks passed successfully. Your system is ready to run Symfony applications.

192 | 193 | 194 | 199 |
200 |
201 |
202 |
Symfony Standard Edition
203 |
204 | 205 | 206 | -------------------------------------------------------------------------------- /web/css/base/reset.css: -------------------------------------------------------------------------------- 1 | /* http://meyerweb.com/eric/tools/css/reset/ 2 | v2.0 | 20110126 3 | License: none (public domain) 4 | */ 5 | 6 | html, body, div, span, applet, object, iframe, 7 | h1, h2, h3, h4, h5, h6, p, blockquote, pre, 8 | a, abbr, acronym, address, big, cite, code, 9 | del, dfn, em, img, ins, kbd, q, s, samp, 10 | small, strike, strong, sub, sup, tt, var, 11 | b, u, i, center, 12 | dl, dt, dd, ol, ul, li, 13 | fieldset, form, label, legend, 14 | table, caption, tbody, tfoot, thead, tr, th, td, 15 | article, aside, canvas, details, embed, 16 | figure, figcaption, footer, header, hgroup, 17 | menu, nav, output, ruby, section, summary, 18 | time, mark, audio, video { 19 | margin: 0; 20 | padding: 0; 21 | border: 0; 22 | font-size: 100%; 23 | font: inherit; 24 | vertical-align: baseline; 25 | } 26 | /* HTML5 display-role reset for older browsers */ 27 | article, aside, details, figcaption, figure, 28 | footer, header, hgroup, menu, nav, section { 29 | display: block; 30 | } 31 | body { 32 | line-height: 1; 33 | } 34 | ol, ul { 35 | list-style: none; 36 | } 37 | blockquote, q { 38 | quotes: none; 39 | } 40 | blockquote:before, blockquote:after, 41 | q:before, q:after { 42 | content: ''; 43 | content: none; 44 | } 45 | table { 46 | border-collapse: collapse; 47 | border-spacing: 0; 48 | } -------------------------------------------------------------------------------- /web/css/index/bubbles.css: -------------------------------------------------------------------------------- 1 | 2 | #bubbles { 3 | /*height: 500px; 4 | width: 500px; 5 | position: absolute; 6 | */ 7 | margin-left: -130px; 8 | padding: 100px; 9 | position: relative; 10 | left: 50%; 11 | top: 0px; 12 | z-index: 4; 13 | } 14 | 15 | 16 | .bubble { 17 | width: 30px; 18 | height: 30px; 19 | background: #15A93B; 20 | border-radius: 200px; 21 | -moz-border-radius: 200px; 22 | -webkit-border-radius: 200px; 23 | position: absolute; 24 | } 25 | 26 | .x1 { 27 | left: 80px; 28 | -webkit-transform: scale(0.5); 29 | -moz-transform: scale(0.5); 30 | transform: scale(0.5); 31 | opacity: 0.4; 32 | -webkit-animation: moveclouds 4.1s linear infinite, sideWays 4s ease-in-out infinite alternate; 33 | -moz-animation: moveclouds 4.1s linear infinite, sideWays 4s ease-in-out infinite alternate; 34 | -o-animation: moveclouds 4.1s linear infinite, sideWays 4s ease-in-out infinite alternate; 35 | } 36 | 37 | .x2 { 38 | left: 80px; 39 | -webkit-transform: scale(0.5); 40 | -moz-transform: scale(0.5); 41 | transform: scale(0.5); 42 | opacity: 0.5; 43 | -webkit-animation: moveclouds 5s linear infinite, sideWays 5s ease-in-out infinite alternate; 44 | -moz-animation: moveclouds 5s linear infinite, sideWays 5s ease-in-out infinite alternate; 45 | -o-animation: moveclouds 5s linear infinite, sideWays 5s ease-in-out infinite alternate; 46 | } 47 | .x3 { 48 | left: 82px; 49 | -webkit-transform: scale(0.35); 50 | -moz-transform: scale(0.35); 51 | transform: scale(0.35); 52 | opacity: 0.6; 53 | -webkit-animation: moveclouds 3.9s linear infinite, sideWays 4s ease-in-out infinite alternate; 54 | -moz-animation: moveclouds 3.9s linear infinite, sideWays 4s ease-in-out infinite alternate; 55 | -o-animation: moveclouds 3.9s linear infinite, sideWays 4s ease-in-out infinite alternate; 56 | } 57 | .x4 { 58 | left: 84px; 59 | -webkit-transform: scale(0.4); 60 | -moz-transform: scale(0.4); 61 | transform: scale(0.4); 62 | opacity: 0.35; 63 | -webkit-animation: moveclouds 6.1s linear infinite, sideWays 2s ease-in-out infinite alternate; 64 | -moz-animation: moveclouds 6.1s linear infinite, sideWays 2s ease-in-out infinite alternate; 65 | -o-animation: moveclouds 6.1s linear infinite, sideWays 2s ease-in-out infinite alternate; 66 | } 67 | .x5 { 68 | left: 82px; 69 | -webkit-transform: scale(0.2); 70 | -moz-transform: scale(0.2); 71 | transform: scale(0.2); 72 | opacity: 0.8; 73 | -webkit-animation: moveclouds 3.5s linear infinite, sideWays 3s ease-in-out infinite alternate; 74 | -moz-animation: moveclouds 3.5s linear infinite, sideWays 3s ease-in-out infinite alternate; 75 | -o-animation: moveclouds 3.5s linear infinite, sideWays 3s ease-in-out infinite alternate; 76 | } 77 | 78 | .x6 { 79 | left: 86px; 80 | -webkit-transform: scale(0.45); 81 | -moz-transform: scale(0.45); 82 | transform: scale(0.45); 83 | opacity: 0.9; 84 | -webkit-animation: moveclouds 7s linear infinite, sideWays 7s ease-in-out infinite alternate; 85 | -moz-animation: moveclouds 7s linear infinite, sideWays 7s ease-in-out infinite alternate; 86 | -o-animation: moveclouds 7s linear infinite, sideWays 7s ease-in-out infinite alternate; 87 | } 88 | 89 | .x7 { 90 | left: 88px; 91 | -webkit-transform: scale(0.7); 92 | -moz-transform: scale(0.7); 93 | transform: scale(0.7); 94 | opacity: 0.9; 95 | -webkit-animation: moveclouds 4.7s linear infinite, sideWays 6s ease-in-out infinite alternate; 96 | -moz-animation: moveclouds 4.7s linear infinite, sideWays 6s ease-in-out infinite alternate; 97 | -o-animation: moveclouds 4.7s linear infinite, sideWays 6s ease-in-out infinite alternate; 98 | } 99 | 100 | .x8 { 101 | left: 80px; 102 | -webkit-transform: scale(0.6); 103 | -moz-transform: scale(0.6); 104 | transform: scale(0.6); 105 | opacity: 0.4; 106 | -webkit-animation: moveclouds 5.5s linear infinite, sideWays 18s ease-in-out infinite alternate; 107 | -moz-animation: moveclouds 5.5s linear infinite, sideWays 18s ease-in-out infinite alternate; 108 | -o-animation: moveclouds 5.5s linear infinite, sideWays 18s ease-in-out infinite alternate; 109 | } 110 | 111 | .x9 { 112 | left: 82px; 113 | -webkit-transform: scale(0.5); 114 | -moz-transform: scale(0.5); 115 | transform: scale(0.5); 116 | opacity: 0.9; 117 | -webkit-animation: moveclouds 6.2s linear infinite, sideWays 4s ease-in-out infinite alternate; 118 | -moz-animation: moveclouds 6.2s linear infinite, sideWays 4s ease-in-out infinite alternate; 119 | -o-animation: moveclouds 6.2s linear infinite, sideWays 4s ease-in-out infinite alternate; 120 | } 121 | 122 | .x10 { 123 | left: 82px; 124 | -webkit-transform: scale(0.3); 125 | -moz-transform: scale(0.3); 126 | transform: scale(0.3); 127 | opacity: 0.9; 128 | -webkit-animation: moveclouds 7.4s linear infinite, sideWays 2s ease-in-out infinite alternate; 129 | -moz-animation: moveclouds 7.4s linear infinite, sideWays 2s ease-in-out infinite alternate; 130 | -o-animation: moveclouds 7.4s linear infinite, sideWays 2s ease-in-out infinite alternate; 131 | } 132 | 133 | .x11 { 134 | left: 84px; 135 | -webkit-transform: scale(0.4); 136 | -moz-transform: scale(0.4); 137 | transform: scale(0.4); 138 | opacity: 0.9; 139 | -webkit-animation: moveclouds 3.8s linear infinite, sideWays 3s ease-in-out infinite alternate; 140 | -moz-animation: moveclouds 3.8s linear infinite, sideWays 3s ease-in-out infinite alternate; 141 | -o-animation: moveclouds 3.8s linear infinite, sideWays 3s ease-in-out infinite alternate; 142 | } 143 | 144 | .x12 { 145 | left: 86px; 146 | -webkit-transform: scale(0.2); 147 | -moz-transform: scale(0.2); 148 | transform: scale(0.2); 149 | opacity: 0.9; 150 | -webkit-animation: moveclouds 5.7s linear infinite, sideWays 3s ease-in-out infinite alternate; 151 | -moz-animation: moveclouds 5.7s linear infinite, sideWays 3s ease-in-out infinite alternate; 152 | -o-animation: moveclouds 5.7s linear infinite, sideWays 3s ease-in-out infinite alternate; 153 | } 154 | 155 | .x13 { 156 | left: 86px; 157 | -webkit-transform: scale(0.3); 158 | -moz-transform: scale(0.3); 159 | transform: scale(0.3); 160 | opacity: 0.9; 161 | -webkit-animation: moveclouds 4.2s linear infinite, sideWays 2s ease-in-out infinite alternate; 162 | -moz-animation: moveclouds 4.2s linear infinite, sideWays 2s ease-in-out infinite alternate; 163 | -o-animation: moveclouds 4.2s linear infinite, sideWays 2s ease-in-out infinite alternate; 164 | } 165 | 166 | .x14 { 167 | left: 88px; 168 | -webkit-transform: scale(0.1); 169 | -moz-transform: scale(0.1); 170 | transform: scale(0.1); 171 | opacity: 0.7; 172 | -webkit-animation: moveclouds 3.2s linear infinite, sideWays 2s ease-in-out infinite alternate; 173 | -moz-animation: moveclouds 3.2s linear infinite, sideWays 2s ease-in-out infinite alternate; 174 | -o-animation: moveclouds 3.2s linear infinite, sideWays 2s ease-in-out infinite alternate; 175 | } 176 | 177 | .x15 { 178 | left: 88px; 179 | -webkit-transform: scale(0.2); 180 | -moz-transform: scale(0.2); 181 | transform: scale(0.2); 182 | opacity: 0.6; 183 | -webkit-animation: moveclouds 3.4s linear infinite, sideWays 2s ease-in-out infinite alternate; 184 | -moz-animation: moveclouds 3.4s linear infinite, sideWays 2s ease-in-out infinite alternate; 185 | -o-animation: moveclouds 3.4s linear infinite, sideWays 2s ease-in-out infinite alternate; 186 | } 187 | 188 | .x16 { 189 | left: 90px; 190 | -webkit-transform: scale(0.2); 191 | -moz-transform: scale(0.2); 192 | transform: scale(0.2); 193 | opacity: 0.9; 194 | -webkit-animation: moveclouds 4.3s linear infinite, sideWays 3s ease-in-out infinite alternate; 195 | -moz-animation: moveclouds 4.3s linear infinite, sideWays 3s ease-in-out infinite alternate; 196 | -o-animation: moveclouds 4.3s linear infinite, sideWays 3s ease-in-out infinite alternate; 197 | } 198 | 199 | .x17 { 200 | left: 92px; 201 | -webkit-transform: scale(0.4); 202 | -moz-transform: scale(0.4); 203 | transform: scale(0.4); 204 | opacity: 0.6; 205 | -webkit-animation: moveclouds 3.7s linear infinite, sideWays 4s ease-in-out infinite alternate; 206 | -moz-animation: moveclouds 3.7s linear infinite, sideWays 4s ease-in-out infinite alternate; 207 | -o-animation: moveclouds 3.7s linear infinite, sideWays 4s ease-in-out infinite alternate; 208 | } 209 | 210 | .x18 { 211 | left: 92px; 212 | -webkit-transform: scale(0.2); 213 | -moz-transform: scale(0.2); 214 | transform: scale(0.2); 215 | opacity: 0.9; 216 | -webkit-animation: moveclouds 5.2s linear infinite, sideWays 3s ease-in-out infinite alternate; 217 | -moz-animation: moveclouds 5.2s linear infinite, sideWays 3s ease-in-out infinite alternate; 218 | -o-animation: moveclouds 5.2s linear infinite, sideWays 3s ease-in-out infinite alternate; 219 | } 220 | 221 | .x19 { 222 | left: 94px; 223 | -webkit-transform: scale(0.5); 224 | -moz-transform: scale(0.5); 225 | transform: scale(0.5); 226 | opacity: 0.7; 227 | -webkit-animation: moveclouds 4.9s linear infinite, sideWays 5.5s ease-in-out infinite alternate; 228 | -moz-animation: moveclouds 4.9s linear infinite, sideWays 5.5s ease-in-out infinite alternate; 229 | -o-animation: moveclouds 4.9s linear infinite, sideWays 5.5s ease-in-out infinite alternate; 230 | } 231 | 232 | .x20 { 233 | left: 94px; 234 | -webkit-transform: scale(0.3); 235 | -moz-transform: scale(0.3); 236 | transform: scale(0.3); 237 | opacity: 0.9; 238 | -webkit-animation: moveclouds 5.4s linear infinite, sideWays 2s ease-in-out infinite alternate; 239 | -moz-animation: moveclouds 5.4s linear infinite, sideWays 2s ease-in-out infinite alternate; 240 | -o-animation: moveclouds 5.4s linear infinite, sideWays 2s ease-in-out infinite alternate; 241 | } 242 | 243 | .x21 { 244 | left: 96px; 245 | -webkit-transform: scale(0.2); 246 | -moz-transform: scale(0.2); 247 | transform: scale(0.2); 248 | opacity: 0.6; 249 | -webkit-animation: moveclouds 3.9s linear infinite, sideWays 2s ease-in-out infinite alternate; 250 | -moz-animation: moveclouds 3.9s linear infinite, sideWays 2s ease-in-out infinite alternate; 251 | -o-animation: moveclouds 3.9s linear infinite, sideWays 2s ease-in-out infinite alternate; 252 | } 253 | 254 | .x22 { 255 | left: 96px; 256 | -webkit-transform: scale(0.35); 257 | -moz-transform: scale(0.35); 258 | transform: scale(0.35); 259 | opacity: 0.7; 260 | -webkit-animation: moveclouds 6.2s linear infinite, sideWays 3s ease-in-out infinite alternate; 261 | -moz-animation: moveclouds 6.2s linear infinite, sideWays 3s ease-in-out infinite alternate; 262 | -o-animation: moveclouds 6.2s linear infinite, sideWays 3s ease-in-out infinite alternate; 263 | } 264 | 265 | .x22 { 266 | left: 94px; 267 | -webkit-transform: scale(0.4); 268 | -moz-transform: scale(0.4); 269 | transform: scale(0.4); 270 | opacity: 0.6; 271 | -webkit-animation: moveclouds 3.7s linear infinite, sideWays 2.3s ease-in-out infinite alternate; 272 | -moz-animation: moveclouds 3.7s linear infinite, sideWays 2.3s ease-in-out infinite alternate; 273 | -o-animation: moveclouds 3.7s linear infinite, sideWays 2.3s ease-in-out infinite alternate; 274 | } 275 | 276 | .x23 { 277 | left: 90px; 278 | -webkit-transform: scale(0.3); 279 | -moz-transform: scale(0.3); 280 | transform: scale(0.3); 281 | opacity: 0.6; 282 | -webkit-animation: moveclouds 4.4s linear infinite, sideWays 3.5s ease-in-out infinite alternate; 283 | -moz-animation: moveclouds 4.4s linear infinite, sideWays 3.5s ease-in-out infinite alternate; 284 | -o-animation: moveclouds 4.4s linear infinite, sideWays 3.5s ease-in-out infinite alternate; 285 | } 286 | 287 | .x24 { 288 | left: 76px; 289 | -webkit-transform: scale(0.4); 290 | -moz-transform: scale(0.4); 291 | transform: scale(0.4); 292 | opacity: 0.8; 293 | -webkit-animation: moveclouds 3.6s linear infinite, sideWays 4.2s ease-in-out infinite alternate; 294 | -moz-animation: moveclouds 3.6s linear infinite, sideWays 4.2s ease-in-out infinite alternate; 295 | -o-animation: moveclouds 3.6s linear infinite, sideWays 4.2s ease-in-out infinite alternate; 296 | } 297 | 298 | .x25 { 299 | left: 80px; 300 | -webkit-transform: scale(0.4); 301 | -moz-transform: scale(0.4); 302 | transform: scale(0.4); 303 | opacity: 0.6; 304 | -webkit-animation: moveclouds 3.2s linear infinite, sideWays 3s ease-in-out infinite alternate; 305 | -moz-animation: moveclouds 3.2s linear infinite, sideWays 3s ease-in-out infinite alternate; 306 | -o-animation: moveclouds 3.2s linear infinite, sideWays 3s ease-in-out infinite alternate; 307 | } 308 | 309 | .x26 { 310 | left: 82px; 311 | -webkit-transform: scale(0.6); 312 | -moz-transform: scale(0.6); 313 | transform: scale(0.6); 314 | opacity: 0.9; 315 | -webkit-animation: moveclouds 4s linear infinite, sideWays 4s ease-in-out infinite alternate; 316 | -moz-animation: moveclouds 4s linear infinite, sideWays 4s ease-in-out infinite alternate; 317 | -o-animation: moveclouds 4s linear infinite, sideWays 4s ease-in-out infinite alternate; 318 | } 319 | 320 | .x27 { 321 | left: 84px; 322 | -webkit-transform: scale(0.4); 323 | -moz-transform: scale(0.4); 324 | transform: scale(0.4); 325 | opacity: 0.9; 326 | -webkit-animation: moveclouds 4.2s linear infinite, sideWays 2.3s ease-in-out infinite alternate; 327 | -moz-animation: moveclouds 4.2s linear infinite, sideWays 2.3s ease-in-out infinite alternate; 328 | -o-animation: moveclouds 4.2s linear infinite, sideWays 2.3s ease-in-out infinite alternate; 329 | } 330 | 331 | @-webkit-keyframes moveclouds { 332 | 0% { 333 | top: 500px; 334 | } 335 | 100% { 336 | top: -500px; 337 | } 338 | } 339 | 340 | @-webkit-keyframes sideWays { 341 | 0% { 342 | margin-left:0px; 343 | } 344 | 100% { 345 | margin-left:50px; 346 | } 347 | } 348 | 349 | @-moz-keyframes moveclouds { 350 | 0% { 351 | top: 500px; 352 | } 353 | 354 | 100% { 355 | top: -500px; 356 | } 357 | } 358 | 359 | @-moz-keyframes sideWays { 360 | 0% { 361 | margin-left:0px; 362 | } 363 | 100% { 364 | margin-left:50px; 365 | } 366 | } 367 | @-o-keyframes moveclouds { 368 | 0% { 369 | top: 500px; 370 | } 371 | 100% { 372 | top: -500px; 373 | } 374 | } 375 | 376 | @-o-keyframes sideWays { 377 | 0% { 378 | margin-left:0px; 379 | } 380 | 100% { 381 | margin-left:50px; 382 | } 383 | } -------------------------------------------------------------------------------- /web/css/index/how-it-works.css: -------------------------------------------------------------------------------- 1 | #how-it-works { 2 | background-image: url(../img/backgrounds/subtle_carbon.png); 3 | height: auto; 4 | margin: 10px; 5 | -moz-border-radius: 10px; 6 | -webkit-border-radius: 10px; 7 | border-radius: 10px; 8 | 9 | 10 | } 11 | 12 | .active { 13 | text-shadow: 0px 0px 9px #fff; 14 | } 15 | 16 | #how-it-works li, #how-it-works h3 { 17 | font-family: 'Podkova'; 18 | font-weight: bold; 19 | font-size: 23pt; 20 | color: #ED7916; 21 | text-shadow: 0px 2px 3px #994E0E; 22 | 23 | } 24 | 25 | #how-it-works h2 { 26 | padding-left: 86px; 27 | display: inline; 28 | font-family: 'Podkova', serif; 29 | font-size: 36pt; 30 | color: #2CB92C; 31 | text-shadow: 0px 2px 2px #065103; 32 | } 33 | 34 | 35 | #how-it-works p { 36 | margin-left: 5px; 37 | font-family: 'Noto Sans'; 38 | font-size: 14pt; 39 | 40 | } 41 | 42 | #how-social { 43 | padding-top: 25px; 44 | padding-left: 220px; 45 | } 46 | 47 | #support { 48 | margin-left: 48px; 49 | } 50 | 51 | #how-it-works ul li i { 52 | text-shadow: none; 53 | } 54 | 55 | 56 | -------------------------------------------------------------------------------- /web/css/index/index.css: -------------------------------------------------------------------------------- 1 | /* Adjustments for mobile */ 2 | @media (max-width: 768px) { 3 | #loading-animation h1 { 4 | font-size: 1em; 5 | top: 40%; 6 | } 7 | #loading-animation .loading-img { 8 | top: 50%; 9 | } 10 | } 11 | 12 | /* Disable Image Dragging and Selecting */ 13 | img { 14 | -webkit-user-select: none; /* Chrome/Safari */ 15 | -moz-user-select: none; /* Firefox */ 16 | -ms-user-select: none; /* IE10+ */ 17 | draggable: false; 18 | } 19 | 20 | body { 21 | font-family: 'Helvetica'; 22 | padding-top: 100px; 23 | background-color: #000; 24 | } 25 | 26 | p, a, h1, h2, h3, h4, h5, h6, span, li 27 | { 28 | font-family: 'Helvetica'; 29 | } 30 | 31 | .tab-content p { 32 | padding: 1em; 33 | color: #fff; 34 | text-shadow: none; 35 | } 36 | 37 | /* Article Textarea */ 38 | 39 | textarea { 40 | resize: none; 41 | } 42 | 43 | /* Article & contact form textareas' placeholder text */ 44 | 45 | textarea::-webkit-input-placeholder { 46 | font-family: 'Helvetica'; 47 | position: relative; 48 | top: 2em; 49 | color: #585550; 50 | font-size: 2em; 51 | text-align: center; 52 | } 53 | 54 | textarea:-moz-placeholder { /* Firefox 18- */ 55 | font-family: 'Helvetica'; 56 | position: relative; 57 | top: 2em; 58 | color: #585550; 59 | font-size: 2em; 60 | text-align: center; 61 | } 62 | 63 | textarea::-moz-placeholder { /* Firefox 19+ */ 64 | font-family: 'Helvetica'; 65 | position: relative; 66 | top: 2em; 67 | color: #585550; 68 | font-size: 2em; 69 | text-align: center; 70 | } 71 | 72 | textarea:-ms-input-placeholder { 73 | font-family: 'Helvetica'; 74 | position: relative; 75 | top: 2em; 76 | color: #585550; 77 | font-size: 2em; 78 | text-align: center; 79 | } 80 | 81 | textarea { 82 | font-family: 'Helvetica'; 83 | min-height: 300px; 84 | width: 100%; 85 | } 86 | 87 | .alert { 88 | margin-top: 1em; 89 | } 90 | 91 | /* Optional contact us response email address placeholder text */ 92 | .contact-email::-webkit-input-placeholder { 93 | font-family: 'Helvetica'; 94 | color: #585550; 95 | font-size: 1.2em; 96 | text-align: center; 97 | } 98 | 99 | .contact-email:-moz-placeholder { 100 | font-family: 'Helvetica'; 101 | color: #585550; 102 | font-size: 1.2em; 103 | text-align: center; 104 | } 105 | 106 | .contact-email::-moz-placeholder { /* Firefox 19+ */ 107 | font-family: 'Helvetica'; 108 | color: #585550; 109 | font-size: 1.2em; 110 | text-align: center; 111 | } 112 | 113 | .contact-email:-ms-input-placeholder { 114 | font-family: 'Helvetica'; 115 | color: #585550; 116 | font-size: 1.2em; 117 | text-align: center; 118 | } 119 | 120 | .submit-button { 121 | font-family: 'Helvetica'; 122 | font-size: 2em; 123 | float: right; 124 | width: 100%; 125 | } 126 | 127 | .nav { 128 | margin-top: 2em; 129 | } 130 | 131 | .nav-tabs { 132 | border-bottom: 1px solid #9AD40B; 133 | } 134 | 135 | .nav-tabs li a { 136 | font-size: 1.2em; 137 | color: #0B7A19; 138 | } 139 | 140 | .nav-tabs li.active a { 141 | background-color: #000 !important; 142 | border: 1px solid #9AD40B !important; 143 | font-size: 1.4em; 144 | color: #fff !important; 145 | } 146 | 147 | .nav-tabs li a:hover { 148 | background-color: #000 !important; 149 | border: 1px solid #9AD40B !important; 150 | color: #0B7A19 !important; 151 | } 152 | 153 | .nav-tabs li.active a:hover { 154 | background-color: #000 !important; 155 | border: 1px solid #9AD40B !important; 156 | color: #fff !important; 157 | } 158 | 159 | .signature { 160 | text-align: center; 161 | margin-top: 6em; 162 | padding-bottom: 4em; 163 | } 164 | 165 | .signature a h1 { 166 | color: green; 167 | font-size: 1.5em; 168 | } 169 | 170 | /* Mobile device adjustments */ 171 | /* Remove bubbles, shrink logo, adjust placeholder text and submit button text */ 172 | @media(max-width: 600px) { 173 | #main-logo { 174 | max-width: 80%; 175 | } 176 | #bubbles { 177 | display: none; 178 | } 179 | 180 | /* Nav */ 181 | .nav { 182 | margin-top: 1.2em; 183 | } 184 | 185 | .nav ul { 186 | display: inline-block; 187 | } 188 | 189 | .nav-tabs li { 190 | width: 100%; 191 | float: none; 192 | } 193 | 194 | .nav-tabs li a { 195 | display: inline-block; 196 | font-size: 1em; 197 | color: #0B7A19; 198 | } 199 | 200 | .nav-tabs li.active a { 201 | 202 | font-size: 0.8em; 203 | color: #0B7A19; 204 | } 205 | 206 | .nav-tabs li.active a:hover { 207 | color: #0B7A19; 208 | } 209 | 210 | /* Article & contact form textareas' placeholder text */ 211 | 212 | textarea::-webkit-input-placeholder { 213 | position: relative; 214 | top: 1.2em; 215 | color: #585550; 216 | font-size: 1.2em; 217 | text-align: center; 218 | } 219 | 220 | textarea:-moz-placeholder { /* Firefox 18- */ 221 | position: relative; 222 | top: 1.2em; 223 | color: #585550; 224 | font-size: 1.2em; 225 | text-align: center; 226 | } 227 | 228 | textarea::-moz-placeholder { /* Firefox 19+ */ 229 | position: relative; 230 | top: 1.2em; 231 | color: #585550; 232 | font-size: 1.2em; 233 | text-align: center; 234 | } 235 | 236 | textarea:-ms-input-placeholder { 237 | position: relative; 238 | top: 1.2em; 239 | color: #585550; 240 | font-size: 1.2em; 241 | text-align: center; 242 | } 243 | 244 | textarea { 245 | min-height: 300px; 246 | width: 100%; 247 | } 248 | 249 | /* Optional contact us response email address placeholder text */ 250 | .contact-email::-webkit-input-placeholder { 251 | color: #585550; 252 | font-size: 1.2em; 253 | text-align: center; 254 | } 255 | 256 | .contact-email:-moz-placeholder { 257 | color: #585550; 258 | font-size: 1.2em; 259 | text-align: center; 260 | } 261 | 262 | .contact-email::-moz-placeholder { /* Firefox 19+ */ 263 | color: #585550; 264 | font-size: 1.2em; 265 | text-align: center; 266 | } 267 | 268 | .contact-email:-ms-input-placeholder { 269 | color: #585550; 270 | font-size: 1.2em; 271 | text-align: center; 272 | } 273 | 274 | .submit-button { 275 | font-size: 1.2em; 276 | float: right; 277 | width: 100%; 278 | } 279 | } 280 | -------------------------------------------------------------------------------- /web/css/index/loading.css: -------------------------------------------------------------------------------- 1 | .overlay { 2 | /* stretch the overlay by attaching all four corners */ 3 | z-index: 3; 4 | font-size: 22pt; 5 | color: #fff; 6 | text-align: center; 7 | position: absolute; 8 | width: 100%; 9 | height: 100%; 10 | min-height: 1600px; 11 | left: 0; 12 | top: 0; 13 | bottom: 0; 14 | right: 0; 15 | /* use any background color you like; rgba would be nice, but older IE will ignore it*/ 16 | background: #000; 17 | opacity: 0.8; 18 | filter: alpha(opacity=85); 19 | } 20 | 21 | #overlay h2 { 22 | font-weight: bold; 23 | position: relative; 24 | padding-top: 235px; 25 | padding-bottom: 20px; 26 | 27 | } 28 | 29 | #loading { 30 | width: 200px; 31 | height: 200px; 32 | position: absolute; 33 | z-index: -40000; 34 | /* the image is 50x57, so we use negative margins of 1/2 that size to center it*/ 35 | margin: -100px 0 0 -100px; 36 | } 37 | 38 | .loading-text { 39 | position: relative; 40 | top: 55%; 41 | font-size: 2em; 42 | opacity: 1.0; 43 | filter: alpha(opacity=100); 44 | } 45 | 46 | .loading-img { 47 | margin-top: 0.5em; 48 | top: 65%; 49 | left: 50%; 50 | } 51 | 52 | -------------------------------------------------------------------------------- /web/css/main.css: -------------------------------------------------------------------------------- 1 | @import 'modules/application.scss'; 2 | @import 'modules/base.scss'; 3 | @import 'modules/buttons.scss'; 4 | @import 'modules/contact.scss'; 5 | @import 'modules/font-awesome.scss'; 6 | @import 'modules/home-nav.scss'; 7 | @import 'modules/how-it-works.scss'; 8 | @import 'modules/index.scss'; 9 | @import 'modules/loading.scss'; 10 | @import 'modules/main.scss'; 11 | @import 'modules/modals.scss'; 12 | @import 'modules/report-ads.scss'; 13 | @import 'modules/report-graphs.scss'; 14 | @import 'modules/report-table.scss'; 15 | @import 'modules/report.scss'; 16 | @import 'modules/reset.scss'; 17 | @import 'modules/signup-form.scss'; 18 | -------------------------------------------------------------------------------- /web/css/report/report.css: -------------------------------------------------------------------------------- 1 | /* Adjustments for mobile */ 2 | @media (max-width: 768px) { 3 | .col-xs-12 label { 4 | padding: 1em 0 1em 0; 5 | } 6 | .ribbon-row button.ribbon h1 { 7 | font-size: 1em; 8 | font-weight: 600; 9 | } 10 | .btn-group.share-buttons a { 11 | margin-top: 0.5em; 12 | font-size: 0.6em; 13 | } 14 | p { 15 | font-size: 1em; 16 | } 17 | 18 | } 19 | 20 | body, p, a, h1, h2, h3, h4, h5, h6, span, li, label 21 | { 22 | font-family: 'Helvetica'; 23 | } 24 | 25 | input[type="submit"]:active { 26 | padding: 0; 27 | } 28 | 29 | button::-moz-focus-inner { 30 | border: 0; 31 | } 32 | 33 | .hidden { 34 | display: none; 35 | } 36 | 37 | /* Bootstrap Ribbon BEGIN */ 38 | .jumbotron { 39 | background-image: url('../img/backgrounds/groovepaper.png'); 40 | margin-top: -10px; 41 | } 42 | 43 | .jumbotron p { 44 | font-size: 18px; 45 | } 46 | 47 | .jumbotron.first, .report-backing { 48 | margin-bottom: 0; 49 | } 50 | 51 | .link-holder { 52 | padding-bottom: 2em; 53 | } 54 | 55 | span.form-control.article-pedestal { 56 | height: 19em; 57 | overflow: scroll; 58 | } 59 | 60 | button { 61 | min-height: 60px; 62 | margin: 20px 0; 63 | line-height: 34px; 64 | position: relative; 65 | cursor: pointer; 66 | user-select: none; 67 | outline: none !important; 68 | width: 100%; 69 | } 70 | 71 | .report-send-button { 72 | min-height: 0; 73 | min-width: 0; 74 | } 75 | 76 | button:active { 77 | outline: none; 78 | } 79 | 80 | button.ribbon { 81 | outline: none; 82 | outline-color: transparent; 83 | } 84 | 85 | button.ribbon:hover { 86 | background-color: #337ab7; 87 | } 88 | 89 | button.ribbon h1, button.ribbon-wrapped { 90 | font-size: 2.5em; 91 | } 92 | 93 | button.ribbon.body { 94 | margin-top: -10px; 95 | } 96 | 97 | button.ribbon:before, 98 | button.ribbon:after { 99 | top: 8px; 100 | z-index: -10; 101 | } 102 | 103 | button.ribbon:before { 104 | border-color: #53dab6 #53dab6 #53dab6 transparent; 105 | left: -35px; 106 | border-width: 20px; 107 | } 108 | 109 | button.ribbon:after { 110 | border-color: #53dab6 transparent #53dab6 #53dab6; 111 | right: -35px; 112 | border-width: 20px; 113 | } 114 | 115 | button:before, 116 | button:after { 117 | content: ''; 118 | position: absolute; 119 | height: 0; 120 | width: 0; 121 | border-style: solid; 122 | border-width: 0; 123 | outline: none; 124 | } 125 | 126 | button.btn-default:before { 127 | border-color: #757575 #757575 #757575 transparent; 128 | } 129 | 130 | button.btn-default:after { 131 | border-color: #757575 transparent #757575 #757575; 132 | } 133 | 134 | button.btn-primary:before { 135 | border-color: #2e6da4 #2e6da4 #2e6da4 transparent; 136 | } 137 | 138 | button.btn-primary:after { 139 | border-color: #2e6da4 transparent #2e6da4 #2e6da4; 140 | } 141 | 142 | button.btn-success:before { 143 | border-color: #398439 #398439 #398439 transparent; 144 | } 145 | 146 | button.btn-success:after { 147 | border-color: #398439 transparent #398439 #398439; 148 | } 149 | 150 | button.btn-info:before { 151 | border-color: #269abc #269abc #269abc transparent; 152 | } 153 | 154 | button.btn-info:after { 155 | border-color: #269abc transparent #269abc #269abc; 156 | } 157 | 158 | button.btn-warning:before { 159 | border-color: #d58512 #d58512 #d58512 transparent; 160 | } 161 | 162 | button.btn-warning:after { 163 | border-color: #d58512 transparent #d58512 #d58512; 164 | } 165 | 166 | button.btn-danger:before { 167 | border-color: #ac2925 #ac2925 #ac2925 transparent; 168 | } 169 | 170 | button.btn-danger:after { 171 | border-color: #ac2925 transparent #ac2925 #ac2925; 172 | } 173 | /* END Bootstrap Ribbon */ 174 | 175 | 176 | /* Disable UI Elements touch activity / highlighting behavior */ 177 | 178 | .noselect { 179 | -webkit-touch-callout: none; 180 | /* iOS Safari */ 181 | -webkit-user-select: none; 182 | /* Chrome/Safari/Opera */ 183 | -khtml-user-select: none; 184 | /* Konqueror */ 185 | -moz-user-select: none; 186 | /* Firefox */ 187 | -ms-user-select: none; 188 | /* Internet Explorer/Edge */ 189 | user-select: none; 190 | /* Non-prefixed version, currently not supported by any browser */ 191 | } 192 | 193 | /* Bootstrap Ribbon END */ 194 | 195 | 196 | /*-----BODY------*/ 197 | 198 | body { 199 | -webkit-touch-callout: none; 200 | -webkit-user-select: none; 201 | -khtml-user-select: none; 202 | -moz-user-select: none; 203 | -ms-user-select: none; 204 | user-select: none; 205 | } 206 | 207 | body { 208 | background-image: url(../img/backgrounds/navy_blue.png); 209 | min-height: 1000px; 210 | height: 100%; 211 | } 212 | 213 | body.report { 214 | background-image: url(../img/backgrounds/paper.png); 215 | } 216 | 217 | img.opening-report-ribbon { 218 | position: absolute; 219 | left: 0; 220 | } 221 | 222 | /* BEGIN Advertisement blocks */ 223 | .ad { 224 | margin: 0 auto; 225 | } 226 | /* END Advertisement blocks */ 227 | 228 | .row.images img.img-responsive { 229 | max-width: 200px; 230 | max-height: 200px; 231 | } 232 | 233 | #error h1 { 234 | text-align: center; 235 | color: #D62901; 236 | font-size: 92pt; 237 | } 238 | 239 | #container { 240 | position: absolute; 241 | left: 50%; 242 | /*top: 50%;*/ 243 | width: 800px; 244 | height: 650px; 245 | /*margin-top: -325px; */ 246 | margin-left: -400px; 247 | /*background-image: url(brushed_alu.png);*/ 248 | } 249 | 250 | #text-box { 251 | position: absolute; 252 | left: 50%; 253 | top: 50%; 254 | width: 750px; 255 | height: 600px; 256 | margin-top: -300px; 257 | margin-left: -375px; 258 | } 259 | 260 | #results-container { 261 | position: absolute; 262 | left: 50%; 263 | top: 50%; 264 | width: 1200px; 265 | height: 1050px; 266 | margin-top: -725px; 267 | margin-left: -800px; 268 | background-color: white; 269 | } 270 | 271 | #report-container { 272 | margin: 0 auto; 273 | max-width: 1260px; 274 | overflow: hidden; 275 | padding-bottom: 20px; 276 | } 277 | 278 | textarea { 279 | resize: none; 280 | /*z-index: 3;*/ 281 | border: none; 282 | } 283 | 284 | #selector input { 285 | font-size: 36pt; 286 | } 287 | 288 | textarea::-webkit-input-placeholder { 289 | font-family: 'Helvetica'; 290 | } 291 | 292 | textarea:-moz-placeholder { /* Firefox 18- */ 293 | font-family: 'Helvetica'; 294 | } 295 | 296 | textarea::-moz-placeholder { /* Firefox 19+ */ 297 | font-family: 'Helvetica'; 298 | } 299 | 300 | textarea:-ms-input-placeholder { 301 | font-family: 'Helvetica'; 302 | } 303 | 304 | #summary { 305 | padding: 12px; 306 | } 307 | 308 | #bufferDiv { 309 | margin-bottom: 765px; 310 | } 311 | 312 | .report-intro-text { 313 | margin: 1em 0 0 0.5em; 314 | padding: 0.5em; 315 | } 316 | 317 | /*-----------GRAPH STYLES-------------*/ 318 | 319 | 320 | /*-------------IMAGES TABLE--------------*/ 321 | 322 | .loading-div .fa-spinner { 323 | margin-top: 1em; 324 | } 325 | 326 | #images { 327 | position: relative; 328 | overflow: scroll; 329 | top: -100px; 330 | width: 100%; 331 | } 332 | 333 | .image-container { 334 | position: relative; 335 | max-width: 200px; 336 | max-height: 200px; 337 | } 338 | 339 | .image-container .overlay { 340 | position: absolute; 341 | top: 0; 342 | left: 0; 343 | width: 100%; 344 | height: 100%; 345 | display: none; 346 | color: #fff; 347 | } 348 | 349 | .image-container:hover .overlay { 350 | display: block; 351 | background: rgba(0, 0, 0, .6); 352 | } 353 | 354 | 355 | /* End pulled from main.css */ 356 | 357 | #results-container { 358 | position: absolute; 359 | left: 50%; 360 | top: 50%; 361 | width: 1200px; 362 | height: 1050px; 363 | margin-top: -725px; 364 | margin-left: -800px; 365 | background-color: white; 366 | } 367 | 368 | #report-container { 369 | background-image: url(../img/backgrounds/debut_light.png); 370 | margin: 0 auto; 371 | max-width: 1260px; 372 | overflow: hidden; 373 | padding-bottom: 20px; 374 | } 375 | 376 | body.report { 377 | } 378 | 379 | #summary { 380 | padding: 12px; 381 | } 382 | 383 | #bufferDiv { 384 | margin-bottom: 765px; 385 | } 386 | 387 | #images { 388 | position: relative; 389 | overflow: scroll; 390 | top: -100px; 391 | width: 100%; 392 | } 393 | 394 | .overlay { 395 | display: none; 396 | opacity: 1; 397 | background-color: rgba(50,50,50,1); 398 | position: absolute; 399 | height: 100%; 400 | width: 100%; 401 | overflow: hidden; 402 | top: 0; 403 | left: 0; 404 | display: none; 405 | } 406 | 407 | .overlay.text-center { 408 | margin-top: 1em; 409 | } 410 | 411 | ul.img-row{ 412 | list-style:none; 413 | margin:0; 414 | padding:0; 415 | } 416 | .ratio-4-3{ 417 | width:100%; 418 | position:relative; 419 | background:url() 50% 50% no-repeat; 420 | background-size:cover; 421 | background-clip:content-box; 422 | } 423 | .ratio-4-3:before{ 424 | display:block; 425 | content:""; 426 | padding-top:75%; 427 | } -------------------------------------------------------------------------------- /web/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/web/favicon.ico -------------------------------------------------------------------------------- /web/fonts/FontAwesome.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/web/fonts/FontAwesome.otf -------------------------------------------------------------------------------- /web/fonts/fontawesome-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/web/fonts/fontawesome-webfont.eot -------------------------------------------------------------------------------- /web/fonts/fontawesome-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/web/fonts/fontawesome-webfont.ttf -------------------------------------------------------------------------------- /web/fonts/fontawesome-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/web/fonts/fontawesome-webfont.woff -------------------------------------------------------------------------------- /web/fonts/fontawesome-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/web/fonts/fontawesome-webfont.woff2 -------------------------------------------------------------------------------- /web/fonts/glyphicons-halflings-regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/web/fonts/glyphicons-halflings-regular.eot -------------------------------------------------------------------------------- /web/fonts/glyphicons-halflings-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/web/fonts/glyphicons-halflings-regular.ttf -------------------------------------------------------------------------------- /web/fonts/glyphicons-halflings-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/web/fonts/glyphicons-halflings-regular.woff -------------------------------------------------------------------------------- /web/fonts/glyphicons-halflings-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/web/fonts/glyphicons-halflings-regular.woff2 -------------------------------------------------------------------------------- /web/img/ads/MarketSamuraiBanner.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/web/img/ads/MarketSamuraiBanner.jpg -------------------------------------------------------------------------------- /web/img/ads/bluehost/300x250/bh_300x250_01.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/web/img/ads/bluehost/300x250/bh_300x250_01.gif -------------------------------------------------------------------------------- /web/img/ads/bluehost/300x250/bh_300x250_02.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/web/img/ads/bluehost/300x250/bh_300x250_02.gif -------------------------------------------------------------------------------- /web/img/ads/bluehost/300x250/bh_300x250_03.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/web/img/ads/bluehost/300x250/bh_300x250_03.gif -------------------------------------------------------------------------------- /web/img/ads/bluehost/300x250/bh_300x250_04.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/web/img/ads/bluehost/300x250/bh_300x250_04.jpg -------------------------------------------------------------------------------- /web/img/ads/bluehost/300x250/bh_300x250_05.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/web/img/ads/bluehost/300x250/bh_300x250_05.gif -------------------------------------------------------------------------------- /web/img/ads/elegantthemes/300x250.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/web/img/ads/elegantthemes/300x250.gif -------------------------------------------------------------------------------- /web/img/ads/semrush_banner_300_250.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/web/img/ads/semrush_banner_300_250.png -------------------------------------------------------------------------------- /web/img/article-optimizer-example-report.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/web/img/article-optimizer-example-report.png -------------------------------------------------------------------------------- /web/img/article-optimizer-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/web/img/article-optimizer-logo.png -------------------------------------------------------------------------------- /web/img/backgrounds/groovepaper.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/web/img/backgrounds/groovepaper.png -------------------------------------------------------------------------------- /web/img/backgrounds/navy_blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/web/img/backgrounds/navy_blue.png -------------------------------------------------------------------------------- /web/img/badges/100-percent-optimized/blue-oval-100-percent-optimized.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/web/img/badges/100-percent-optimized/blue-oval-100-percent-optimized.png -------------------------------------------------------------------------------- /web/img/badges/100-percent-optimized/brown-100-percent-optimized.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/web/img/badges/100-percent-optimized/brown-100-percent-optimized.png -------------------------------------------------------------------------------- /web/img/badges/100-percent-optimized/brown-leather-100-percent-optimized.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/web/img/badges/100-percent-optimized/brown-leather-100-percent-optimized.png -------------------------------------------------------------------------------- /web/img/badges/100-percent-optimized/excellent-article-award.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/web/img/badges/100-percent-optimized/excellent-article-award.png -------------------------------------------------------------------------------- /web/img/badges/100-percent-optimized/excellent-content-award.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/web/img/badges/100-percent-optimized/excellent-content-award.png -------------------------------------------------------------------------------- /web/img/badges/100-percent-optimized/excellent-content.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/web/img/badges/100-percent-optimized/excellent-content.png -------------------------------------------------------------------------------- /web/img/badges/100-percent-optimized/fantastic-article-award.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/web/img/badges/100-percent-optimized/fantastic-article-award.png -------------------------------------------------------------------------------- /web/img/badges/100-percent-optimized/fantastic-content-badge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/web/img/badges/100-percent-optimized/fantastic-content-badge.png -------------------------------------------------------------------------------- /web/img/badges/100-percent-optimized/green-100-percent-optimized.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/web/img/badges/100-percent-optimized/green-100-percent-optimized.png -------------------------------------------------------------------------------- /web/img/badges/100-percent-optimized/optimal-article.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/web/img/badges/100-percent-optimized/optimal-article.png -------------------------------------------------------------------------------- /web/img/badges/100-percent-optimized/optimized-content-badge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/web/img/badges/100-percent-optimized/optimized-content-badge.png -------------------------------------------------------------------------------- /web/img/badges/100-percent-optimized/optimized.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/web/img/badges/100-percent-optimized/optimized.png -------------------------------------------------------------------------------- /web/img/badges/100-percent-optimized/perfect-article-award.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/web/img/badges/100-percent-optimized/perfect-article-award.png -------------------------------------------------------------------------------- /web/img/badges/100-percent-optimized/perfect-article.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/web/img/badges/100-percent-optimized/perfect-article.png -------------------------------------------------------------------------------- /web/img/badges/100-percent-optimized/seo-optimized-article.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/web/img/badges/100-percent-optimized/seo-optimized-article.png -------------------------------------------------------------------------------- /web/img/badges/100-percent-optimized/silver-circle-100-percent-optimized.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/web/img/badges/100-percent-optimized/silver-circle-100-percent-optimized.png -------------------------------------------------------------------------------- /web/img/badges/100-percent-optimized/super-article-award.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/web/img/badges/100-percent-optimized/super-article-award.png -------------------------------------------------------------------------------- /web/img/badges/100-percent-optimized/super-content.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/web/img/badges/100-percent-optimized/super-content.png -------------------------------------------------------------------------------- /web/img/badges/100-percent-optimized/superb-content-award.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/web/img/badges/100-percent-optimized/superb-content-award.png -------------------------------------------------------------------------------- /web/img/badges/100-percent-optimized/well-done-article-award.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/web/img/badges/100-percent-optimized/well-done-article-award.png -------------------------------------------------------------------------------- /web/img/badges/100-percent-optimized/winning-article-badge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/web/img/badges/100-percent-optimized/winning-article-badge.png -------------------------------------------------------------------------------- /web/img/badges/100-percent-optimized/winning-content-badge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/web/img/badges/100-percent-optimized/winning-content-badge.png -------------------------------------------------------------------------------- /web/img/badges/100-percent-optimized/winning-writing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/web/img/badges/100-percent-optimized/winning-writing.png -------------------------------------------------------------------------------- /web/img/badges/categories/arts-entertainment/art-entertainment-award.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/web/img/badges/categories/arts-entertainment/art-entertainment-award.png -------------------------------------------------------------------------------- /web/img/badges/categories/business/business-article-award.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/web/img/badges/categories/business/business-article-award.png -------------------------------------------------------------------------------- /web/img/badges/categories/computer-internet/computers-internet-award.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/web/img/badges/categories/computer-internet/computers-internet-award.png -------------------------------------------------------------------------------- /web/img/badges/categories/culture-politics/culture-politics-badge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/web/img/badges/categories/culture-politics/culture-politics-badge.png -------------------------------------------------------------------------------- /web/img/badges/categories/gaming/gaming-award.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/web/img/badges/categories/gaming/gaming-award.png -------------------------------------------------------------------------------- /web/img/badges/categories/health/health-content-award.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/web/img/badges/categories/health/health-content-award.png -------------------------------------------------------------------------------- /web/img/badges/categories/law-crime/law-and-crime-badge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/web/img/badges/categories/law-crime/law-and-crime-badge.png -------------------------------------------------------------------------------- /web/img/badges/categories/recreation/recreation-badge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/web/img/badges/categories/recreation/recreation-badge.png -------------------------------------------------------------------------------- /web/img/badges/categories/religion/religion-badge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/web/img/badges/categories/religion/religion-badge.png -------------------------------------------------------------------------------- /web/img/badges/categories/science-technology/science.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/web/img/badges/categories/science-technology/science.png -------------------------------------------------------------------------------- /web/img/badges/categories/sports/sports-award.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/web/img/badges/categories/sports/sports-award.png -------------------------------------------------------------------------------- /web/img/badges/categories/unknown/default-article-badge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/web/img/badges/categories/unknown/default-article-badge.png -------------------------------------------------------------------------------- /web/img/badges/categories/weather/weather-badge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/web/img/badges/categories/weather/weather-badge.png -------------------------------------------------------------------------------- /web/img/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/web/img/favicon.png -------------------------------------------------------------------------------- /web/img/loading.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackproser/articleoptimizer/d6b7b82c24f178524e1f621bd6f4c4a189602370/web/img/loading.gif -------------------------------------------------------------------------------- /web/js/clipboard.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * clipboard.js v1.5.12 3 | * https://zenorocha.github.io/clipboard.js 4 | * 5 | * Licensed MIT © Zeno Rocha 6 | */ 7 | !function(t){if("object"==typeof exports&&"undefined"!=typeof module)module.exports=t();else if("function"==typeof define&&define.amd)define([],t);else{var e;e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:this,e.Clipboard=t()}}(function(){var t,e,n;return function t(e,n,o){function i(a,c){if(!n[a]){if(!e[a]){var s="function"==typeof require&&require;if(!c&&s)return s(a,!0);if(r)return r(a,!0);var l=new Error("Cannot find module '"+a+"'");throw l.code="MODULE_NOT_FOUND",l}var u=n[a]={exports:{}};e[a][0].call(u.exports,function(t){var n=e[a][1][t];return i(n?n:t)},u,u.exports,t,e,n,o)}return n[a].exports}for(var r="function"==typeof require&&require,a=0;ao;o++)n[o].fn.apply(n[o].ctx,e);return this},off:function(t,e){var n=this.e||(this.e={}),o=n[t],i=[];if(o&&e)for(var r=0,a=o.length;a>r;r++)o[r].fn!==e&&o[r].fn._!==e&&i.push(o[r]);return i.length?n[t]=i:delete n[t],this}},e.exports=o},{}],8:[function(e,n,o){!function(i,r){if("function"==typeof t&&t.amd)t(["module","select"],r);else if("undefined"!=typeof o)r(n,e("select"));else{var a={exports:{}};r(a,i.select),i.clipboardAction=a.exports}}(this,function(t,e){"use strict";function n(t){return t&&t.__esModule?t:{"default":t}}function o(t,e){if(!(t instanceof e))throw new TypeError("Cannot call a class as a function")}var i=n(e),r="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol?"symbol":typeof t},a=function(){function t(t,e){for(var n=0;n Copied!'); 20 | setTimeout(function() { 21 | $(button).html(' Click to Copy'); 22 | }, 900); 23 | }); 24 | 25 | //On copy bitly url success, display visual confirmation 26 | clipboard.on('success', function(e) { 27 | $('#copy-link-button').tooltip('show'); 28 | setTimeout(function() { 29 | $('#copy-link-button').tooltip('hide'); 30 | }, 1200); 31 | }); 32 | 33 | //On image hover, display the copy to clipboard overlay 34 | $(ctrl.imageThumbnail).on('mouseenter', function() { 35 | $(this).find('.overlay').fadeIn(400); 36 | }).on('mouseleave', function() { 37 | $(this).find('.overlay').stop().fadeOut(100); 38 | }); 39 | 40 | //Bind email share button to show report emailing modal 41 | $(ctrl.emailReportButton).on('click', function() { 42 | $(ctrl.emailReportModal).modal('show'); 43 | }); 44 | 45 | //Set Facebook share button to current report url 46 | $(ctrl.facebookShareButton).attr('href', $(ctrl.facebookShareButton).attr('href') + window.location.href); 47 | 48 | //Process requests to email the report to someone 49 | $(ctrl.sendReportButton).on('click', function(e) { 50 | e.preventDefault(); 51 | 52 | function showSuccessAlert(msg) { 53 | $(ctrl.sendReportSuccessAlert).html(msg).removeClass('hidden').show(); 54 | resetSendReportModal(); 55 | } 56 | 57 | function showErrorAlert(msg) { 58 | $(ctrl.sendReportErrorAlert).html(msg).removeClass('hidden').show(); 59 | resetSendReportModal(); 60 | } 61 | 62 | function resetSendReportModal() { 63 | setTimeout(function() { 64 | $(ctrl.sendReportSuccessAlert).html('').hide(); 65 | $(ctrl.sendReportErrorAlert).html('').hide(); 66 | }, 1200); 67 | } 68 | 69 | var values = {}; 70 | $.each($(ctrl.sendReportForm).serializeArray(), function(i, field) { 71 | values[field.name] = field.value; 72 | }); 73 | 74 | values.uri = window.location.href; 75 | 76 | var requestOptions = { 77 | url: '/email-report', 78 | method: 'POST', 79 | data: values, 80 | success: function(data) { 81 | showSuccessAlert(data); 82 | setTimeout(function() { 83 | ctrl.emailReportModal.modal('hide'); 84 | }, 1200); 85 | }, 86 | error: function(error) { 87 | showErrorAlert(error); 88 | } 89 | } 90 | $.ajax(requestOptions); 91 | }); 92 | } 93 | 94 | function showShortlinkPending() { 95 | $(ctrl.copyLinkButton).html(' Linking..'); 96 | } 97 | 98 | function updateBitlyUrl(url) { 99 | $(ctrl.copyLinkButton).html('Copy Link'); 100 | $('#report-link-holder').val(url); 101 | } 102 | 103 | function renderBitlyError(error) { 104 | $(ctrl.copyLinkButton).html('Copy Link'); 105 | $(ctrl.bitlyErrorAlert).html('Sorry, there was an issue obtaining a shortlink.') 106 | .removeClass('hidden') 107 | .show(); 108 | } 109 | 110 | function getBitlyLink() { 111 | 112 | showShortlinkPending(); 113 | 114 | var url = window.location.href; 115 | var requestOptions = { 116 | method: 'POST', 117 | url: '/bitly-shorten', 118 | data: { 119 | url: url 120 | }, 121 | success: function(data) { 122 | if (data && typeof data === "string") { 123 | var response = JSON.parse(data); 124 | } 125 | if (response.status_code && response.status_code === 200 && response.data.url) { 126 | updateBitlyUrl(response.data.url); 127 | addBitlyLinkToTweetPrefill(response.data.url); 128 | } else { 129 | renderBitlyError(); 130 | } 131 | }, 132 | error: function(error) { 133 | this.renderBitlyError(); 134 | } 135 | }; 136 | $.ajax(requestOptions); 137 | }; 138 | 139 | //Add the bitly link to the pre-formatted tweet 140 | function addBitlyLinkToTweetPrefill(url) { 141 | if (typeof url === "string" && url != null) { 142 | var 143 | originalHref = $(ctrl.tweetButton).attr('href'), 144 | newHref = originalHref += ' ' + url; 145 | $(ctrl.tweetButton).attr('href', newHref); 146 | } 147 | } 148 | 149 | getBitlyLink(); 150 | 151 | getFlickrImages(); 152 | 153 | return { 154 | init: init 155 | } 156 | 157 | })(); -------------------------------------------------------------------------------- /web/js/report.js: -------------------------------------------------------------------------------- 1 | $(document).ready(function() { 2 | 3 | var controls = {}; 4 | 5 | controls.copyLinkButton = $('#copy-link-button'); 6 | controls.emailReportButton = $('#email-report-button'); 7 | controls.emailReportModal = $('#email-report-modal'); 8 | controls.sendReportButton = $('#form_sendReport'); 9 | controls.sendReportForm = $('form'); 10 | controls.sendReportSuccessAlert = $(controls.emailReportModal).find('.alert.alert-success'); 11 | controls.sendReportErrorAlert = $(controls.emailReportModal).find('.alert.alert-danger'); 12 | controls.facebookShareButton = $('#facebook-share-button'); 13 | controls.imageThumbnail = $('.img-thumbnail'); 14 | controls.bitlyErrorAlert = $('#bitly-error'); 15 | controls.tweetButton = $('#tweet-button'); 16 | 17 | ReportClient.init(controls); 18 | 19 | }); 20 | 21 | -------------------------------------------------------------------------------- /web/robots.txt: -------------------------------------------------------------------------------- 1 | # www.robotstxt.org/ 2 | # www.google.com/support/webmasters/bin/answer.py?hl=en&answer=156449 3 | 4 | User-agent: * 5 | Disallow: 6 | --------------------------------------------------------------------------------