23 Commits
Author SHA1 Message Date
krzysiej 50305d5b5d Merge pull request 'Add twig extra bundle to use currency formatter filter. Add custom twig method to display number of active promos. Remove old index.php file.' (#5) from feature/display-stock into master
Reviewed-on: #5
2024-11-17 16:38:32 +01:00
Krzysztof Płaczek f963dcd4c8 Display stock in product info in price table. 2024-11-17 16:38:22 +01:00
Krzysztof Płaczek 48ee68f71a Add twig extra bundle to use currency formatter filter. Add custom twig method to display number of active promos. Remove old index.php file. 2024-11-17 12:33:40 +01:00
krzysiej f1c0ec6b3a Merge pull request 'Add dotenv package and .env file' (#3) from feature/dotenvfile into master
Reviewed-on: #3
2024-11-15 09:32:07 +01:00
krzysiej 0168290340 Merge pull request 'Hide code column on mobile' (#4) from feature/hide-code-column-on-mobile into master
Reviewed-on: #4
2024-10-26 11:58:30 +02:00
Krzysztof Płaczek f451bfca0e Hide code column on mobile 2024-10-26 11:56:49 +02:00
Krzysztof Płaczek c280abe484 Fix number of columns on product list. 2024-10-20 15:51:05 +02:00
Krzysztof Płaczek 7053716924 Add dotenv package and .env file 2024-10-20 12:57:16 +02:00
krzysiej a911d614e0 Merge pull request 'feature/micro-kernel-trait' (#2) from feature/micro-kernel-trait into master
Reviewed-on: #2
2024-10-19 16:38:30 +02:00
Krzysztof Płaczek 58562746a3 Thrown product not found exception. 2024-10-19 16:37:41 +02:00
Krzysztof Płaczek 5ee1bba812 Use MicroKernelTrait to bootstrap application. Rewrote routes, controllers and structure of application a bit. Including use of twig. 2024-10-19 16:01:54 +02:00
krzysiej 3d47726a81 Merge pull request 'Promo endpoint that display only products on sale.' (#1) from feature/promo-branch into master
Reviewed-on: #1
2024-10-18 11:28:01 +02:00
Krzysztof Płaczek fee7495cfe Promo endpoint that display only products on sale. 2024-10-18 11:24:46 +02:00
Krzysztof Płaczek 53b6d33ab9 Display price discount on items list. 2024-10-17 10:22:32 +02:00
Krzysztof Płaczek 3cc5d73758 Autostart docker app unless stopped on purpose. Update README.md and describe cron scheduling. Add chart to product view and sort product prices. 2024-10-15 20:20:57 +02:00
Krzysztof Płaczek d043e8efb1 Change url naming convention. Update README.md 2024-10-14 14:04:15 +02:00
Krzysztof Płaczek 2b595c1403 Update star route. 2024-10-14 09:34:23 +02:00
Krzysztof Płaczek f2e6cba2f5 Split browser.php to commands with routes. 2024-10-13 20:44:14 +02:00
Krzysztof Płaczek e13a0acce1 Add parameter recreate to migration command. 2024-10-13 20:33:30 +02:00
Krzysztof Płaczek 77732689d8 Remove product property agilityID. 2024-10-13 12:46:56 +02:00
Krzysztof Płaczek 22cc3f699b Change port to 9001 port allocation collision. 2024-10-13 12:40:26 +02:00
Krzysztof Płaczek a1b6d65137 Change port to avoid sentry port allocation collision. 2024-10-13 12:39:19 +02:00
Krzysztof Płaczek 7ee8df32f9 Add screenshot 2024-10-12 18:41:49 +02:00
27 changed files with 2381 additions and 166 deletions
+2
View File
@@ -0,0 +1,2 @@
APP_ENV=prod
APP_DEBUG=false
+3 -1
View File
@@ -1,3 +1,5 @@
/vendor/
.idea
database.sqlite
database.sqlite
var/cache/
.env.local
+23 -3
View File
@@ -5,6 +5,26 @@
1. Clone repository using `git clone https://git.techtube.pl/krzysiej/ryobi-crawler.git`
2. Cd into project directory `cd ryobi-crawler`
3. Build and start docker container `docker compose up -d`
4. Run `docker compose exec php-app php index.php app:migrate` file to create `database.sqlite` and create tables.
5. Run `docker compose exec php-app php index.php app:scrape` command to scrape all the products from the ryobi website.
6. Access web interface using `localhost:9000` address in web browser.
4. Run `docker compose exec php-app php console.php app:migrate` file to create `database.sqlite` and create tables.
5. Run `docker compose exec php-app php console.php app:scrape` command to scrape all the products from the ryobi website.
6. Access web interface using `localhost:9001` address in web browser.
## Update project
1. Cd into project directory
2. Run `git pull`
3. Refresh cache on production by removing cache directory: `rm -rf var/cache`
4. Start and build image in one go with command: `docker compose up -d --build --force-recreate`
## Running Cron
For now only way of running `app:scrape` command on schedule is to use host crontab.
1. Run `crontab -e` command to edit host crontab job file
2. Add a new line with e.g. line like this `0 1 * * * cd /var/project/directory/ && docker compose exec php-app php console.php app:scrape`
3. Save and exit file editor. Cron will execute `app:scrape` once per day.
## Screenshots
### Main screen of the web view
![Main screen of the web view](screenshot1.png)
-42
View File
@@ -1,42 +0,0 @@
<?php
include_once 'vendor/autoload.php';
use Illuminate\Database\Capsule\Manager as Capsule;
use Krzysiej\RyobiCrawler\Models\Product;
use Twig\{Environment, Loader\FilesystemLoader};
if (!file_exists('database.sqlite')) {
exit('Database file <code>database.sqlite</code> missing. Run docker compose <blockquote>docker compose exec php-app php index.php app:migrate</blockquote> to create it.');
}
$capsule = new Capsule;
$capsule->addConnection(['driver' => 'sqlite', 'database' => __DIR__ . '/database.sqlite']);
$capsule->setAsGlobal();
$capsule->bootEloquent();
$loader = new FilesystemLoader(__DIR__ . '/src/templates');
$twig = new Environment($loader);
if (isset($_GET['product_id'])) {
$template = 'product.html.twig';
$product = Product::with('price')->find($_GET['product_id']);
}
if (isset($_GET['category'])) {
$template = 'productList.html.twig';
$products = Product::with('price')->selectRaw('products.*')->fromRaw('products, json_each(products.categories)')->whereRaw('json_each.value = ?', [$_GET['category']])
->orderByDesc('starred')->orderByDesc('created_by')->get();
}
if (isset($_GET['search'])) {
$template = 'productList.html.twig';
$products = Product::with('price')
->orWhere([['name', 'like', "%{$_GET['search']}%"]])
->orWhere([['subTitle', 'like', "%{$_GET['search']}%"]])->get();
}
if (isset($_GET['star'])) {
Product::find($_GET['star'])->toggleStarred()->save();
header('Location: '.$_SERVER['HTTP_REFERER']);
}
if (empty($_GET)) {
$template = 'productList.html.twig';
$products = Product::with('price')->orderByDesc('starred')->orderByDesc('created_by')->get();
}
$twig->display($template, ['products' => $products ?? [], 'product' => $product ?? [], 'search' => $_GET['search'] ?? '']);
+9 -2
View File
@@ -4,8 +4,15 @@
"symfony/var-dumper": "^7.0",
"illuminate/database": "11.26.0.0",
"ext-json": "*",
"twig/twig": "3.14.0.0",
"symfony/console": "^7.0"
"symfony/console": "^7.0",
"symfony/routing": "^7.1",
"laravel/serializable-closure": "^1.3",
"symfony/http-kernel": "^7.1",
"symfony/framework-bundle": "^7.1",
"symfony/twig-bundle": "^7.1",
"symfony/dotenv": "^7.1",
"twig/intl-extra": "^3.13",
"twig/extra-bundle": "^3.13"
},
"autoload": {
"psr-4": {
Generated
+1886 -1
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
<?php
include_once 'vendor/autoload.php';
use Krzysiej\RyobiCrawler\Command\Migrate;
use Krzysiej\RyobiCrawler\Command\ScrapeWebsite;
use Symfony\Component\Console\Application;
if (php_sapi_name() !== 'cli') {
header('Location: browser.php');
echo 'Execute this script in cli only';
exit;
}
$application = new Application('Ryobi website scraper application', '1.1.1');
$application->add(new ScrapeWebsite());
$application->add(new Migrate());
$application->run();
+2 -1
View File
@@ -1,9 +1,10 @@
services:
php-app:
restart: unless-stopped
build: .
volumes:
- .:/usr/src/app
command: >
sh -c "composer install && php -S 0.0.0.0:8000 -t /usr/src/app"
ports:
- "9000:8000"
- "9001:8000"
+43 -12
View File
@@ -1,19 +1,50 @@
<?php
include_once 'vendor/autoload.php';
use Krzysiej\RyobiCrawler\Twig\AppExtension;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
use Symfony\Component\Dotenv\Dotenv;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator;
use Krzysiej\RyobiCrawler\Command\Migrate;
use Krzysiej\RyobiCrawler\Command\ScrapeWebsite;
require 'vendor/autoload.php';
use Symfony\Component\Console\Application;
class Kernel extends BaseKernel
{
use MicroKernelTrait;
if (php_sapi_name() !== 'cli') {
header('Location: browser.php');
echo 'Execute this script in cli only';
exit;
public function registerBundles(): iterable
{
return [
new Symfony\Bundle\FrameworkBundle\FrameworkBundle(),
new Symfony\Bundle\TwigBundle\TwigBundle(),
new Twig\Extra\TwigExtraBundle\TwigExtraBundle(),
];
}
protected function configureContainer(ContainerConfigurator $container): void
{
$container->extension('framework', [
'secret' => 'S0ME_SECRET'
]);
$container->services()
->load('Krzysiej\\RyobiCrawler\\Controller\\', __DIR__.'/src/Controller/*')
->autowire()
->autoconfigure()
;
$container->services()->set(AppExtension::class)->tag('twig.extension');
}
protected function configureRoutes(RoutingConfigurator $routes): void
{
$routes->import(__DIR__.'/src/Controller/', 'attribute');
}
}
(new Dotenv())->bootEnv(__DIR__.'/.env');
$application = new Application('Ryobi website scraper application', '1.1.1');
$application->add(new ScrapeWebsite());
$application->add(new Migrate());
$application->run();
$kernel = new Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG']);
$request = Request::createFromGlobals();
$response = $kernel->handle($request);
$response->send();
$kernel->terminate($request, $response);
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

+12 -6
View File
@@ -9,13 +9,25 @@ use Illuminate\Database\Schema\Blueprint;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(name: 'app:migrate', description: 'Create database and rum migrations')]
class Migrate extends Command
{
private const RECREATE_OPTION = 'recreate';
protected function configure(): void
{
$this->addOption(self::RECREATE_OPTION, null, InputOption::VALUE_OPTIONAL, 'Recreate database file event if exist and has data.');
}
public function execute(InputInterface $input, OutputInterface $output): int
{
if (true === $input->hasOption(self::RECREATE_OPTION)) {
unlink(__DIR__ . '/../../database.sqlite');
//sleep(5);
}
touch(__DIR__ . '/../../database.sqlite');
$capsule = new Capsule;
$capsule->addConnection([
@@ -24,10 +36,6 @@ class Migrate extends Command
]);
$capsule->setAsGlobal();
$capsule->bootEloquent();
}
public function execute(InputInterface $input, OutputInterface $output): int
{
$this->createProductsTable();
$this->createPricesTable();
$this->createStocksTable();
@@ -42,9 +50,7 @@ class Migrate extends Command
$table->increments('id');
$table->string('name');
$table->integer('skuID')->unique();
$table->integer('agilityID');
$table->integer('availableQuantity');
$table->integer('stock');
$table->json('categories');
$table->string('image');
$table->string('subTitle');
+7 -5
View File
@@ -40,6 +40,7 @@ class ScrapeWebsite extends Command
$products = $this->getProducts();
$progress->setMaxSteps(count($products));
foreach ($products as $product) {
//dd($product);
$this->saveProduct($product);
$progress->advance();
}
@@ -68,7 +69,7 @@ class ScrapeWebsite extends Command
$products = array_merge($products, $responseObject->products);
$page++;
$canLoadMore = $responseObject->canLoadMore;
} catch (GuzzleException $e) {
} catch (GuzzleException) {
return $products;
}
} while ($canLoadMore);
@@ -80,11 +81,11 @@ class ScrapeWebsite extends Command
{
/** @var Product $productModel */
$productModel = Product::firstOrNew(['skuID' => $product->skuID]);
$productModel->skuID = $product->skuID;
$productModel->name = $product->name;
$productModel->availableQuantity = $product->availableQuantity;
$productModel->stock = $product->stock;
$productModel->categories = json_encode($product->categories);
$productModel->categories = $product->categories;
$productModel->image = $product->image;
$productModel->subTitle = $product->subTitle;
$productModel->variantCode = $product->variantCode;
@@ -92,7 +93,8 @@ class ScrapeWebsite extends Command
$productModel->url = $product->url;
$productModel->save();
$priceExists = $productModel->price()->whereRaw("strftime('%Y-%m-%d', created_at) = ?", [date('Y-m-d')])->exists();
if (!$priceExists) {
if (false === $priceExists) {
$price = new Price();
$price->price = $product->productPrice;
$price->productStandardPrice = $product->productStandardPrice;
@@ -100,7 +102,7 @@ class ScrapeWebsite extends Command
$productModel->price()->save($price);
}
$stockExist = $productModel->stock()->whereRaw("strftime('%Y-%m-%d', created_at) = ?", [date('Y-m-d')])->exists();
if (!$stockExist) {
if (false === $stockExist) {
$stock = new Stock();
$stock->availableQuantity = $product->availableQuantity;
$stock->stock = $product->stock;
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace Krzysiej\RyobiCrawler\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Twig\Environment;
use Illuminate\Database\Capsule\Manager as Capsule;
class BaseController extends AbstractController
{
protected Environment $twig;
public function __construct()
{
$capsule = new Capsule;
$capsule->addConnection(['driver' => 'sqlite', 'database' => __DIR__ . '/../../database.sqlite']);
$capsule->setAsGlobal();
$capsule->bootEloquent();
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace Krzysiej\RyobiCrawler\Controller;
use Krzysiej\RyobiCrawler\Models\Product;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class CategoryController extends BaseController
{
#[Route('/category/{category}', name: 'app_category')]
public function __invoke(string $category): Response
{
$products = Product::with('price')
->selectRaw('products.*')
->fromRaw('products, json_each(products.categories)')
->whereRaw('json_each.value = ?', [$category])
->orderByDesc('starred')
->orderByDesc('created_by')
->get();
return $this->render('productList.html.twig', ['products' => $products]);
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace Krzysiej\RyobiCrawler\Controller;
use Krzysiej\RyobiCrawler\Models\Product;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class IndexController extends BaseController
{
#[Route('/', name: 'app_home')]
public function __invoke(): Response
{
$products = Product::with('price')->orderByDesc('starred')->orderByDesc('created_by')->get();
return $this->render('productList.html.twig', ['products' => $products]);
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace Krzysiej\RyobiCrawler\Controller;
use Krzysiej\RyobiCrawler\Models\Product;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Twig\Error\LoaderError;
use Twig\Error\RuntimeError;
use Twig\Error\SyntaxError;
final class ProductController extends BaseController
{
/**
* @throws SyntaxError
* @throws RuntimeError
* @throws LoaderError
*/
#[Route('/product/{productId<\d+>}', name: 'app_product')]
public function __invoke(int $productId): Response
{
$product = Product::with([
'price' => fn($query) => $query->orderBy('created_at', 'desc'),
'stock' => fn($query) => $query->orderBy('created_at', 'desc'),
])->find($productId);
if(null === $product) {
throw $this->createNotFoundException('Product not found');
}
$priceList = $product->price()->pluck('price')->implode(',');
$priceDates = $product->price()->pluck('created_at')->map(fn($date) => $date->format('Y-m-d'))->implode("','");
return $this->render('product.html.twig', ['product' => $product, 'price_list' => $priceList, 'price_dates' => $priceDates]);
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace Krzysiej\RyobiCrawler\Controller;
use Illuminate\Database\Eloquent\Builder;
use Krzysiej\RyobiCrawler\Models\Product;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class PromosController extends BaseController
{
#[Route('/promos', name: 'app_promos')]
public function __invoke(): Response
{
$products = Product::whereHas('currentPrice', fn(Builder $query) => $query->whereColumn('price', '<', 'productStandardPrice'))->with(['currentPrice'])->get();
return $this->render('productList.html.twig', ['products' => $products]);
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace Krzysiej\RyobiCrawler\Controller;
use Krzysiej\RyobiCrawler\Models\Product;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class SearchController extends BaseController
{
#[Route('/search', name: 'app_search')]
public function __invoke(Request $request): Response
{
$search = $request->query->get('search');
//dd();
$products = Product::with('price')
->orWhere([['name', 'like', "%$search%"]])
->orWhere([['subTitle', 'like', "%$search%"]])->get();
return $this->render('productList.html.twig', ['products' => $products ?? [], 'search' => $search]);
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace Krzysiej\RyobiCrawler\Controller;
use Krzysiej\RyobiCrawler\Models\Product;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class StarController extends BaseController
{
#[Route('/star/{productId<\d+>}', name: 'app_star')]
public function __invoke(int $productId, Request $request): Response
{
Product::find($productId)->toggleStarred()->save();
return $this->redirect($request->headers->get('referer'));
}
}
+16 -4
View File
@@ -5,20 +5,19 @@ namespace Krzysiej\RyobiCrawler\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
/**
* @property integer $skuID
* @property integer $agilityID
* @property string $name
* @property integer $availableQuantity
* @property integer $stock
* @property string[] $categories
* @property string $image
* @property string $subTitle
* @property string $variantCode
* @property string $modelCode
* @property string $url
* @property boolean $starred
* @property int $starred
*/
class Product extends Model
{
@@ -29,6 +28,17 @@ class Product extends Model
{
return $this->hasMany(Price::class);
}
public function isStarred(): bool
{
return (bool) $this->starred;
}
public function currentPrice(): HasOne
{
return $this->hasOne(Price::class)->latestOfMany('created_at');
}
public function stock(): HasMany
{
return $this->hasMany(Stock::class);
@@ -37,13 +47,15 @@ class Product extends Model
public function toggleStarred(): self
{
$this->starred = !$this->starred;
return $this;
}
protected function categories(): Attribute
public function categories(): Attribute
{
return Attribute::make(
get: fn(string $value) => array_reverse(json_decode($value, 1)),
set: fn(array $value) => json_encode($value),
);
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace Krzysiej\RyobiCrawler\Twig;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Krzysiej\RyobiCrawler\Models\Price;
use Krzysiej\RyobiCrawler\Models\Product;
use Krzysiej\RyobiCrawler\Models\Stock;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
use Twig\TwigFunction;
class AppExtension extends AbstractExtension
{
public function getFunctions(): array
{
return [
new TwigFunction('promosCount', [$this, 'promosCount']),
];
}
public function getFilters(): array
{
return [
new TwigFilter('findByCreatedAtDate', [$this, 'findByCreatedAtDate']),
];
}
public function promosCount(): int
{
return Product::whereHas('currentPrice', fn(Builder $query) => $query->whereColumn('price', '<', 'productStandardPrice'))->with(['currentPrice'])->count();
}
public function findByCreatedAtDate(Collection $items, string $date): Stock|Price|null
{
return $items->first(fn($item) => str_starts_with($item->created_at, $date));
}
}
-42
View File
@@ -1,42 +0,0 @@
{% extends "template.html.twig" %}
{% block content %}
<table class='table table-hover'>
<tr>
<td><img src='{{ product.image }}&width=150' class='img-fluid' alt='{{ product.name }}'/></td>
<td><a href='?product_id={{ product.id }}'>{{ product.name }}</a></td>
<td>{{ product.subTitle }}</td>
<td>
<ul class='nav'>
{% for category in product.categories %}
<li class="nav-item"><a class="nav-link" href="?category={{ category }}"> {{ category }} </a></li>
{% endfor %}
</ul>
</td>
<td><a href='https://pl.ryobitools.eu/{{ product.url }}'>link</a></td>
</tr>
<tr>
<td colspan="4">
<table class='table table-hover table-sm mb-0'>
<thead>
<tr>
<th>price</th>
<th>lowest product price in 30 days</th>
<th colspan='2'>standard price</th>
</tr>
</thead>
{% for price in product.price %}
<tr>
<td>{{ price.price }}</td>
<td>{{ price.lowestProductPrice30Days }}</td>
<td>{{ price.productStandardPrice }}</td>
<td>{{ price.created_at }}</td>
</tr>
{% endfor %}
</table>
</td>
</tr>
</table>
{% endblock %}
-23
View File
@@ -1,23 +0,0 @@
{% extends "template.html.twig" %}
{% block content %}
<table class='table table-hover'>
{% for product in products %}
<tr>
<td class="align-middle font-weight-bold h3"><a class="text-warning text-decoration-none" href="?star={{ product.id }}">{% if product.starred %}{% else %}{% endif %}</a></td>
<td><img src='{{ product.image }}&width=70' class='img-fluid' alt='{{ product.name }}'/></td>
<td class="align-middle"><a href='?product_id={{ product.id }}'>{{ product.name }}</a></td>
<td class="align-middle">{{ product.subTitle }}</td>
<td class="align-middle">
<ul class='nav'>
{% for category in product.categories %}
<li class="nav-item"><a class="nav-link" href="?category={{ category }}"> {{ category }} </a></li>
{% endfor %}
</ul>
</td>
<td class="align-middle"><a href='https://pl.ryobitools.eu/{{ product.url }}'>link</a></td>
<td class="align-middle">{{ product.price.last.price }}</td>
</tr>
{% endfor %}
</table>
{% endblock %}
-24
View File
@@ -1,24 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Ryobi crawler</title>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"
crossorigin="anonymous"/>
</head>
<body>
<nav class="navbar sticky-top bg-body-tertiary border-bottom border-secondary border-1">
<div class="container-fluid">
<a class="navbar-brand" href="/browser.php">Crawler</a>
<form class="d-flex w-50" role="search">
<input class="form-control me-2" type="search" name="search" placeholder="Search term eg. 36v or RCS18X" value="{{ search }}" aria-label="Search">
<button class="btn btn-outline-success" type="submit">Search</button>
</form>
</div>
</nav>
{% block content %}{% endblock %}
</body>
</html>
+93
View File
@@ -0,0 +1,93 @@
{% extends "template.html.twig" %}
{% block content %}
<table class='table table-hover'>
<tr>
<td class="align-middle font-weight-bold h3"><a class="text-warning text-decoration-none"
href="{{ path('app_star', {'productId': product.id}) }}">{% if product.starred %}{% else %}{% endif %}</a></td>
<td><img src='{{ product.image }}&width=150' class='border rounded p-1' alt='{{ product.name }}'/></td>
<td><a href='{{ path('app_product', {'productId': product.id}) }}' class="text-decoration-none">{{ product.name }}</a></td>
<td>{{ product.subTitle }}</td>
<td>
<ul class='nav'>
{% for category in product.categories %}
<li class="nav-item"><a class="nav-link" href="{{ path('app_category', {'category': category}) }}"> {{ category }} </a></li>
{% endfor %}
</ul>
</td>
<td><a href='https://pl.ryobitools.eu/{{ product.url }}'>link</a></td>
</tr>
<tr>
<td colspan="4">
<div style="width: 800px;">
<canvas id="price"></canvas>
</div>
</td>
</tr>
<tr>
<td colspan="4">
<table class='table table-hover table-sm mb-0'>
<thead>
<tr>
<th>price</th>
<th>lowest product price in 30 days</th>
<th colspan='2'>standard price</th>
<th>Stock</th>
</tr>
</thead>
{% for price in product.price %}
<tr>
<td>{{ price.price | format_currency('PLN', {}, 'pl') }}</td>
<td>{{ price.lowestProductPrice30Days | format_currency('PLN', {}, 'pl') }}</td>
<td>{{ price.productStandardPrice | format_currency('PLN', {}, 'pl') }}</td>
<td>{{ price.created_at }}</td>
<td>{{ (product.stock | findByCreatedAtDate(price.created_at | slice(0,10))).stock ?? '' }}</td>
</tr>
{% endfor %}
</table>
</td>
</tr>
</table>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
const ctx = document.getElementById('price');
new Chart(ctx, {
type: 'line',
data: {
labels: ['{{ price_dates|raw }}'],
datasets: [{
label: 'Price (PLN)',
data: [{{ price_list }}],
borderWidth: 1
}]
},
options: {
animation: false,
plugins: {
title: {
display: true,
text: 'Price in time'
},
},
scales: {
x: {
title: {
display: true,
text: 'Date'
}
},
y: {
beginAtZero: true,
title: {
display: true,
text: 'Price'
}
}
}
}
});
</script>
{% endblock %}
+36
View File
@@ -0,0 +1,36 @@
{% extends "template.html.twig" %}
{% block content %}
<table class='table table-hover'>
<thead>
<tr>
<th></th>
<th></th>
<th>Name</th>
<th class="d-none d-md-table-cell">Code</th>
<th>Categories</th>
<th></th>
<th>Price</th>
<th></th>
</tr>
</thead>
{% for product in products %}
<tr>
<td class="align-middle font-weight-bold h3"><a class="text-warning text-decoration-none" href="{{ path('app_star', {'productId': product.id}) }}">{% if product.starred == true %}{% else %}{% endif %}</a></td>
<td class="align-middle" style="width: 120px;"><img src='{{ product.image }}&width=70' class='border rounded p-1' alt='{{ product.name }}'/></td>
<td class="align-middle"><a href='{{ path('app_product', {'productId': product.id}) }}' class="text-decoration-none">{{ product.name }}</a></td>
<td class="align-middle d-none d-md-table-cell">{{ product.subTitle }}</td>
<td class="align-middle">
<ul class='nav'>
{% for category in product.categories %}
<li class="nav-item"><a class="nav-link" href="{{ path('app_category', {'category': category}) }}"> {{ category }} </a></li>
{% endfor %}
</ul>
</td>
<td class="align-middle"><a href='https://pl.ryobitools.eu/{{ product.url }}'>link</a></td>
<td class="align-middle">{{ product.price.last.price | format_currency('PLN', {}, 'pl') }}</td>
<td class="align-middle">{% if product.price.last.price != product.price.last.productStandardPrice %}<span class="text-decoration-line-through text-warning">{{ product.price.last.productStandardPrice | format_currency('PLN', {}, 'pl') }}</span>&nbsp;<span class="text-success">{{ ((1 - product.price.last.price / product.price.last.productStandardPrice)*100)|number_format(0) }}%</span>{% else %}{% endif %}</td>
</tr>
{% endfor %}
</table>
{% endblock %}
+38
View File
@@ -0,0 +1,38 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Ryobi crawler</title>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"
crossorigin="anonymous"/>
</head>
<body>
<nav class="navbar navbar-expand-lg sticky-top bg-body-tertiary border-bottom border-secondary border-1">
<div class="container-fluid">
<a class="navbar-brand" href="{{ path('app_home') }}">Crawler</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<a class="nav-link active" aria-current="page" href="{{ path('app_promos') }}">Promos ({{ promosCount() }})</a>
</li>
</ul>
<form class="d-flex col-lg-6 col-sm-8" role="search" action="{{ path('app_search') }}">
<input class="form-control me-2" type="search" name="search" placeholder="Search term eg. 36v or RCS18X" value="{{ search|default('') }}" aria-label="Search">
<button class="btn btn-outline-success" type="submit">Search</button>
</form>
</div>
</div>
</nav>
{% block content %}{% endblock %}
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
</body>
</html>