chore: Korrekturen
This commit is contained in:
parent
174d3725e2
commit
8a94cb0392
44
AGENTS.md
Normal file
44
AGENTS.md
Normal 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 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`.
|
||||||
39
README.md
39
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.
|
||||||
7
pom.xml
7
pom.xml
@ -28,6 +28,7 @@
|
|||||||
</scm>
|
</scm>
|
||||||
<properties>
|
<properties>
|
||||||
<java.version>17</java.version>
|
<java.version>17</java.version>
|
||||||
|
<selenium.version>4.28.1</selenium.version>
|
||||||
</properties>
|
</properties>
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<dependency>
|
<dependency>
|
||||||
@ -35,6 +36,12 @@
|
|||||||
<artifactId>spring-boot-starter</artifactId>
|
<artifactId>spring-boot-starter</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.seleniumhq.selenium</groupId>
|
||||||
|
<artifactId>selenium-java</artifactId>
|
||||||
|
<version>${selenium.version}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-test</artifactId>
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1 +1,39 @@
|
|||||||
spring.application.name=PrimfeedLiker
|
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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user