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() {
+ }
}