Compare commits

..

11 Commits

Author SHA1 Message Date
Valentijn van der Jagt
7fd0eab2c5 some more css & "no results found" 2025-12-29 23:46:07 +01:00
Valentijn van der Jagt
84773e3363 AI CSS 2025-12-29 23:36:59 +01:00
Valentijn van der Jagt
4f7abb6b04 yayy now it works 2025-12-29 23:36:29 +01:00
Valentijn van der Jagt
a0b2ffa8c4 some more fancy shit to make it auto update without having to press enter 2025-12-29 23:26:14 +01:00
Valentijn van der Jagt
5b765f9a7d test to see if ai-generated fuzzy search is better 2025-12-29 23:14:52 +01:00
Valentijn van der Jagt
adfb3a9fb2 test to see if ai-generated fuzzy search is better 2025-12-29 23:09:55 +01:00
Valentijn van der Jagt
b619afe24f added (somewhat) functional front-end 2025-12-29 23:06:07 +01:00
Valentijn van der Jagt
1c2966b140 added web template 2025-12-29 22:53:42 +01:00
Valentijn van der Jagt
2c125b4bfd added more pring depednecies or something 2025-12-29 22:48:57 +01:00
Valentijn van der Jagt
73d6e2ef82 added thymeleaf, a nice webserver thingy 2025-12-29 22:44:22 +01:00
Valentijn van der Jagt
d296c68ec1 made stuff static in preperation for web front-end 2025-12-29 22:38:13 +01:00
7 changed files with 725 additions and 41 deletions

15
pom.xml
View File

@@ -27,6 +27,21 @@
<artifactId>simple-levenshtein-distance</artifactId>
<version>1.0.0</version>
</dependency>
<dependency>
<groupId>org.thymeleaf</groupId>
<artifactId>thymeleaf</artifactId>
<version>3.1.3.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>3.2.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
<version>3.2.0</version>
</dependency>
</dependencies>
<build>
<plugins>

View File

@@ -3,13 +3,13 @@ package nl.herpiederpiee.appie_scraper;
import com.microsoft.playwright.ElementHandle;
public class BonusItem {
String title;
String description = "";
String bonusText;
String category;
String imageURL;
public String title;
public String description = "";
public String bonusText;
public String category;
public String imageURL;
String moreInfoURL;
public String moreInfoURL;
float originalPrice = 0.0f;
float bonusPrice = 0.0f;

View File

@@ -3,13 +3,14 @@ package nl.herpiederpiee.appie_scraper;
import com.microsoft.playwright.*;
import xyz.nextn.levenshteindistance.LevenshteinDistance;
import java.text.Normalizer;
import java.util.ArrayList;
import java.util.concurrent.TimeUnit;
public class BonusManager {
ArrayList<BonusItem> bonusItems = new ArrayList<BonusItem>();;
static ArrayList<BonusItem> bonusItems = new ArrayList<BonusItem>();;
public void updateBonusItems(){
public static void updateBonusItems(){
try (Playwright playwright = Playwright.create()) {
Browser browser = playwright.chromium().launch(new BrowserType.LaunchOptions().setHeadless(false));
@@ -31,7 +32,7 @@ public class BonusManager {
if (bonusItem.category.equals( "gall-card")) continue;
if (bonusItem.category.equals( "etos")) continue;
this.bonusItems.add(bonusItem);
bonusItems.add(bonusItem);
}
} catch (InterruptedException e) {
@@ -39,15 +40,22 @@ public class BonusManager {
}
}
public ArrayList<BonusItem> getBonusItems(String name){
public static ArrayList<BonusItem> getBonusItems(String name){
ArrayList<Pair<BonusItem, Integer>> list = new ArrayList<>();
if (name == null || name.trim().isEmpty()){
return bonusItems;
}
for (BonusItem bonusItem : bonusItems) {
Integer score = fuzzyMatchScore(name, bonusItem.title);
list.add(Pair.pair(bonusItem, score));
}
list.sort((a, b) -> Integer.compare(b.second, a.second));
if (list.get(0).second.equals(0)){
return new ArrayList<>();
}
ArrayList<BonusItem> top10 = new ArrayList<>();
int i = 0;
while (top10.size() < 10) {
@@ -58,26 +66,153 @@ public class BonusManager {
return top10;
}
public int fuzzyMatchScore(String query, String title) {
query = query.toLowerCase();
title = title.toLowerCase();
if (title.contains(query)) {
return 100; // perfect match
public static int fuzzyMatchScore(String query, String title) {
if (query == null || title == null || query.isEmpty() || title.isEmpty()) {
return 0;
}
int best = Integer.MAX_VALUE;
String normalizedQuery = normalize(query);
String normalizedTitle = normalize(title);
// ===== TIER 1: EXACT WORD MATCH (highest priority) =====
if (isExactWordMatch(normalizedTitle, normalizedQuery)) {
return 100;
}
// ===== TIER 2: WORD-BOUNDARY SUBSTRING =====
if (isWordBoundaryMatch(normalizedTitle, normalizedQuery)) {
return 95;
}
// ===== TIER 3: PREFIX MATCH =====
if (isPrefixMatch(normalizedTitle, normalizedQuery)) {
return 85;
}
// ===== TIER 4: LEVENSHTEIN (typo tolerance) =====
int qlen = normalizedQuery.length();
int tlen = normalizedTitle.length();
if (qlen > tlen) {
return 0;
}
int bestDistance = Integer.MAX_VALUE;
for (int i = 0; i <= tlen - qlen; i++) {
String sub = normalizedTitle.substring(i, i + qlen);
int dist = levenshteinDistance(normalizedQuery, sub);
if (dist < bestDistance) {
bestDistance = dist;
if (dist == 0) break;
}
}
// Allow up to 2 edits (typo tolerance)
if (bestDistance <= 2) {
// Distance 0 = 80, Distance 1 = 70, Distance 2 = 60
int score = 80 - (bestDistance * 10);
return Math.max(0, score);
}
return 0;
}
/**
* Exact word match: query must be surrounded by word boundaries or string edges
* "LOR" matches "L OR" or "LOR coffee" but NOT "LOREAL"
*/
private static boolean isExactWordMatch(String title, String query) {
String[] words = title.split("\\s+");
for (String word : words) {
if (word.equals(query)) {
return true;
}
}
return false;
}
/**
* Word boundary match: query matches at word start/end
* "LOR" matches in "L'OR" (after special char removed)
* "REAL" matches in "LOREAL" as word boundary? No, stays in Tier 4
*/
private static boolean isWordBoundaryMatch(String title, String query) {
// Check if query appears after space or at start
if (title.startsWith(query + " ")) {
return true;
}
if (title.contains(" " + query)) {
return true;
}
// Check if query ends at word boundary
if (title.endsWith(" " + query)) {
return true;
}
return false;
}
/**
* Prefix match: query is the start of any word
* "CAF" matches in "CAFFE" or "CAFE LATTE"
*/
private static boolean isPrefixMatch(String title, String query) {
for (String word : title.split("\\s+")) {
if (word.startsWith(query) && word.length() > query.length()) {
return true;
}
}
return false;
}
private static String normalize(String input) {
if (input == null || input.isEmpty()) {
return input;
}
// Remove diacritics
String decomposed = Normalizer.normalize(input, Normalizer.Form.NFD);
String withoutDiacritics = decomposed
.replaceAll("\\p{InCombiningDiacriticalMarks}+", "");
// Lowercase, remove special chars, normalize spaces
String cleaned = withoutDiacritics
.toLowerCase()
.replaceAll("[^a-z0-9\\s]", "")
.replaceAll("\\s+", " ")
.trim();
return cleaned;
}
private static int levenshteinDistance(String query, String title) {
int qlen = query.length();
int tlen = title.length();
for (int i = 0; i <= tlen - qlen; i++) {
String sub = title.substring(i, i + qlen);
int dist = LevenshteinDistance.calculate(query, sub);
if (dist < best) best = dist;
if (qlen == 0) return tlen;
if (tlen == 0) return qlen;
int[][] dp = new int[qlen + 1][tlen + 1];
for (int i = 0; i <= qlen; i++) dp[i][0] = i;
for (int j = 0; j <= tlen; j++) dp[0][j] = j;
for (int i = 1; i <= qlen; i++) {
for (int j = 1; j <= tlen; j++) {
if (query.charAt(i - 1) == title.charAt(j - 1)) {
dp[i][j] = dp[i - 1][j - 1];
} else {
dp[i][j] = 1 + Math.min(
Math.min(dp[i - 1][j - 1], dp[i - 1][j]),
dp[i][j - 1]
);
}
}
}
int score = (int)(100.0 * (1.0 - (best / (double) qlen))); // fancy manier om t naar een % match om te zetten
return Math.max(0, Math.min(100, score));
return dp[qlen][tlen];
}
}

View File

@@ -1,30 +1,29 @@
package nl.herpiederpiee.appie_scraper;
import com.microsoft.playwright.*;
import org.json.*;
import java.util.ArrayList;
import java.util.Scanner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class Main {
public static void main(String[] args) {
BonusManager bonusManager = new BonusManager();
bonusManager.updateBonusItems();
Scanner input = new Scanner(System.in);
BonusManager.updateBonusItems();
// start the webserver
SpringApplication.run(Main.class, args);
while (true) {
System.out.println("\n\nWhat item would you like to look for?");
String userInput = input.nextLine();
if (userInput.equals("qqq")) break;
ArrayList<BonusItem> userResults = bonusManager.getBonusItems(userInput);
for (BonusItem bonusItem : userResults) {
System.out.println(bonusItem.title + " => " + bonusItem.bonusText);
}
}
// while (true) {
// System.out.println("\n\nWhat item would you like to look for?");
// String userInput = input.nextLine();
// if (userInput.equals("qqq")) break;
// ArrayList<BonusItem> userResults = BonusManager.getBonusItems(userInput);
//
// for (BonusItem bonusItem : userResults) {
// System.out.println(bonusItem.title + " => " + bonusItem.bonusText);
// }
// }
}
}

View File

@@ -0,0 +1,25 @@
package nl.herpiederpiee.appie_scraper;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import java.util.ArrayList;
@Controller
public class WebServer {
@GetMapping("/")
public String index() {
return "index";
}
@GetMapping("/api/fuzzy")
@ResponseBody
public ArrayList<BonusItem> fuzzySearch(@RequestParam(value = "q", required = false, defaultValue = "") String fuzzySearch){
ArrayList<BonusItem> items = BonusManager.getBonusItems(fuzzySearch);
return items;
}
}

View File

@@ -0,0 +1,2 @@
server.port=9823
spring.application.name=AppieBonusScraper

View File

@@ -0,0 +1,508 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Appie Scraper</title>
<style>
:root {
--ah-blue: #00ADE6;
--ah-blue-dark: #0091B8;
--ah-blue-light: #1FC4F0;
--ah-orange: #FF7900;
--ah-dark-bg: #0D1117;
--ah-dark-surface: #161B22;
--ah-dark-surface-light: #21262D;
--ah-text: #E6EDF3;
--ah-text-secondary: #8B949E;
--ah-border: #30363D;
--spacing-xs: 8px;
--spacing-sm: 12px;
--spacing-md: 16px;
--spacing-lg: 24px;
--spacing-xl: 32px;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
width: 100%;
height: 100%;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: linear-gradient(135deg, var(--ah-dark-bg) 0%, #132236 100%);
color: var(--ah-text);
padding: 0;
min-height: 100vh;
overflow-x: hidden;
}
header {
background: linear-gradient(90deg, var(--ah-dark-surface) 0%, #1a2634 100%);
padding: var(--spacing-lg);
border-bottom: 3px solid var(--ah-blue);
box-shadow: 0 8px 32px rgba(0, 173, 230, 0.15);
margin-bottom: var(--spacing-lg);
position: sticky;
top: 0;
z-index: 100;
}
h1 {
font-size: clamp(24px, 5vw, 36px);
font-weight: 700;
letter-spacing: -0.5px;
background: linear-gradient(135deg, var(--ah-blue) 0%, var(--ah-blue-light) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 0 var(--spacing-lg);
}
.search-container {
margin-bottom: var(--spacing-xl);
animation: slideDown 0.6s ease-out;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.search-wrapper {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
input[type="text"] {
width: 100%;
padding: var(--spacing-md);
font-size: 15px;
border: 2px solid var(--ah-border);
border-radius: 10px;
background: var(--ah-dark-surface-light);
color: var(--ah-text);
transition: all 0.3s ease;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
input[type="text"]::placeholder {
color: var(--ah-text-secondary);
}
input[type="text"]:focus {
outline: none;
border-color: var(--ah-blue);
box-shadow: 0 0 0 3px rgba(0, 173, 230, 0.25), 0 4px 12px rgba(0, 0, 0, 0.3);
}
.search-info {
font-size: 13px;
color: var(--ah-text-secondary);
font-weight: 500;
}
.cards-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: var(--spacing-lg);
margin-bottom: var(--spacing-xl);
animation: fadeIn 0.4s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.card {
background: var(--ah-dark-surface);
border: 1px solid var(--ah-border);
border-radius: 12px;
overflow: hidden;
transition: all 0.3s ease;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
display: flex;
flex-direction: column;
height: 100%;
}
.card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 32px rgba(0, 173, 230, 0.2);
border-color: var(--ah-blue);
}
.card-image {
width: 100%;
height: 200px;
object-fit: cover;
background: linear-gradient(135deg, var(--ah-dark-surface-light) 0%, var(--ah-dark-bg) 100%);
transition: transform 0.3s ease;
}
.card:hover .card-image {
transform: scale(1.05);
}
.card-content {
padding: var(--spacing-lg);
display: flex;
flex-direction: column;
flex: 1;
gap: var(--spacing-md);
}
.card-title {
font-size: 18px;
font-weight: 700;
color: var(--ah-text);
line-height: 1.3;
}
.card-description {
font-size: 13px;
color: var(--ah-text-secondary);
line-height: 1.5;
flex: 1;
}
.card-bonus {
display: inline-block;
padding: var(--spacing-sm) var(--spacing-md);
background: linear-gradient(135deg, rgba(255, 121, 0, 0.15), rgba(255, 121, 0, 0.05));
border: 1px solid rgba(255, 121, 0, 0.3);
border-radius: 6px;
color: var(--ah-orange);
font-weight: 600;
font-size: 12px;
text-align: center;
}
.card-meta {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: var(--spacing-md);
border-top: 1px solid var(--ah-border);
gap: var(--spacing-sm);
flex-wrap: wrap;
}
.card-category {
font-size: 11px;
background: rgba(0, 173, 230, 0.1);
color: var(--ah-blue-light);
padding: 4px 8px;
border-radius: 4px;
text-transform: uppercase;
font-weight: 600;
letter-spacing: 0.3px;
}
.card-link {
margin-left: auto;
}
a {
color: var(--ah-blue-light);
text-decoration: none;
font-weight: 600;
font-size: 12px;
padding: 6px 12px;
border-radius: 6px;
transition: all 0.2s ease;
background: rgba(0, 173, 230, 0.1);
display: inline-block;
white-space: nowrap;
}
a:hover {
background: rgba(0, 173, 230, 0.2);
transform: translateX(2px);
}
.no-results {
text-align: center;
padding: var(--spacing-xl);
color: var(--ah-text-secondary);
font-size: 16px;
background: var(--ah-dark-surface);
border-radius: 12px;
border: 1px solid var(--ah-border);
}
.no-results strong {
color: var(--ah-orange);
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 10px;
}
::-webkit-scrollbar-track {
background: var(--ah-dark-bg);
}
::-webkit-scrollbar-thumb {
background: var(--ah-blue);
border-radius: 5px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--ah-blue-light);
}
/* Mobile Responsive */
@media (max-width: 768px) {
:root {
--spacing-xs: 6px;
--spacing-sm: 10px;
--spacing-md: 12px;
--spacing-lg: 16px;
--spacing-xl: 20px;
}
header {
padding: var(--spacing-md);
margin-bottom: var(--spacing-lg);
}
h1 {
font-size: 20px;
}
.container {
padding: 0 var(--spacing-md);
}
input[type="text"] {
font-size: 16px; /* Prevents zoom on iOS */
padding: var(--spacing-sm) var(--spacing-md);
}
.cards-grid {
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: var(--spacing-md);
margin-bottom: var(--spacing-lg);
}
.card-image {
height: 150px;
}
.card-content {
padding: var(--spacing-md);
gap: var(--spacing-sm);
}
.card-title {
font-size: 15px;
}
.card-description {
font-size: 12px;
}
.card-meta {
flex-direction: column;
align-items: flex-start;
}
.card-link {
margin-left: 0;
width: 100%;
}
a {
width: 100%;
text-align: center;
padding: var(--spacing-sm);
}
}
@media (max-width: 480px) {
header {
padding: var(--spacing-sm);
}
h1 {
font-size: 18px;
}
.container {
padding: 0 var(--spacing-sm);
}
.cards-grid {
grid-template-columns: 1fr;
gap: var(--spacing-sm);
}
.card-image {
height: 120px;
}
.card-content {
padding: var(--spacing-sm);
}
.card-title {
font-size: 14px;
}
.card-description {
font-size: 11px;
}
.card-bonus {
font-size: 11px;
padding: 4px 8px;
}
input[type="text"] {
padding: 10px 12px;
font-size: 16px;
}
}
/* Loading skeleton */
.card-skeleton {
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 0.6; }
50% { opacity: 1; }
}
</style>
</head>
<body>
<header>
<div class="container">
<h1>🛒 Appie Bonus Tracker</h1>
</div>
</header>
<div class="container">
<div class="search-container">
<div class="search-wrapper">
<input
type="text"
id="fuzzySearch"
placeholder="Search bonus items... (real-time)"
autocomplete="off"
>
<div class="search-info">
<span id="resultCount">Loading items...</span>
</div>
</div>
</div>
<div id="cardsContainer" class="cards-grid">
<!-- Cards will be inserted here -->
</div>
</div>
<script>
const searchInput = document.getElementById('fuzzySearch');
const cardsContainer = document.getElementById('cardsContainer');
const resultCount = document.getElementById('resultCount');
let debounceTimer;
document.addEventListener('DOMContentLoaded', () => {
performFuzzySearch('');
});
searchInput.addEventListener('input', (e) => {
clearTimeout(debounceTimer);
const query = e.target.value.trim();
debounceTimer = setTimeout(() => {
performFuzzySearch(query);
}, 300);
});
async function performFuzzySearch(query) {
try {
const response = await fetch(`/api/fuzzy?q=${encodeURIComponent(query)}`);
if (!response.ok) {
console.error('Search failed:', response.status);
return;
}
const results = await response.json();
renderResults(results, query);
} catch (error) {
console.error('Error performing search:', error);
}
}
function renderResults(results, query) {
if (results.length === 0) {
cardsContainer.innerHTML = `
<div style="grid-column: 1 / -1;">
<div class="no-results">
No items found matching "<strong>${escapeHtml(query)}</strong>"
</div>
</div>
`;
resultCount.textContent = `0 results for "${query}"`;
return;
}
cardsContainer.innerHTML = results.map(item => `
<div class="card">
<img src="${escapeHtml(item.imageURL)}" alt="${escapeHtml(item.title)}" class="card-image">
<div class="card-content">
<h2 class="card-title">${escapeHtml(item.title)}</h2>
<p class="card-description">${escapeHtml(item.description)}</p>
<div class="card-bonus">${escapeHtml(item.bonusText)}</div>
<div class="card-meta">
<span class="card-category">${escapeHtml(item.category)}</span>
<a href="${escapeHtml(item.moreInfoURL)}" target="_blank" class="card-link">View Offer</a>
</div>
</div>
</div>
`).join('');
resultCount.textContent = `${results.length} item${results.length !== 1 ? 's' : ''} found`;
}
function escapeHtml(text) {
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return text.replace(/[&<>"']/g, m => map[m]);
}
</script>
</body>
</html>