chore: Korrekturen

This commit is contained in:
mita 2026-01-31 11:23:57 +01:00
parent 174d3725e2
commit 8a94cb0392
7 changed files with 1076 additions and 54 deletions

44
AGENTS.md Normal file
View File

@ -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 projects 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`.

View File

@ -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.

103
pom.xml
View File

@ -1,54 +1,61 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.0.2</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>de.tamse</groupId>
<artifactId>PrimfeedLiker</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>PrimfeedLiker</name>
<description>PrimfeedLiker</description>
<url/>
<licenses>
<license/>
</licenses>
<developers>
<developer/>
</developers>
<scm>
<connection/>
<developerConnection/>
<tag/>
<url/>
</scm>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.0.2</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>de.tamse</groupId>
<artifactId>PrimfeedLiker</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>PrimfeedLiker</name>
<description>PrimfeedLiker</description>
<url/>
<licenses>
<license/>
</licenses>
<developers>
<developer/>
</developers>
<scm>
<connection/>
<developerConnection/>
<tag/>
<url/>
</scm>
<properties>
<java.version>17</java.version>
<selenium.version>4.28.1</selenium.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<dependency>
<groupId>org.seleniumhq.selenium</groupId>
<artifactId>selenium-java</artifactId>
<version>${selenium.version}</version>
</dependency>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@ -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);
}
}

View File

@ -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<String> 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<WebElement> 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<WebElement> 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<WebElement> 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<WebElement> 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<WebElement> 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<WebElement> 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<WebElement> 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<WebElement> 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 "<unknown>";
}
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<String> keys = new LinkedHashSet<>();
if (Files.exists(path)) {
try (Stream<String> 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<String> 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<String> parseProfileList(Properties properties, String key) {
String rawValue = readProperty(properties, key, "");
if (rawValue == null || rawValue.isBlank()) {
return Set.of();
}
Set<String> 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<String> seenItemKeys = new HashSet<>();
private final Set<String> 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<String> keys;
private final boolean enabled;
private final int maxEntries;
private LikedCache(Path path, Set<String> 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<String> 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<String> 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);
}
}
}
}

View File

@ -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

View File

@ -6,8 +6,8 @@ import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class PrimfeedLikerApplicationTests {
@Test
void contextLoads() {
}
@Test
void contextLoads() {
}
}