From 8a94cb03923764685ee8c8b9a85bb1e6ab7bad00 Mon Sep 17 00:00:00 2001 From: mita Date: Sat, 31 Jan 2026 11:23:57 +0100 Subject: [PATCH] chore: Korrekturen --- AGENTS.md | 44 + README.md | 39 + pom.xml | 103 +- .../PrimfeedLikerApplication.java | 6 +- .../automation/PrimfeedDoomscrollScript.java | 894 ++++++++++++++++++ src/main/resources/application.properties | 38 + .../PrimfeedLikerApplicationTests.java | 6 +- 7 files changed, 1076 insertions(+), 54 deletions(-) create mode 100644 AGENTS.md create mode 100644 src/main/java/de/tamse/primfeedliker/automation/PrimfeedDoomscrollScript.java diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e6293e6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,44 @@ +# Repository Guidelines + +## Project Structure & Module Organization +- `pom.xml` defines the Maven build, dependencies, and Java 17 target. +- `src/main/java/de/tamse/primfeedliker/` holds Spring Boot application code. +- `src/main/resources/` is for configuration (`application.properties` or `application.yml`) and any static assets. +- `src/test/java/de/tamse/primfeedliker/` contains JUnit tests. +- `target/` is the build output directory (generated by Maven). + +## Build, Test, and Development Commands +- `./mvnw spring-boot:run` starts the application locally. +- `./mvnw test` runs the JUnit test suite. +- `./mvnw package` builds the runnable JAR in `target/`. +- `./mvnw clean` removes generated build artifacts. + +## Coding Style & Naming Conventions +- Use Java 17 language features only when supported by the project’s target level. +- Indentation is 4 spaces; follow standard Java and Spring Boot formatting. +- Classes use `PascalCase`, methods/fields use `lowerCamelCase`, and constants use `UPPER_SNAKE_CASE`. +- Keep package names lowercase and aligned with `de.tamse.primfeedliker`. +- No formatter or linter is configured; rely on IDE formatting and consistent manual style. + +## Testing Guidelines +- Tests use JUnit 5 via `spring-boot-starter-test`. +- Place tests under `src/test/java/de/tamse/primfeedliker/` and name them `*Tests.java`. +- Keep tests fast and isolated; avoid external dependencies in unit tests. + +## Commit & Pull Request Guidelines +- No commit history exists yet; use short, imperative summaries (e.g., “Add feed service”). +- Include context in the body when behavior changes or configuration is added. +- Pull requests should describe the change, list test commands run, and link relevant issues. +- Add screenshots or logs when changes affect runtime output or API responses. + +## Configuration & Secrets +- Store runtime configuration in `src/main/resources/application.properties` or `application.yml`. +- Do not commit secrets; use environment variables or local overrides instead. + +## Automation Notes +- `PrimfeedDoomscrollScript` (`de.tamse.primfeedliker.automation`) is the Selenium entrypoint; run via `./mvnw spring-boot:run -Dspring-boot.run.main-class=de.tamse.primfeedliker.automation.PrimfeedDoomscrollScript`. +- Required selectors live in `src/main/resources/application.properties` (`primfeed.feed-item-selector`, `primfeed.profile-name-selector`, `primfeed.like-button-selector`, `primfeed.target-profile`). +- Login persistence uses browser profile settings (`primfeed.chrome-user-data-dir`, `primfeed.chrome-profile-dir`, `primfeed.firefox-profile-dir`). +- Doomscroll tuning: `primfeed.like-delay-*`, `primfeed.scroll-*`, `primfeed.long-pause-*`, `primfeed.max-scrolls`, `primfeed.max-items`. +- Stop/skip controls: `primfeed.max-consecutive-liked`, `primfeed.skip-profiles` (case-insensitive). +- Cache controls: `primfeed.cache-path`, `primfeed.cache-max-entries` (default 50k), `primfeed.rescan-window-size`, `primfeed.max-linked-items`. diff --git a/README.md b/README.md index e69de29..d6aa4ef 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,39 @@ +## Primfeed Liker + +### Architecture +- `PrimfeedDoomscrollScript` in `src/main/java/de/tamse/primfeedliker/automation/PrimfeedDoomscrollScript.java` is the entrypoint. +- Config loads from `src/main/resources/application.properties` (or `application.yml`) and system properties. +- Selenium WebDriver handles the browser; a `DoomscrollState` tracks progress and stopping logic. +- `LikedCache` persists liked post keys to disk and caps the cache size. + +### Run +- Ensure Java 17 and a matching WebDriver (ChromeDriver/GeckoDriver) are available. +- Start with: + `./mvnw spring-boot:run -Dspring-boot.run.main-class=de.tamse.primfeedliker.automation.PrimfeedDoomscrollScript` + +### Configuration (Key Parameters) +- Selectors (required): + - `primfeed.feed-item-selector` + - `primfeed.profile-name-selector` + - `primfeed.like-button-selector` + - `primfeed.target-profile` +- Login persistence: + - `primfeed.chrome-user-data-dir`, `primfeed.chrome-profile-dir` + - `primfeed.firefox-profile-dir` + - `primfeed.manual-login-seconds` +- Doomscroll pacing: + - `primfeed.like-delay-min-ms`, `primfeed.like-delay-max-ms` + - `primfeed.scroll-min-px`, `primfeed.scroll-max-px` + - `primfeed.long-pause-every`, `primfeed.long-pause-min-ms`, `primfeed.long-pause-max-ms` + - `primfeed.max-scrolls`, `primfeed.max-items` +- Stop/skip controls: + - `primfeed.max-consecutive-liked` (0 disables the stop condition) + - `primfeed.skip-profiles` (comma-separated, case-insensitive) +- Cache controls: + - `primfeed.cache-path` (default `${user.home}/.primfeed-liker/liked-posts.txt`) + - `primfeed.cache-max-entries` (default 50k) + - `primfeed.rescan-window-size`, `primfeed.max-linked-items` + +### Notes +- Clear the cache by deleting `~/.primfeed-liker/liked-posts.txt`. +- If you switch accounts or undo likes, clear the cache to avoid stale skips. diff --git a/pom.xml b/pom.xml index dda130e..0e3aaee 100644 --- a/pom.xml +++ b/pom.xml @@ -1,54 +1,61 @@ - 4.0.0 - - org.springframework.boot - spring-boot-starter-parent - 4.0.2 - - - de.tamse - PrimfeedLiker - 0.0.1-SNAPSHOT - PrimfeedLiker - PrimfeedLiker - - - - - - - - - - - - - - - 17 - - - - org.springframework.boot - spring-boot-starter - + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 4.0.2 + + + de.tamse + PrimfeedLiker + 0.0.1-SNAPSHOT + PrimfeedLiker + PrimfeedLiker + + + + + + + + + + + + + + + 17 + 4.28.1 + + + + org.springframework.boot + spring-boot-starter + - - org.springframework.boot - spring-boot-starter-test - test - - + + org.seleniumhq.selenium + selenium-java + ${selenium.version} + - - - - org.springframework.boot - spring-boot-maven-plugin - - - + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + diff --git a/src/main/java/de/tamse/primfeedliker/PrimfeedLikerApplication.java b/src/main/java/de/tamse/primfeedliker/PrimfeedLikerApplication.java index 86f1282..a3459f7 100644 --- a/src/main/java/de/tamse/primfeedliker/PrimfeedLikerApplication.java +++ b/src/main/java/de/tamse/primfeedliker/PrimfeedLikerApplication.java @@ -6,8 +6,8 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class PrimfeedLikerApplication { - public static void main(String[] args) { - SpringApplication.run(PrimfeedLikerApplication.class, args); - } + public static void main(String[] args) { + SpringApplication.run(PrimfeedLikerApplication.class, args); + } } diff --git a/src/main/java/de/tamse/primfeedliker/automation/PrimfeedDoomscrollScript.java b/src/main/java/de/tamse/primfeedliker/automation/PrimfeedDoomscrollScript.java new file mode 100644 index 0000000..5c871b0 --- /dev/null +++ b/src/main/java/de/tamse/primfeedliker/automation/PrimfeedDoomscrollScript.java @@ -0,0 +1,894 @@ +package de.tamse.primfeedliker.automation; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.time.Duration; +import java.util.Arrays; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Properties; +import java.util.Set; +import java.util.concurrent.ThreadLocalRandom; +import java.util.stream.Stream; + +import org.openqa.selenium.By; +import org.openqa.selenium.ElementNotInteractableException; +import org.openqa.selenium.JavascriptExecutor; +import org.openqa.selenium.StaleElementReferenceException; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.chrome.ChromeDriver; +import org.openqa.selenium.chrome.ChromeOptions; +import org.openqa.selenium.firefox.FirefoxDriver; +import org.openqa.selenium.firefox.FirefoxOptions; +import org.openqa.selenium.firefox.FirefoxProfile; +import org.openqa.selenium.interactions.Actions; +import org.openqa.selenium.support.ui.WebDriverWait; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class PrimfeedDoomscrollScript { + + private static final List FALLBACK_ID_ATTRIBUTES = List.of("data-id", "data-testid", "id"); + private static final String POST_LINK_SELECTOR = "a[href*='/posts/']"; + private static final String NON_UNIQUE_ITEM_ID = "info-media"; + private static final Logger LOGGER = LoggerFactory.getLogger(PrimfeedDoomscrollScript.class); + + public static void main(String[] args) { + Config config = Config.from(loadProperties()); + config.validate(); + + WebDriver driver = createDriver(config); + try { + runScenario(driver, config); + } finally { + driver.quit(); + } + } + + private static Properties loadProperties() { + Properties properties = new Properties(); + try (InputStream input = PrimfeedDoomscrollScript.class.getClassLoader() + .getResourceAsStream("application.properties")) { + if (input != null) { + properties.load(input); + } + } catch (IOException exception) { + throw new UncheckedIOException(exception); + } + return properties; + } + + private static WebDriver createDriver(Config config) { + if ("firefox".equalsIgnoreCase(config.browser())) { + FirefoxOptions options = new FirefoxOptions(); + if (config.headless()) { + options.addArguments("-headless"); + } + if (hasText(config.firefoxProfileDir())) { + FirefoxProfile profile = new FirefoxProfile(Path.of(config.firefoxProfileDir()).toFile()); + options.setProfile(profile); + } + return new FirefoxDriver(options); + } + + ChromeOptions options = new ChromeOptions(); + if (config.headless()) { + options.addArguments("--headless=new"); + } + options.addArguments("--disable-notifications", "--start-maximized"); + if (hasText(config.chromeUserDataDir())) { + options.addArguments("--user-data-dir=" + config.chromeUserDataDir()); + } + if (hasText(config.chromeProfileDir())) { + options.addArguments("--profile-directory=" + config.chromeProfileDir()); + } + return new ChromeDriver(options); + } + + private static void runScenario(WebDriver driver, Config config) { + driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(config.implicitWaitSeconds())); + driver.get(config.baseUrl()); + + if (config.manualLoginSeconds() > 0) { + pause(Duration.ofSeconds(config.manualLoginSeconds()).toMillis()); + } + + WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(config.waitTimeoutSeconds())); + wait.until(webDriver -> !webDriver.findElements(By.cssSelector(config.feedItemSelector())).isEmpty()); + + LikedCache likedCache = loadLikedCache(config); + DoomscrollState state = new DoomscrollState(likedCache); + boolean targetReached = false; + LOGGER.info("Starting doomscroll. Target profile: {}", config.targetProfile()); + + while (!targetReached && !state.stopRequested + && state.scrollCount < config.maxScrolls() && state.processedCount < config.maxItems()) { + List items = driver.findElements(By.cssSelector(config.feedItemSelector())); + LOGGER.info("Loaded items: {}", items.size()); + boolean refreshTriggered = false; + for (int index = 0; index < items.size(); index++) { + WebElement item = items.get(index); + targetReached = handleItem(driver, item, index, config, state); + if (targetReached || state.stopRequested || state.processedCount >= config.maxItems()) { + break; + } + if (state.refreshRequested) { + refreshTriggered = true; + state.refreshRequested = false; + LOGGER.info("Refreshing item list after DOM update."); + break; + } + } + + if (targetReached || state.stopRequested || state.processedCount >= config.maxItems()) { + break; + } + + if (refreshTriggered) { + continue; + } + + scrollDown(driver, randomBetween(config.scrollMinPx(), config.scrollMaxPx())); + state.scrollCount++; + pauseRandom(config.minDelayMs(), config.maxDelayMs()); + + if (config.longPauseEvery() > 0 && state.scrollCount % config.longPauseEvery() == 0) { + pauseRandom(config.longPauseMinMs(), config.longPauseMaxMs()); + } + } + + System.out.println("Processed items: " + state.processedCount); + System.out.println("Likes given: " + state.likesGiven); + System.out.println("Target profile hit: " + state.targetProfileHit); + } + + private static boolean handleItem(WebDriver driver, WebElement item, int itemIndex, Config config, + DoomscrollState state) { + try { + String itemKey = resolveItemKey(item, config); + if (itemKey != null && !state.seenItemKeys.add(itemKey)) { + LOGGER.info("Skip item: already seen. key={}", itemKey); + registerConsecutiveLiked(state, config, itemKey); + return false; + } + + if (itemKey != null && state.likedCache.contains(itemKey)) { + LOGGER.info("Skip item: cached liked key. key={}", itemKey); + state.autoLikedKeys.add(itemKey); + registerConsecutiveLiked(state, config, itemKey); + return false; + } + + state.processedCount++; + + ProfileDetails profileDetails = resolveProfileDetails(item, config); + String profileName = profileDetails.name(); + String profileKey = profileDetails.key(); + if (isProfileSkipped(profileDetails, config)) { + LOGGER.info("Skip item: profile excluded. profile={} key={}", + safeText(profileName), + safeText(profileKey)); + resetConsecutiveLiked(state); + return false; + } + LOGGER.info("Processing item. key={} profile={}", safeText(itemKey), safeText(profileName)); + if (profileName != null && !profileName.isBlank() + && profileName.equalsIgnoreCase(config.targetProfile())) { + state.targetProfileHit = profileName; + if (config.likeTargetProfile()) { + long likeStartMillis = System.currentTimeMillis(); + boolean liked = likeItem(driver, item, config, state, itemKey, profileName); + if (liked) { + int autoLiked = updateAutoLikedItems(driver, config, state, profileDetails, itemIndex); + if (autoLiked > 0) { + LOGGER.info("Auto-liked detected after target: {} items", autoLiked); + } + pauseRemainingLikeDelay(likeStartMillis, config); + state.refreshRequested = true; + } + } + LOGGER.info("Target profile reached: {}", profileName); + return true; + } + + if (itemKey != null && state.autoLikedKeys.contains(itemKey)) { + LOGGER.info("Skip item: auto-liked already marked. key={}", itemKey); + registerConsecutiveLiked(state, config, itemKey); + return false; + } + + if (isItemAlreadyLiked(item, config)) { + cacheLikedItem(state, itemKey); + LOGGER.info("Skip item: already liked. key={}", safeText(itemKey)); + registerConsecutiveLiked(state, config, itemKey); + return false; + } + + resetConsecutiveLiked(state); + + long likeStartMillis = System.currentTimeMillis(); + boolean liked = likeItem(driver, item, config, state, itemKey, profileName); + if (liked) { + cacheLikedItem(state, itemKey); + int autoLiked = updateAutoLikedItems(driver, config, state, profileDetails, itemIndex); + if (autoLiked > 0) { + LOGGER.info("Auto-liked detected: {} items", autoLiked); + } + pauseRemainingLikeDelay(likeStartMillis, config); + state.refreshRequested = true; + } else { + pauseRandom(config.minDelayMs(), config.maxDelayMs()); + } + return false; + } catch (StaleElementReferenceException ignored) { + state.refreshRequested = true; + return false; + } + } + + private static boolean likeItem(WebDriver driver, WebElement item, Config config, DoomscrollState state, + String itemKey, String profileName) { + List likeButtons = item.findElements(By.cssSelector(config.likeButtonSelector())); + if (likeButtons.isEmpty()) { + LOGGER.info("Skip item: like button not found. key={}", safeText(itemKey)); + return false; + } + + WebElement likeButton = likeButtons.get(0); + if (!likeButton.isDisplayed() || !likeButton.isEnabled()) { + LOGGER.info("Skip item: like button inactive. key={}", safeText(itemKey)); + return false; + } + + if (!shouldClickLike(likeButton, config)) { + LOGGER.info("Skip item: like already active. key={}", safeText(itemKey)); + return false; + } + + scrollIntoView(driver, likeButton); + pauseRandom(120, 280); + if (clickLikeButton(driver, likeButton)) { + state.likesGiven++; + LOGGER.info("Liked item. key={} profile={}", safeText(itemKey), safeText(profileName)); + return true; + } + LOGGER.info("Like failed. key={} profile={}", safeText(itemKey), safeText(profileName)); + return false; + } + + private static boolean clickLikeButton(WebDriver driver, WebElement likeButton) { + try { + new Actions(driver) + .moveToElement(likeButton) + .pause(Duration.ofMillis(randomBetween(80, 220))) + .click() + .perform(); + return true; + } catch (ElementNotInteractableException exception) { + try { + ((JavascriptExecutor) driver).executeScript("arguments[0].click();", likeButton); + return true; + } catch (RuntimeException ignored) { + return false; + } + } + } + + private static boolean shouldClickLike(WebElement likeButton, Config config) { + return !isAlreadyLiked(likeButton, config); + } + + private static boolean isItemAlreadyLiked(WebElement item, Config config) { + List likeButtons = item.findElements(By.cssSelector(config.likeButtonSelector())); + if (likeButtons.isEmpty()) { + return false; + } + return isAlreadyLiked(likeButtons.get(0), config); + } + + private static boolean isAlreadyLiked(WebElement likeButton, Config config) { + String configuredAttribute = config.likedStateAttribute(); + if (configuredAttribute != null && !configuredAttribute.isBlank()) { + String attributeValue = likeButton.getAttribute(configuredAttribute); + if (attributeValue != null && !attributeValue.isBlank()) { + return isTruthy(attributeValue, config.likedStateValue()); + } + } + + String ariaPressed = likeButton.getAttribute("aria-pressed"); + if (ariaPressed != null && ariaPressed.equalsIgnoreCase("true")) { + return true; + } + + String className = likeButton.getAttribute("class"); + if (className != null) { + String lowerClass = className.toLowerCase(); + return lowerClass.contains("liked") || lowerClass.contains("active") || lowerClass.contains("selected"); + } + + return false; + } + + private static boolean isTruthy(String rawValue, String expectedValue) { + String normalized = rawValue.trim(); + if (expectedValue != null && !expectedValue.isBlank()) { + return normalized.equalsIgnoreCase(expectedValue.trim()); + } + return normalized.equalsIgnoreCase("true") + || normalized.equalsIgnoreCase("yes") + || normalized.equalsIgnoreCase("1"); + } + + private static ProfileDetails resolveProfileDetails(WebElement item, Config config) { + List profileElements = item.findElements(By.cssSelector(config.profileNameSelector())); + if (profileElements.isEmpty()) { + return new ProfileDetails(null, null); + } + WebElement profileElement = profileElements.get(0); + String name = normalizeText(profileElement.getText()); + String href = null; + List links = profileElement.findElements(By.xpath("./ancestor::a[1]")); + if (!links.isEmpty()) { + href = normalizeText(links.get(0).getAttribute("href")); + } + return new ProfileDetails(name, href); + } + + private static String resolveItemKey(WebElement item, Config config) { + if (config.itemIdAttribute() != null && !config.itemIdAttribute().isBlank()) { + String value = attributeValue(item, config.itemIdAttribute()); + if (value != null) { + return config.itemIdAttribute() + ":" + value; + } + } + + String postHref = resolvePostHref(item); + if (postHref != null) { + return "post:" + postHref; + } + + for (String attribute : FALLBACK_ID_ATTRIBUTES) { + String value = attributeValue(item, attribute); + if ("id".equals(attribute) && NON_UNIQUE_ITEM_ID.equalsIgnoreCase(value)) { + continue; + } + if (value != null) { + return attribute + ":" + value; + } + } + + String text = item.getText(); + if (text != null && !text.isBlank()) { + String trimmed = text.trim(); + int limit = Math.min(trimmed.length(), 120); + return "text:" + trimmed.substring(0, limit); + } + return null; + } + + private static String resolvePostHref(WebElement item) { + String href = extractPostHref(item, By.cssSelector(POST_LINK_SELECTOR)); + if (href != null) { + return href; + } + + href = extractPostHref(item, By.xpath("./preceding-sibling::a[contains(@href, '/posts/')][1]")); + if (href != null) { + return href; + } + + href = extractPostHref(item, By.xpath("./following-sibling::a[contains(@href, '/posts/')][1]")); + if (href != null) { + return href; + } + + List parents = item.findElements(By.xpath("./parent::*")); + if (!parents.isEmpty()) { + href = extractPostHref(parents.get(0), By.cssSelector(POST_LINK_SELECTOR)); + if (href != null) { + return href; + } + } + + return null; + } + + private static String extractPostHref(WebElement scope, By locator) { + List postLinks = scope.findElements(locator); + if (postLinks.isEmpty()) { + return null; + } + String href = postLinks.get(0).getAttribute("href"); + if (href == null) { + return null; + } + String trimmed = href.trim(); + return trimmed.isBlank() ? null : trimmed; + } + + private static String attributeValue(WebElement item, String attribute) { + String value = item.getAttribute(attribute); + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isBlank() ? null : trimmed; + } + + private static void scrollIntoView(WebDriver driver, WebElement element) { + ((JavascriptExecutor) driver).executeScript( + "arguments[0].scrollIntoView({block: 'center', inline: 'nearest'});", + element); + } + + private static void scrollDown(WebDriver driver, int pixels) { + ((JavascriptExecutor) driver).executeScript("window.scrollBy(0, arguments[0]);", pixels); + } + + private static void pauseRandom(int minMillis, int maxMillis) { + if (maxMillis < minMillis) { + pause(minMillis); + return; + } + pause(randomBetween(minMillis, maxMillis)); + } + + private static void pauseRemainingLikeDelay(long startMillis, Config config) { + int minDelay = config.likeDelayMinMs(); + int maxDelay = config.likeDelayMaxMs(); + int targetDelay = maxDelay >= minDelay ? randomBetween(minDelay, maxDelay) : minDelay; + long elapsed = System.currentTimeMillis() - startMillis; + long remaining = targetDelay - elapsed; + if (remaining > 0) { + pause(remaining); + } + } + + private static int updateAutoLikedItems(WebDriver driver, Config config, DoomscrollState state, + ProfileDetails profileDetails, int itemIndex) { + int newlyMarked = 0; + String profileKey = profileDetails == null ? null : profileDetails.key(); + LOGGER.info("Rescanning DOM for auto-liked items. profileKey={}", safeText(profileKey)); + long startMillis = System.currentTimeMillis(); + Duration originalWait = Duration.ofSeconds(config.implicitWaitSeconds()); + driver.manage().timeouts().implicitlyWait(Duration.ZERO); + try { + List items = driver.findElements(By.cssSelector(config.feedItemSelector())); + if (items.isEmpty()) { + return 0; + } + int windowSize = config.rescanWindowSize(); + int safeIndex = Math.min(itemIndex, items.size() - 1); + int startIndex = 0; + int endIndex = items.size() - 1; + if (windowSize > 0) { + startIndex = Math.max(0, safeIndex - windowSize); + endIndex = Math.min(items.size() - 1, safeIndex + windowSize); + } + LOGGER.info("Rescan window: {}-{} of {}", startIndex + 1, endIndex + 1, items.size()); + int matchedProfileItems = 0; + for (int index = startIndex; index <= endIndex; index++) { + WebElement item = items.get(index); + try { + if (profileKey != null) { + String itemProfileKey = resolveProfileDetails(item, config).key(); + if (itemProfileKey == null || !profileKey.equals(itemProfileKey)) { + continue; + } + matchedProfileItems++; + } + if (!isItemAlreadyLiked(item, config)) { + continue; + } + String itemKey = resolveItemKey(item, config); + if (itemKey == null || state.autoLikedKeys.contains(itemKey)) { + continue; + } + cacheLikedItem(state, itemKey); + newlyMarked++; + if (profileKey != null && config.maxLinkedItems() > 0 + && matchedProfileItems >= config.maxLinkedItems()) { + break; + } + } catch (StaleElementReferenceException ignored) { + } + } + } finally { + driver.manage().timeouts().implicitlyWait(originalWait); + } + long durationMillis = System.currentTimeMillis() - startMillis; + LOGGER.info("Rescan finished in {} ms.", durationMillis); + return newlyMarked; + } + + private static String safeText(String value) { + if (value == null || value.isBlank()) { + return ""; + } + return value; + } + + private static String normalizeText(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isBlank() ? null : trimmed; + } + + private static LikedCache loadLikedCache(Config config) { + if (!hasText(config.cachePath())) { + LOGGER.info("Liked cache disabled."); + return LikedCache.disabled(); + } + String expandedPath = expandPath(config.cachePath()); + Path path = Path.of(expandedPath).toAbsolutePath(); + Set keys = new LinkedHashSet<>(); + if (Files.exists(path)) { + try (Stream lines = Files.lines(path, StandardCharsets.UTF_8)) { + lines.map(String::trim) + .filter(value -> !value.isBlank()) + .forEach(keys::add); + } catch (IOException exception) { + LOGGER.warn("Failed to read cache file: {}", path, exception); + return LikedCache.disabled(); + } + } + LikedCache cache = new LikedCache(path, keys, true, config.cacheMaxEntries()); + boolean trimmed = cache.trimToMaxEntries(); + if (trimmed) { + LOGGER.info("Cache trimmed to {} entries.", cache.keys().size()); + cache.rewrite(); + } + LOGGER.info("Loaded cached likes: {}", cache.keys().size()); + return cache; + } + + private static void cacheLikedItem(DoomscrollState state, String itemKey) { + if (!hasText(itemKey)) { + return; + } + state.autoLikedKeys.add(itemKey); + state.likedCache.add(itemKey); + } + + private static void registerConsecutiveLiked(DoomscrollState state, Config config, String itemKey) { + int maxConsecutive = config.maxConsecutiveLiked(); + if (maxConsecutive <= 0) { + return; + } + state.consecutiveLikedCount++; + if (state.consecutiveLikedCount >= maxConsecutive) { + state.stopRequested = true; + LOGGER.info("Stopping after {} consecutive liked items. lastKey={}", + state.consecutiveLikedCount, + safeText(itemKey)); + } + } + + private static void resetConsecutiveLiked(DoomscrollState state) { + state.consecutiveLikedCount = 0; + } + + private static boolean isProfileSkipped(ProfileDetails profileDetails, Config config) { + Set skipProfiles = config.skipProfiles(); + if (skipProfiles.isEmpty()) { + return false; + } + String name = normalizeText(profileDetails.name()); + if (name != null && skipProfiles.contains(name.toLowerCase())) { + return true; + } + String key = profileDetails.key(); + return key != null && skipProfiles.contains(key); + } + + private static String expandPath(String value) { + if (!hasText(value)) { + return value; + } + String expanded = value.replace("${user.home}", System.getProperty("user.home")); + if (expanded.equals("~")) { + return System.getProperty("user.home"); + } + if (expanded.startsWith("~/")) { + return System.getProperty("user.home") + expanded.substring(1); + } + return expanded; + } + + private static boolean hasText(String value) { + return value != null && !value.isBlank(); + } + + private static String defaultCachePath() { + return Path.of(System.getProperty("user.home"), ".primfeed-liker", "liked-posts.txt").toString(); + } + + private static Set parseProfileList(Properties properties, String key) { + String rawValue = readProperty(properties, key, ""); + if (rawValue == null || rawValue.isBlank()) { + return Set.of(); + } + Set values = new LinkedHashSet<>(); + Arrays.stream(rawValue.split(",")) + .map(String::trim) + .filter(value -> !value.isBlank()) + .map(String::toLowerCase) + .forEach(values::add); + return values; + } + + private static int randomBetween(int min, int max) { + return ThreadLocalRandom.current().nextInt(min, max + 1); + } + + private static String readProperty(Properties properties, String key, String defaultValue) { + String systemValue = System.getProperty(key); + if (systemValue != null) { + return systemValue; + } + String value = properties.getProperty(key); + return value == null ? defaultValue : value; + } + + private static void pause(long millis) { + try { + Thread.sleep(millis); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + } + } + + private static class DoomscrollState { + private final Set seenItemKeys = new HashSet<>(); + private final Set autoLikedKeys; + private final LikedCache likedCache; + private int scrollCount; + private int processedCount; + private int likesGiven; + private String targetProfileHit; + private boolean refreshRequested; + private int consecutiveLikedCount; + private boolean stopRequested; + + private DoomscrollState(LikedCache likedCache) { + this.likedCache = likedCache; + this.autoLikedKeys = new HashSet<>(likedCache.keys()); + } + } + + private static final class LikedCache { + private final Path path; + private final Set keys; + private final boolean enabled; + private final int maxEntries; + + private LikedCache(Path path, Set keys, boolean enabled, int maxEntries) { + this.path = path; + this.keys = keys; + this.enabled = enabled; + this.maxEntries = maxEntries; + } + + private static LikedCache disabled() { + return new LikedCache(null, new HashSet<>(), false, 0); + } + + private Set keys() { + return keys; + } + + private boolean contains(String key) { + return enabled && keys.contains(key); + } + + private void add(String key) { + if (!enabled || !hasText(key) || keys.contains(key)) { + return; + } + keys.add(key); + if (trimToMaxEntries()) { + rewrite(); + return; + } + appendLine(key); + } + + private boolean trimToMaxEntries() { + if (!enabled || maxEntries <= 0 || keys.size() <= maxEntries) { + return false; + } + boolean trimmed = false; + while (keys.size() > maxEntries) { + keys.remove(keys.iterator().next()); + trimmed = true; + } + return trimmed; + } + + private void rewrite() { + try { + if (path.getParent() != null) { + Files.createDirectories(path.getParent()); + } + Files.write(path, keys, StandardCharsets.UTF_8, + StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING); + } catch (IOException exception) { + LOGGER.warn("Failed to rewrite cache file: {}", path, exception); + } + } + + private void appendLine(String key) { + try { + if (path.getParent() != null) { + Files.createDirectories(path.getParent()); + } + Files.writeString( + path, + key + System.lineSeparator(), + StandardCharsets.UTF_8, + StandardOpenOption.CREATE, + StandardOpenOption.APPEND); + } catch (IOException exception) { + LOGGER.warn("Failed to update cache file: {}", path, exception); + } + } + } + + private record ProfileDetails(String name, String href) { + String key() { + if (href != null && !href.isBlank()) { + return href.toLowerCase(); + } + if (name != null && !name.isBlank()) { + return name.toLowerCase(); + } + return null; + } + } + + private record Config( + String baseUrl, + String browser, + boolean headless, + String chromeUserDataDir, + String chromeProfileDir, + String firefoxProfileDir, + String feedItemSelector, + String profileNameSelector, + String likeButtonSelector, + String itemIdAttribute, + String likedStateAttribute, + String likedStateValue, + String targetProfile, + boolean likeTargetProfile, + int maxScrolls, + int maxItems, + int maxLinkedItems, + int rescanWindowSize, + String cachePath, + int cacheMaxEntries, + int maxConsecutiveLiked, + Set skipProfiles, + int scrollMinPx, + int scrollMaxPx, + int minDelayMs, + int maxDelayMs, + int likeDelayMinMs, + int likeDelayMaxMs, + int longPauseEvery, + int longPauseMinMs, + int longPauseMaxMs, + int manualLoginSeconds, + int waitTimeoutSeconds, + int implicitWaitSeconds) { + + static Config from(Properties properties) { + return new Config( + property(properties, "primfeed.base-url", "https://www.primfeed.com/"), + property(properties, "primfeed.browser", "chrome"), + booleanProperty(properties, "primfeed.headless", false), + property(properties, "primfeed.chrome-user-data-dir", ""), + property(properties, "primfeed.chrome-profile-dir", ""), + property(properties, "primfeed.firefox-profile-dir", ""), + property(properties, "primfeed.feed-item-selector", ""), + property(properties, "primfeed.profile-name-selector", ""), + property(properties, "primfeed.like-button-selector", ""), + property(properties, "primfeed.item-id-attribute", ""), + property(properties, "primfeed.liked-state-attribute", "aria-pressed"), + property(properties, "primfeed.liked-state-value", "true"), + property(properties, "primfeed.target-profile", ""), + booleanProperty(properties, "primfeed.like-target-profile", false), + intProperty(properties, "primfeed.max-scrolls", 120), + intProperty(properties, "primfeed.max-items", 400), + intProperty(properties, "primfeed.max-linked-items", 4), + intProperty(properties, "primfeed.rescan-window-size", 12), + property(properties, "primfeed.cache-path", defaultCachePath()), + intProperty(properties, "primfeed.cache-max-entries", 50000), + intProperty(properties, "primfeed.max-consecutive-liked", 0), + parseProfileList(properties, "primfeed.skip-profiles"), + intProperty(properties, "primfeed.scroll-min-px", 400), + intProperty(properties, "primfeed.scroll-max-px", 900), + intProperty(properties, "primfeed.min-delay-ms", 250), + intProperty(properties, "primfeed.max-delay-ms", 900), + intProperty(properties, "primfeed.like-delay-min-ms", 500), + intProperty(properties, "primfeed.like-delay-max-ms", 1000), + intProperty(properties, "primfeed.long-pause-every", 12), + intProperty(properties, "primfeed.long-pause-min-ms", 2500), + intProperty(properties, "primfeed.long-pause-max-ms", 6000), + intProperty(properties, "primfeed.manual-login-seconds", 20), + intProperty(properties, "primfeed.wait-timeout-seconds", 15), + intProperty(properties, "primfeed.implicit-wait-seconds", 0)); + } + + void validate() { + requireNonBlank(baseUrl, "primfeed.base-url"); + requireNonBlank(feedItemSelector, "primfeed.feed-item-selector"); + requireNonBlank(profileNameSelector, "primfeed.profile-name-selector"); + requireNonBlank(likeButtonSelector, "primfeed.like-button-selector"); + requireNonBlank(targetProfile, "primfeed.target-profile"); + requirePositive(maxScrolls, "primfeed.max-scrolls"); + requirePositive(maxItems, "primfeed.max-items"); + requireNonNegative(maxLinkedItems, "primfeed.max-linked-items"); + requireNonNegative(rescanWindowSize, "primfeed.rescan-window-size"); + requireNonNegative(cacheMaxEntries, "primfeed.cache-max-entries"); + requireNonNegative(maxConsecutiveLiked, "primfeed.max-consecutive-liked"); + requireNonNegative(manualLoginSeconds, "primfeed.manual-login-seconds"); + requireNonNegative(waitTimeoutSeconds, "primfeed.wait-timeout-seconds"); + requireNonNegative(minDelayMs, "primfeed.min-delay-ms"); + requireNonNegative(maxDelayMs, "primfeed.max-delay-ms"); + requireNonNegative(likeDelayMinMs, "primfeed.like-delay-min-ms"); + requireNonNegative(likeDelayMaxMs, "primfeed.like-delay-max-ms"); + requireNonNegative(scrollMinPx, "primfeed.scroll-min-px"); + requireNonNegative(scrollMaxPx, "primfeed.scroll-max-px"); + requireNonNegative(longPauseMinMs, "primfeed.long-pause-min-ms"); + requireNonNegative(longPauseMaxMs, "primfeed.long-pause-max-ms"); + requireNonNegative(implicitWaitSeconds, "primfeed.implicit-wait-seconds"); + } + + private static String property(Properties properties, String key, String defaultValue) { + return readProperty(properties, key, defaultValue); + } + + private static boolean booleanProperty(Properties properties, String key, boolean defaultValue) { + String value = property(properties, key, String.valueOf(defaultValue)); + return Boolean.parseBoolean(value.trim()); + } + + private static int intProperty(Properties properties, String key, int defaultValue) { + String value = property(properties, key, String.valueOf(defaultValue)); + try { + return Integer.parseInt(value.trim()); + } catch (NumberFormatException exception) { + throw new IllegalArgumentException("Invalid number for " + key + ": " + value); + } + } + + private static void requireNonBlank(String value, String key) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("Missing configuration: " + key); + } + } + + private static void requirePositive(int value, String key) { + if (value <= 0) { + throw new IllegalArgumentException("Expected positive value for " + key + ": " + value); + } + } + + private static void requireNonNegative(int value, String key) { + if (value < 0) { + throw new IllegalArgumentException("Expected non-negative value for " + key + ": " + value); + } + } + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 4e3108d..957d3e8 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1 +1,39 @@ spring.application.name=PrimfeedLiker +primfeed.base-url=https://www.primfeed.com/ +primfeed.browser=chrome +primfeed.headless=false +primfeed.chrome-user-data-dir=/Users/mita/Library/Application Support/Google/Chrome-Primfeed +primfeed.chrome-profile-dir=Default +primfeed.firefox-profile-dir= +primfeed.feed-item-selector=div#info-media +primfeed.profile-name-selector=a[href^="/"]:not([href*="/posts/"]) span.PJLV +primfeed.like-button-selector=button.c-bOoEnV +primfeed.target-profile=Amanda3345 Resident +#primfeed.feed-item-selector= +#primfeed.profile-name-selector= +#primfeed.like-button-selector= +#primfeed.item-id-attribute= +primfeed.liked-state-attribute=aria-pressed +primfeed.liked-state-value=true +#primfeed.target-profile= +primfeed.like-target-profile=true +primfeed.max-scrolls=500 +primfeed.max-items=1000 +primfeed.max-linked-items=4 +primfeed.rescan-window-size=12 +primfeed.cache-path=${user.home}/.primfeed-liker/liked-posts.txt +primfeed.cache-max-entries=50000 +primfeed.max-consecutive-liked=30 +primfeed.skip-profiles=Horny Scheflo,Black Corleone +primfeed.scroll-min-px=400 +primfeed.scroll-max-px=900 +primfeed.min-delay-ms=250 +primfeed.max-delay-ms=900 +primfeed.like-delay-min-ms=500 +primfeed.like-delay-max-ms=1000 +primfeed.long-pause-every=12 +primfeed.long-pause-min-ms=2500 +primfeed.long-pause-max-ms=6000 +primfeed.manual-login-seconds=5 +primfeed.wait-timeout-seconds=5 +primfeed.implicit-wait-seconds=0 diff --git a/src/test/java/de/tamse/primfeedliker/PrimfeedLikerApplicationTests.java b/src/test/java/de/tamse/primfeedliker/PrimfeedLikerApplicationTests.java index 945449c..019229e 100644 --- a/src/test/java/de/tamse/primfeedliker/PrimfeedLikerApplicationTests.java +++ b/src/test/java/de/tamse/primfeedliker/PrimfeedLikerApplicationTests.java @@ -6,8 +6,8 @@ import org.springframework.boot.test.context.SpringBootTest; @SpringBootTest class PrimfeedLikerApplicationTests { - @Test - void contextLoads() { - } + @Test + void contextLoads() { + } }