From c0e8c52503928c45847e65660e1ac6dc9b54f52b Mon Sep 17 00:00:00 2001 From: slingvector Date: Sat, 28 Mar 2026 03:32:00 +0530 Subject: [PATCH] feat: add linkedin feed automation documentation and worker scripts --- backend/ai_brain/llm_client.py | 63 ++- backend/api_server.py | 33 ++ backend/appium_service/adb_client.py | 12 +- .../appium_service/mobile_driver_service.py | 10 + .../pages/linkedin/feed_page.py | 86 +++- .../appium_service/pages/linkedin/job_page.py | 34 +- backend/linkedin_feed_automation.md | 79 ++++ backend/linkedin_feed_worker.py | 337 +++++++++++++++ backend/linkedin_feed_worker_final.py | 342 +++++++++++++++ backend/requirements.txt | 6 +- frontend/app/page.tsx | 389 +++++++++++------- frontend/package-lock.json | 10 + frontend/package.json | 1 + start_all.sh | 1 + 14 files changed, 1236 insertions(+), 167 deletions(-) create mode 100644 backend/linkedin_feed_automation.md create mode 100644 backend/linkedin_feed_worker.py create mode 100644 backend/linkedin_feed_worker_final.py diff --git a/backend/ai_brain/llm_client.py b/backend/ai_brain/llm_client.py index 55754c8..9428b2b 100644 --- a/backend/ai_brain/llm_client.py +++ b/backend/ai_brain/llm_client.py @@ -14,7 +14,7 @@ pass from openai import OpenAI -import google.generativeai as genai +from google import genai logger = logging.getLogger(__name__) @@ -37,9 +37,8 @@ def __init__(self, provider="openai"): http_client=httpx.Client() ) elif self.provider == "gemini" and self.gemini_key: - genai.configure(api_key=self.gemini_key) - # gemini-2.0-flash is available for this key - self.model = genai.GenerativeModel('gemini-2.0-flash') + self.client = genai.Client(api_key=self.gemini_key) + self.model_name = 'gemini-2.5-flash' else: logger.warning("No API Key found for AI Brain. AI features will fail.") @@ -133,7 +132,14 @@ def generate_linkedin_comment(self, image_path): Do not sound like a bot. Be encouraging or ask a relevant question. """ - response = self.model.generate_content([prompt, img]) + try: + response = self.client.models.generate_content( + model=self.model_name, + contents=[prompt, img] + ) + except Exception as api_err: + raise api_err + return response.text except Exception as e: logger.error(f"Gemini Error: {e}") @@ -217,9 +223,10 @@ def solve_form(self, image_path, profile_context): img = PIL.Image.open(image_path) # Check for other models if flash fails - # Try standard gemini-pro-vision if flash is 404ing? - # Or gemini-1.5-flash-latest - response = self.model.generate_content([prompt, img]) + response = self.client.models.generate_content( + model=self.model_name, + contents=[prompt, img] + ) answer_json_str = response.text.strip() # Clean JSON markdown @@ -235,3 +242,43 @@ def solve_form(self, image_path, profile_context): return {} return {} # Fallback + + def enhance_prompt(self, raw_prompt): + """ + Takes a raw, sparse user prompt and expands it into a highly descriptive + and structured prompt using the LLM. + """ + system_instructions = ( + "You are an expert Prompt Engineer. The user will give you a raw, short, or confusing prompt. " + "Your job is to understand their intent and rewrite it into a highly descriptive, " + "structured, and effective prompt that could be used for an AI agent or LLM.\n" + "Return ONLY the rewritten prompt. Do not include introductory text." + ) + + if self.provider == "openai": + try: + response = self.client.chat.completions.create( + model="gpt-4o", + messages=[ + {"role": "system", "content": system_instructions}, + {"role": "user", "content": f"Raw prompt: {raw_prompt}"} + ] + ) + return response.choices[0].message.content.strip() + except Exception as e: + logger.error(f"OpenAI enhance_prompt failed: {e}") + return raw_prompt + + elif self.provider == "gemini": + try: + full_prompt = f"{system_instructions}\n\nRaw prompt: {raw_prompt}" + response = self.client.models.generate_content( + model=self.model_name, + contents=full_prompt + ) + return response.text.strip() + except Exception as e: + logger.error(f"Gemini enhance_prompt failed: {e}") + return raw_prompt + + return raw_prompt diff --git a/backend/api_server.py b/backend/api_server.py index 6a5711c..9741c97 100644 --- a/backend/api_server.py +++ b/backend/api_server.py @@ -6,6 +6,8 @@ import time import os from dotenv import load_dotenv +from pydantic import BaseModel +import backend.ai_brain.llm_client as llm_client # Load environment variables load_dotenv() @@ -86,3 +88,34 @@ def emit(self, record): async def get_logs(): """Returns the last 50 logs.""" return {"logs": list(reversed(memory_handler.log_records))} + +class PromptRequest(BaseModel): + raw_prompt: str + +@app.post("/api/save_prompt") +async def save_prompt(request: PromptRequest): + """ + Receives a raw prompt, enhances it via AI Brain, + and saves both to a local markdown file. + """ + raw = request.raw_prompt + logger.info(f"Processing raw prompt: {raw}") + + # Init Brain + brain = llm_client.AIBrain(provider='gemini') + enhanced = brain.enhance_prompt(raw) + + # Save to file + file_path = os.path.join(os.path.dirname(__file__), "saved_prompts.md") + + import datetime + timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + with open(file_path, "a", encoding="utf-8") as f: + f.write(f"## Saved on {timestamp}\n") + f.write(f"**Raw Input:** {raw}\n\n") + f.write(f"**Descriptive Prompt:**\n```\n{enhanced}\n```\n\n") + f.write("---\n\n") + + logger.info("Prompt successfully enhanced and saved.") + return {"status": "success", "raw": raw, "enhanced": enhanced} diff --git a/backend/appium_service/adb_client.py b/backend/appium_service/adb_client.py index f8a4812..266c031 100644 --- a/backend/appium_service/adb_client.py +++ b/backend/appium_service/adb_client.py @@ -53,7 +53,7 @@ def input_text(self, text): return self._run_command(["shell", "input", "text", escaped_text]) def press_home(self): - return self._run_command(["shell", "input", "keyevent", "KEYWORDS_HOME"]) + return self._run_command(["shell", "input", "keyevent", "KEYCODE_HOME"]) def press_back(self): """Simulate Back button.""" @@ -64,7 +64,7 @@ def wake_screen(self): # Check if screen is on dump = self._run_command(["shell", "dumpsys", "power"]) if "mWakefulness=Awake" not in dump: - self._run_command(["shell", "input", "keyevent", "KW_POWER"]) + self._run_command(["shell", "input", "keyevent", "KEYCODE_POWER"]) self._run_command(["shell", "input", "keyevent", "82"]) # Unlock def get_screenshot(self, save_path="screen.png"): @@ -76,3 +76,11 @@ def get_screenshot(self, save_path="screen.png"): def start_app(self, package_name): """Launches an app via monkey (often more reliable than am start for some apps).""" self._run_command(["shell", "monkey", "-p", package_name, "-c", "android.intent.category.LAUNCHER", "1"]) + + def get_clipboard(self): + """Retrieves text from the Android clipboard.""" + # Use am broadcast to get clipboard (depends on version, but usually works on standard builds) + # Fallback: Appium driver.get_clipboard_text() is used in MobileDriverService + # For direct ADB without extra helpers, it's tricky. + # We will use the service's driver in the final implementation. + return self._run_command(["shell", "am", "broadcast", "-a", "com.android.clipboard.GET_TEXT"]) diff --git a/backend/appium_service/mobile_driver_service.py b/backend/appium_service/mobile_driver_service.py index 29898c7..fac7767 100644 --- a/backend/appium_service/mobile_driver_service.py +++ b/backend/appium_service/mobile_driver_service.py @@ -98,6 +98,16 @@ def stop_session(self): self.discard_recording() # Cleanup if not already done self.driver.quit() + def get_clipboard_text(self): + """Returns the text currently on the device clipboard.""" + if not self.driver: + self.start_session() + try: + return self.driver.get_clipboard_text() + except Exception as e: + logger.error(f"Failed to get clipboard: {e}") + return None + def find_and_click(self, xpath, timeout=10): """Robust click with wait.""" if not self.driver: diff --git a/backend/appium_service/pages/linkedin/feed_page.py b/backend/appium_service/pages/linkedin/feed_page.py index d8ed6bb..7cdf1c7 100644 --- a/backend/appium_service/pages/linkedin/feed_page.py +++ b/backend/appium_service/pages/linkedin/feed_page.py @@ -10,6 +10,25 @@ class LinkedInFeedPage(BasePage): Responsible for: Verifying feed state, scrolling, and basic interaction. """ + def dismiss_popups(self): + """Identifies and dismisses blocking popups like Ads or Premium Prompts.""" + popups_dismissed = False + popup_id = "com.linkedin.android:id/ad_non_modal_dialog_close_button" + if self.device.click_element_by_id(popup_id): + logger.warning("⚠️ Dismissed Popup via ID.") + popups_dismissed = True + time.sleep(1) + + dismiss_texts = ["No thanks", "Not now", "Skip", "Close", "Got it"] + for text in dismiss_texts: + if self.device.click_text(text): + logger.warning(f"⚠️ Dismissed Popup via text '{text}'.") + popups_dismissed = True + time.sleep(1) + break + + return popups_dismissed + def ensure_app_open(self): """ Ensures the app is open and on the Feed. @@ -25,10 +44,7 @@ def ensure_app_open(self): # 2. Check for Popup logger.info("Feed not found. Checking for popups...") - popup_id = "com.linkedin.android:id/ad_non_modal_dialog_close_button" - if self.device.click_element_by_id(popup_id): - logger.info("⚠️ Dismissed Popup in Feed Page.") - time.sleep(2) + if self.dismiss_popups(): if self.device.wait_for_text(["Search", "Messaging", "Home"], timeout=5): return True @@ -37,6 +53,8 @@ def ensure_app_open(self): def scroll_feed(self, swipes=3): """Performs scrolling action on the feed.""" logger.info(f"Scrolling feed {swipes} times...") + self.dismiss_popups() + for i in range(swipes): self.scroll_down() time.sleep(1.5) # Wait for content to settle @@ -126,3 +144,63 @@ def leave_comment(self, comment_text): logger.warning("Could not find 'Post' or 'Comment' or 'Send' button.") return False + + def get_post_link(self): + """ + Retrieves the sharing link for the current post at the top of the feed. + Returns the URL as a string, or None if failed. + """ + logger.info("Attempting to get post link...") + + # 1. Click 'Send' + if not self.device.click_text("Send", timeout=5): + logger.error("Could not find 'Send' button for the post.") + return None + + time.sleep(2) # Wait for share menu + + # 2. Click 'Copy link' + if not self.device.click_text("Copy link", timeout=5): + logger.error("Could not find 'Copy link' in the share menu.") + # Try to dismiss the menu if it's stuck + self.device.adb.press_back() + return None + + # 3. Retrieve from clipboard + time.sleep(1) + link = self.device.get_clipboard_text() + + if link and "linkedin.com/posts" in link: + # Clean URL: Strip tracking params (everything after ?) + if "?" in link: + link = link.split("?")[0] + + logger.info(f"✅ Extracted post link (cleaned): {link}") + return link + else: + logger.warning(f"Unexpected clipboard content: {link}") + return None + + def get_feed_links(self, count=5): + """ + Scans the feed and extracts links for multiple posts. + """ + logger.info(f"Scanning feed for {count} post links...") + links = [] + seen = set() + + retries = 0 + while len(links) < count and retries < count * 2: + link = self.get_post_link() + if link and link not in seen: + links.append(link) + seen.add(link) + logger.info(f"Progress: {len(links)}/{count}") + + # Scroll to next post + logger.info("Scrolling to next post...") + self.scroll_down() + time.sleep(2) + retries += 1 + + return links diff --git a/backend/appium_service/pages/linkedin/job_page.py b/backend/appium_service/pages/linkedin/job_page.py index d65ec96..0c9124a 100644 --- a/backend/appium_service/pages/linkedin/job_page.py +++ b/backend/appium_service/pages/linkedin/job_page.py @@ -10,19 +10,34 @@ class LinkedInJobPage(BasePage): Responsible for: Navigating to jobs, searching, and filtering. """ - def go_to_jobs(self): - """Clicks the 'Jobs' tab. Handles Payment Popup if present.""" - logger.info("Navigating to Jobs tab...") + def dismiss_popups(self): + """Identifies and dismisses blocking popups like Ads or Premium Prompts.""" + popups_dismissed = False - # 0. Check for blocking Popups (Payment Problem etc) + # 1. Close Button by ID (e.g. ad_non_modal_dialog_close_button) popup_id = "com.linkedin.android:id/ad_non_modal_dialog_close_button" if self.device.click_element_by_id(popup_id): - logger.info("⚠️ Dismissed Payment/Promo Popup.") + logger.warning("⚠️ Dismissed Ad/Promo Popup via ID.") + popups_dismissed = True time.sleep(1) - if self.device.click_text("No thanks"): - logger.info("⚠️ Dismissed Popup via 'No thanks'.") - time.sleep(1) + # 2. Text based dismissals + dismiss_texts = ["No thanks", "Not now", "Skip", "Close", "Got it"] + for text in dismiss_texts: + if self.device.click_text(text): + logger.warning(f"⚠️ Dismissed Popup via text '{text}'.") + popups_dismissed = True + time.sleep(1) + break + + return popups_dismissed + + def go_to_jobs(self): + """Clicks the 'Jobs' tab. Handles Payment Popup if present.""" + logger.info("Navigating to Jobs tab...") + + # 0. Check for blocking Popups + self.dismiss_popups() # 1. Look for the Jobs tab # ID is usually stable: com.linkedin.android:id/tab_jobs @@ -49,6 +64,8 @@ def search_jobs(self, keyword): """ logger.info(f"Searching for jobs: '{keyword}'") + self.dismiss_popups() + # 1. Find Search Entry Point (Top Bar) # Often "Search jobs" text or ID com.linkedin.android:id/search_bar_text search_clicked = False @@ -92,6 +109,7 @@ def filter_easy_apply(self): Clicks the 'Easy Apply' filter button. """ logger.info("Filtering for Easy Apply...") + self.dismiss_popups() # Method: Look for "Easy Apply" text in the horizontal scroll view of filters # It usually appears at the top after search. diff --git a/backend/linkedin_feed_automation.md b/backend/linkedin_feed_automation.md new file mode 100644 index 0000000..f8f8c11 --- /dev/null +++ b/backend/linkedin_feed_automation.md @@ -0,0 +1,79 @@ +# LinkedIn Feed Automation Overview + +This document explains how the automation system interacts with the LinkedIn Home Feed, specifically focusing on scrolling mechanics and post link extraction. + +## Core Components + +The LinkedIn feed automation logic is primarily implemented in: +- [LinkedInFeedPage](file:///Users/manthan/Documents/RoboticDevice/backend/appium_service/pages/linkedin/feed_page.py): High-level feed interactions. +- [BasePage](file:///Users/manthan/Documents/RoboticDevice/backend/appium_service/pages/base_page.py): Generic device gestures like scrolling. +- [MobileDriverService](file:///Users/manthan/Documents/RoboticDevice/backend/appium_service/mobile_driver_service.py): Low-level Appium driver wrapper. + +--- + +## 1. Scrolling Mechanics + +Scrolling is handled through coordinate-based swipe gestures to ensure compatibility across different screen sizes. + +### Gesture Implementation +In `BasePage.py`, the `scroll_down()` method calculates screen dimensions and performs a swipe from the bottom 80% to the top 20% of the screen. + +```python +def scroll_down(self): + size = self.driver.get_window_size() + start_y = int(size['height'] * 0.8) + end_y = int(size['height'] * 0.2) + start_x = int(size['width'] * 0.5) + + self.driver.swipe(start_x, start_y, start_x, end_y, 800) +``` + +### Feed Scrolling Logic +In `LinkedInFeedPage.py`, the `scroll_feed(swipes=3)` method: +1. **Dismisses Popups**: Calls `dismiss_popups()` to handle blocking elements (e.g., ads, premium prompts). +2. **Performs Swipes**: Calls `scroll_down()` multiple times. +3. **Settling Time**: Includes a `time.sleep(1.5)` after each swipe to allow content to load and stabilize. + +--- + +## 2. Post Link Extraction + +Extracting a post's URL involves simulating the user's "Share" flow and retrieving the link from the system clipboard. + +### Step-by-Step Flow (`get_post_link` method) + +1. **Click 'Send'**: The automation finds and clicks the "Send" button on the post. + ```python + self.device.click_text("Send", timeout=5) + ``` +2. **Open Share Menu**: It waits for the LinkedIn sharing modal to appear. +3. **Click 'Copy link'**: It clicks the "Copy link" option within the modal. + ```python + self.device.click_text("Copy link", timeout=5) + ``` +4. **Clipboard Extraction**: The link is retrieved from the Android/iOS clipboard. + ```python + link = self.device.get_clipboard_text() + ``` +5. **URL Cleaning**: Any tracking parameters (e.g., `?trackingId=...`) are stripped to maintain a clean post link. + ```python + if "?" in link: + link = link.split("?")[0] + ``` + +--- + +## 3. Batch Extraction + +The `get_feed_links(count=5)` method automates the discovery of multiple posts: +- It iterates `count` times. +- For each post, it extracts the link and adds it to a `seen` set to avoid duplicates. +- It then scrolls down to move the next post to the top of the viewport. + +--- + +## Popup Handling + +The `dismiss_popups()` method is critical for reliability. It checks for common blocking elements: +- Non-modal dialog close buttons (`com.linkedin.android:id/ad_non_modal_dialog_close_button`). +- Common dismissal text patterns: "No thanks", "Not now", "Skip", "Close", "Got it". diff --git a/backend/linkedin_feed_worker.py b/backend/linkedin_feed_worker.py new file mode 100644 index 0000000..29cd41c --- /dev/null +++ b/backend/linkedin_feed_worker.py @@ -0,0 +1,337 @@ +""" +linkedin_feed_worker.py +----------------------- +Fetches LinkedIn feed posts, filters by hashtags, and saves to SQLite. + +Auth priority: + 1. LINKEDIN_LI_AT cookie (preferred — password never touched) + 2. LINKEDIN_EMAIL + LINKEDIN_PASSWORD (fallback) + +Credentials are loaded from a .env file (never hardcode them). + +Install: + pip install linkedin-api python-dotenv + +Setup: + Create a .env file in the same directory as this script: + + # Option 1 — cookie auth (recommended) + LINKEDIN_LI_AT=AQEDATd2... + + # Option 2 — password auth (fallback if no cookie) + LINKEDIN_EMAIL=you@example.com + LINKEDIN_PASSWORD=yourpassword + + Then add to .gitignore: + .env + posts.db + +Usage: + python linkedin_feed_worker.py +""" + +import logging +import os +import random +import re +import sqlite3 +import time +from typing import Optional + +from dotenv import load_dotenv +from linkedin_api import Linkedin +from linkedin_api.client import ChallengeException, UnauthorizedException + +# load .env file — must be before os.environ.get() calls +load_dotenv() + +# --------------------------------------------------------------------------- +# CONFIG — edit these +# --------------------------------------------------------------------------- +DB_PATH = "posts.db" + +# Hashtag filter — lowercase, with or without # +# Set to [] to keep all posts regardless of hashtags +HASHTAG_FILTER = [] + +# Engagement thresholds (requires FETCH_ENGAGEMENT = True) +MIN_REACTIONS = 0 +MIN_COMMENTS = 0 + +# NOTE: get_post_reactions() returns 500 in linkedin-api 2.2.0 +# Keep False until the library is updated +FETCH_ENGAGEMENT = False + +# Fetch config +MAX_POSTS_PER_RUN = 500 +FETCH_BATCH_SIZE = 100 +BASE_DELAY_S = 2.0 +JITTER_S = 1.5 +ENGAGEMENT_DELAY_S = 1.5 +# --------------------------------------------------------------------------- + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", +) +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Auth — cookie first, password fallback +# --------------------------------------------------------------------------- +def get_linkedin_client() -> Linkedin: + """ + Auth priority: + 1. LINKEDIN_LI_AT cookie → no password ever used + 2. LINKEDIN_EMAIL + LINKEDIN_PASSWORD → fallback + """ + li_at = os.environ.get("LINKEDIN_LI_AT", "").strip() + email = os.environ.get("LINKEDIN_EMAIL", "").strip() + password = os.environ.get("LINKEDIN_PASSWORD", "").strip() + + if li_at: + logger.info("Auth method : cookie (li_at) [password never used]") + try: + return Linkedin("", "", cookies={"li_at": li_at}) + except Exception as exc: + logger.warning("Cookie auth failed (%s) — trying password fallback", exc) + + if email and password: + logger.info("Auth method : email/password (%s)", email) + try: + return Linkedin(email, password) + except ChallengeException: + raise SystemExit( + "LinkedIn triggered a 2FA / CAPTCHA challenge.\n" + "Log in manually in a browser, complete the challenge, then retry.\n" + "Tip: grab the fresh li_at cookie after solving and use that instead." + ) + except UnauthorizedException: + raise SystemExit( + "Bad credentials — check LINKEDIN_EMAIL / LINKEDIN_PASSWORD in your .env file." + ) + + raise SystemExit( + "No credentials found. Add one of the following to your .env file:\n\n" + " # Option 1 — recommended (cookie auth)\n" + " LINKEDIN_LI_AT=AQEDATd2...\n\n" + " # Option 2 — password auth\n" + " LINKEDIN_EMAIL=you@example.com\n" + " LINKEDIN_PASSWORD=yourpassword\n\n" + "Get li_at from: Chrome DevTools → Application → Cookies → www.linkedin.com" + ) + + +# --------------------------------------------------------------------------- +# DB +# --------------------------------------------------------------------------- +def init_db(path: str = DB_PATH) -> sqlite3.Connection: + conn = sqlite3.connect(path) + conn.execute(""" + CREATE TABLE IF NOT EXISTS posts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + post_link TEXT NOT NULL UNIQUE, + author_name TEXT, + content TEXT, + hashtags TEXT, + reactions INTEGER, + comments INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """) + conn.commit() + return conn + + +def get_existing_links(conn: sqlite3.Connection) -> set[str]: + rows = conn.execute("SELECT post_link FROM posts").fetchall() + return {row[0] for row in rows} + + +def insert_post( + conn: sqlite3.Connection, + post_link: str, + author_name: Optional[str], + content: Optional[str], + hashtags: Optional[str], + reactions: Optional[int], + comments: Optional[int], +) -> bool: + try: + conn.execute( + """ + INSERT OR IGNORE INTO posts + (post_link, author_name, content, hashtags, reactions, comments) + VALUES (?, ?, ?, ?, ?, ?) + """, + (post_link, author_name, content, hashtags, reactions, comments), + ) + conn.commit() + return conn.execute("SELECT changes()").fetchone()[0] == 1 + except Exception as exc: + logger.error("DB insert failed for %s: %s", post_link, exc) + return False + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +def extract_hashtags(text: str) -> list[str]: + return [tag.lower() for tag in re.findall(r"#\w+", text)] + + +def passes_hashtag_filter(hashtags_in_post: list[str]) -> bool: + if not HASHTAG_FILTER: + return True + targets = {t.lower().lstrip("#") for t in HASHTAG_FILTER} + post_tags = {t.lstrip("#") for t in hashtags_in_post} + return bool(targets & post_tags) + + +def urn_from_url(url: str) -> Optional[str]: + match = re.search(r"(urn:li:activity:\d+)", url) + return match.group(1) if match else None + + +def fetch_engagement(api: Linkedin, url: str) -> tuple[Optional[int], Optional[int]]: + urn = urn_from_url(url) + if not urn: + return None, None + reactions = None + comments = None + try: + data = api.get_post_reactions(urn, limit=1) + if isinstance(data, dict): + reactions = data.get("paging", {}).get("total", len(data.get("elements", []))) + elif isinstance(data, list): + reactions = len(data) + except Exception as exc: + logger.debug("Reaction fetch failed: %s", exc) + time.sleep(ENGAGEMENT_DELAY_S * 0.5) + try: + data = api.get_post_comments(urn, comment_count=1) + if isinstance(data, dict): + comments = data.get("paging", {}).get("total", len(data.get("elements", []))) + elif isinstance(data, list): + comments = len(data) + except Exception as exc: + logger.debug("Comment fetch failed: %s", exc) + return reactions, comments + + +def passes_engagement_filter(reactions: Optional[int], comments: Optional[int]) -> bool: + if not FETCH_ENGAGEMENT: + return True + return (reactions or 0) >= MIN_REACTIONS and (comments or 0) >= MIN_COMMENTS + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- +def run(): + api = get_linkedin_client() + conn = init_db(DB_PATH) + + existing = get_existing_links(conn) + logger.info("DB ready — %d posts already stored", len(existing)) + + if HASHTAG_FILTER: + logger.info("Hashtag filter : %s", ", ".join(HASHTAG_FILTER)) + else: + logger.info("Hashtag filter : none (keeping all posts)") + + if FETCH_ENGAGEMENT: + logger.info("Engagement filter : reactions >= %d, comments >= %d", + MIN_REACTIONS, MIN_COMMENTS) + else: + logger.info("Engagement fetch : disabled") + + fetched_total = 0 + kept = 0 + skipped_hash = 0 + skipped_engage = 0 + skipped_dup = 0 + + while fetched_total < MAX_POSTS_PER_RUN: + batch_size = min(FETCH_BATCH_SIZE, MAX_POSTS_PER_RUN - fetched_total) + logger.info("Fetching up to %d posts (total so far: %d) ...", + batch_size, fetched_total) + + try: + posts = api.get_feed_posts(limit=batch_size) + except Exception as exc: + logger.error("Feed fetch failed: %s", exc) + break + + if not posts: + logger.info("Feed exhausted") + break + + for post in posts: + url = post.get("url", "").strip() + content = post.get("content", "").strip() + author = post.get("author_name", "").strip() + + if not url: + continue + + if url in existing: + skipped_dup += 1 + continue + + hashtags = extract_hashtags(content) + if not passes_hashtag_filter(hashtags): + skipped_hash += 1 + continue + + reactions, comments = None, None + if FETCH_ENGAGEMENT: + reactions, comments = fetch_engagement(api, url) + time.sleep(ENGAGEMENT_DELAY_S + random.uniform(0, 0.5)) + if not passes_engagement_filter(reactions, comments): + skipped_engage += 1 + continue + + inserted = insert_post( + conn, + post_link = url, + author_name = author, + content = content, + hashtags = ",".join(hashtags) if hashtags else None, + reactions = reactions, + comments = comments, + ) + if inserted: + existing.add(url) + kept += 1 + logger.info("Saved [r=%s c=%s] %s", reactions, comments, url) + + fetched_total += len(posts) + + if len(posts) < batch_size: + logger.info("Feed exhausted after %d posts", fetched_total) + break + + delay = BASE_DELAY_S + random.uniform(0, JITTER_S) + logger.info("Sleeping %.1fs ...", delay) + time.sleep(delay) + + conn.close() + + print("\n── Results ─────────────────────────────────") + print(f" Posts fetched : {fetched_total}") + print(f" Saved to DB : {kept}") + print(f" Skipped (duplicate) : {skipped_dup}") + print(f" Skipped (hashtag) : {skipped_hash}") + print(f" Skipped (engagement) : {skipped_engage}") + print(f" DB file : {os.path.abspath(DB_PATH)}") + print("─────────────────────────────────────────────\n") + print("Useful DB queries:") + print(f' sqlite3 {DB_PATH} "SELECT author_name, hashtags, post_link FROM posts LIMIT 10"') + print(f' sqlite3 {DB_PATH} "SELECT COUNT(*) FROM posts"') + print() + + +if __name__ == "__main__": + run() diff --git a/backend/linkedin_feed_worker_final.py b/backend/linkedin_feed_worker_final.py new file mode 100644 index 0000000..5345988 --- /dev/null +++ b/backend/linkedin_feed_worker_final.py @@ -0,0 +1,342 @@ +""" +linkedin_feed_worker.py +----------------------- +Fetches LinkedIn feed posts, filters by hashtags and engagement, +and saves results to a local SQLite DB. + +Works entirely within linkedin-api 2.2.0. + +Install: + pip install linkedin-api + +Usage: + export LINKEDIN_EMAIL="you@example.com" + export LINKEDIN_PASSWORD="yourpassword" + python linkedin_feed_worker.py + +Config (edit the CONFIG block below): + HASHTAG_FILTER — only keep posts containing at least one of these + set to [] to keep all posts regardless of hashtags + MIN_REACTIONS — minimum likes/reactions to keep a post + set to 0 to skip engagement filtering + FETCH_ENGAGEMENT — set to False to skip per-post API calls and run faster + (engagement columns will be NULL in the DB) +""" + +import logging +import os +import random +import re +import sqlite3 +import time +from typing import Optional + +from linkedin_api import Linkedin +from linkedin_api.client import ChallengeException, UnauthorizedException + +# --------------------------------------------------------------------------- +# CONFIG — edit these +# --------------------------------------------------------------------------- +DB_PATH = "posts.db" + +# Hashtag filter — lowercase, with or without # +# Examples: ["#ai", "#python", "#machinelearning"] +# Set to [] to disable and keep all posts +HASHTAG_FILTER = ["#ai", "#python", "#machinelearning", "#llm", "#genai"] +HASHTAG_FILTER = [] +# Minimum engagement thresholds — set to 0 to disable +MIN_REACTIONS = 0 # likes / reactions +MIN_COMMENTS = 0 # comments + +# Set to False for faster runs with no engagement data +FETCH_ENGAGEMENT = False + +# Fetch config +MAX_POSTS_PER_RUN = 50 +FETCH_BATCH_SIZE = 10 +BASE_DELAY_S = 2.0 +JITTER_S = 1.5 + +# Delay between per-post engagement calls (only used if FETCH_ENGAGEMENT=True) +ENGAGEMENT_DELAY_S = 1.5 + +# --------------------------------------------------------------------------- + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", +) +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# DB +# --------------------------------------------------------------------------- +def init_db(path: str = DB_PATH) -> sqlite3.Connection: + conn = sqlite3.connect(path) + conn.execute(""" + CREATE TABLE IF NOT EXISTS posts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + post_link TEXT NOT NULL UNIQUE, + author_name TEXT, + content TEXT, + hashtags TEXT, -- comma-separated hashtags found in content + reactions INTEGER, -- NULL if FETCH_ENGAGEMENT=False + comments INTEGER, -- NULL if FETCH_ENGAGEMENT=False + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """) + conn.commit() + return conn + + +def get_existing_links(conn: sqlite3.Connection) -> set[str]: + rows = conn.execute("SELECT post_link FROM posts").fetchall() + return {row[0] for row in rows} + + +def insert_post( + conn: sqlite3.Connection, + post_link: str, + author_name: Optional[str], + content: Optional[str], + hashtags: Optional[str], + reactions: Optional[int], + comments: Optional[int], +) -> bool: + """Insert one post. Returns True if inserted, False if duplicate.""" + try: + conn.execute( + """ + INSERT OR IGNORE INTO posts + (post_link, author_name, content, hashtags, reactions, comments) + VALUES (?, ?, ?, ?, ?, ?) + """, + (post_link, author_name, content, hashtags, reactions, comments), + ) + conn.commit() + return conn.execute("SELECT changes()").fetchone()[0] == 1 + except Exception as exc: + logger.error("DB insert failed for %s: %s", post_link, exc) + return False + + +# --------------------------------------------------------------------------- +# Hashtag helpers +# --------------------------------------------------------------------------- +def extract_hashtags(text: str) -> list[str]: + """Return all #hashtags found in text, lowercased.""" + return [tag.lower() for tag in re.findall(r"#\w+", text)] + + +def passes_hashtag_filter(hashtags_in_post: list[str]) -> bool: + """Return True if HASHTAG_FILTER is empty or any target tag is present.""" + if not HASHTAG_FILTER: + return True + targets = {t.lower().lstrip("#") for t in HASHTAG_FILTER} + post_tags = {t.lstrip("#") for t in hashtags_in_post} + return bool(targets & post_tags) + + +# --------------------------------------------------------------------------- +# URN extraction (needed for engagement calls) +# --------------------------------------------------------------------------- +def urn_from_url(url: str) -> Optional[str]: + """ + https://www.linkedin.com/feed/update/urn:li:activity:1234567890 + → urn:li:activity:1234567890 + """ + match = re.search(r"(urn:li:activity:\d+)", url) + return match.group(1) if match else None + + +# --------------------------------------------------------------------------- +# Engagement fetch +# --------------------------------------------------------------------------- +def fetch_engagement(api: Linkedin, url: str) -> tuple[Optional[int], Optional[int]]: + """ + Returns (reaction_count, comment_count) for a post. + Uses get_post_reactions() and get_post_comments() from 2.2.0. + Returns (None, None) on any error. + """ + urn = urn_from_url(url) + if not urn: + return None, None + + reactions = None + comments = None + + try: + reaction_data = api.get_post_reactions(urn, limit=1) + # get_post_reactions returns a list of reactor profiles + # the total count isn't directly in the response — use len as a floor + # or check the paging metadata if present + if isinstance(reaction_data, dict): + paging = reaction_data.get("paging", {}) + reactions = paging.get("total", len(reaction_data.get("elements", []))) + elif isinstance(reaction_data, list): + reactions = len(reaction_data) + except Exception as exc: + logger.debug("Reaction fetch failed for %s: %s", url, exc) + + time.sleep(ENGAGEMENT_DELAY_S * 0.5) + + try: + comment_data = api.get_post_comments(urn, comment_count=1) + if isinstance(comment_data, dict): + paging = comment_data.get("paging", {}) + comments = paging.get("total", len(comment_data.get("elements", []))) + elif isinstance(comment_data, list): + comments = len(comment_data) + except Exception as exc: + logger.debug("Comment fetch failed for %s: %s", url, exc) + + return reactions, comments + + +def passes_engagement_filter(reactions: Optional[int], comments: Optional[int]) -> bool: + if not FETCH_ENGAGEMENT: + return True + r = reactions or 0 + c = comments or 0 + return r >= MIN_REACTIONS and c >= MIN_COMMENTS + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- +def run(): + email = os.environ.get("LINKEDIN_EMAIL") + password = os.environ.get("LINKEDIN_PASSWORD") + + if not email or not password: + raise SystemExit( + "Set LINKEDIN_EMAIL and LINKEDIN_PASSWORD environment variables:\n" + " export LINKEDIN_EMAIL='you@example.com'\n" + " export LINKEDIN_PASSWORD='yourpassword'" + ) + + logger.info("Authenticating as %s ...", email) + try: + api = Linkedin(email, password) + except ChallengeException: + raise SystemExit( + "LinkedIn triggered a 2FA / CAPTCHA challenge.\n" + "Log in manually in a browser, complete the challenge, then retry." + ) + except UnauthorizedException: + raise SystemExit("Bad credentials — check LINKEDIN_EMAIL / LINKEDIN_PASSWORD.") + + conn = init_db(DB_PATH) + existing = get_existing_links(conn) + logger.info("DB ready — %d posts already stored", len(existing)) + + # print active filters + if HASHTAG_FILTER: + logger.info("Hashtag filter : %s", ", ".join(HASHTAG_FILTER)) + else: + logger.info("Hashtag filter : none (keeping all posts)") + if FETCH_ENGAGEMENT: + logger.info("Engagement filter : reactions >= %d, comments >= %d", + MIN_REACTIONS, MIN_COMMENTS) + else: + logger.info("Engagement fetch : disabled") + + # counters + fetched_total = 0 + kept = 0 + skipped_hash = 0 + skipped_engage = 0 + skipped_dup = 0 + + while fetched_total < MAX_POSTS_PER_RUN: + batch_size = min(FETCH_BATCH_SIZE, MAX_POSTS_PER_RUN - fetched_total) + logger.info("Fetching up to %d posts (total so far: %d) ...", + batch_size, fetched_total) + + try: + posts = api.get_feed_posts(limit=batch_size) + except Exception as exc: + logger.error("Feed fetch failed: %s", exc) + break + + if not posts: + logger.info("Feed exhausted") + break + + for post in posts: + url = post.get("url", "").strip() + content = post.get("content", "").strip() + author = post.get("author_name", "").strip() + + if not url: + continue + + # skip duplicates + if url in existing: + skipped_dup += 1 + continue + + # hashtag filter + hashtags = extract_hashtags(content) + if not passes_hashtag_filter(hashtags): + skipped_hash += 1 + logger.debug("Skipped (hashtag) : %s", url) + continue + + # engagement filter + reactions, comments = None, None + if FETCH_ENGAGEMENT: + reactions, comments = fetch_engagement(api, url) + time.sleep(ENGAGEMENT_DELAY_S + random.uniform(0, 0.5)) + if not passes_engagement_filter(reactions, comments): + skipped_engage += 1 + logger.debug("Skipped (engagement r=%s c=%s) : %s", + reactions, comments, url) + continue + + # persist + inserted = insert_post( + conn, + post_link = url, + author_name = author, + content = content, + hashtags = ",".join(hashtags) if hashtags else None, + reactions = reactions, + comments = comments, + ) + if inserted: + existing.add(url) + kept += 1 + logger.info("Saved [r=%s c=%s] %s", reactions, comments, url) + + fetched_total += len(posts) + + if len(posts) < batch_size: + logger.info("Feed exhausted after %d posts", fetched_total) + break + + delay = BASE_DELAY_S + random.uniform(0, JITTER_S) + logger.info("Sleeping %.1fs ...", delay) + time.sleep(delay) + + conn.close() + + print("\n── Results ─────────────────────────────────") + print(f" Posts fetched : {fetched_total}") + print(f" Saved to DB : {kept}") + print(f" Skipped (duplicate) : {skipped_dup}") + print(f" Skipped (hashtag) : {skipped_hash}") + print(f" Skipped (engagement) : {skipped_engage}") + print(f" DB file : {os.path.abspath(DB_PATH)}") + print("─────────────────────────────────────────────\n") + + # handy queries to run after + print("Useful DB queries:") + print(f" sqlite3 {DB_PATH} \"SELECT post_link, hashtags, reactions, comments FROM posts ORDER BY reactions DESC LIMIT 10\"") + print(f" sqlite3 {DB_PATH} \"SELECT COUNT(*) FROM posts\"") + print() + + +if __name__ == "__main__": + run() diff --git a/backend/requirements.txt b/backend/requirements.txt index e01547b..a65d8d5 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,11 +1,7 @@ temporalio==1.4.0 Appium-Python-Client==3.1.0 openai==1.6.1 -google-generativeai==0.3.2 -temporalio==1.4.0 -Appium-Python-Client==3.1.0 -openai==1.6.1 -google-generativeai==0.3.2 +google-genai protobuf==4.25.1 fastapi==0.109.0 uvicorn==0.27.0 diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 23fa752..57935ba 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -1,36 +1,73 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect, useRef } from "react"; +import { + Activity, Smartphone, Play, Image as ImageIcon, Briefcase, UserPlus, + Terminal, ShieldAlert, Cpu +} from "lucide-react"; export default function Home() { const [status, setStatus] = useState("IDLE"); + const [deviceStatus, setDeviceStatus] = useState("Checking..."); const [logs, setLogs] = useState([]); const [loading, setLoading] = useState(false); + const [promptInput, setPromptInput] = useState(""); + const [promptStatus, setPromptStatus] = useState(""); + const logsEndRef = useRef(null); - // Simple polling to update status based on server logs - const pollLogs = async () => { + useEffect(() => { + // Initial fetch + fetchStatus(); + fetchLogs(); + + // Poll every 3 seconds for active log streaming and connection health + const interval = setInterval(() => { + fetchLogs(); + fetchStatus(); + }, 3000); + + return () => clearInterval(interval); + }, []); + + useEffect(() => { + // Auto scroll to bottom + if (logsEndRef.current) { + logsEndRef.current.scrollIntoView({ behavior: "smooth" }); + } + }, [logs]); + + const fetchStatus = async () => { + try { + const res = await fetch("http://localhost:8000/api/status"); + const data = await res.json(); + setDeviceStatus(data.device || "Unknown"); + } catch { + setDeviceStatus("Backend Offline"); + } + }; + + const fetchLogs = async () => { try { const res = await fetch("http://localhost:8000/api/logs"); const data = await res.json(); - const lastLog = data.logs[0] || ""; - - if (lastLog.includes("STEP PASS")) { - setStatus("SUCCESS"); - } else if (lastLog.includes("STEP FAIL")) { - setStatus("FAILED"); - } else if (status === "RUNNING") { - // Keep polling - setTimeout(pollLogs, 1000); + + if (data.logs && Array.isArray(data.logs)) { + setLogs(data.logs); + const lastLog = data.logs[data.logs.length - 1] || ""; + + if (status === "RUNNING") { + if (lastLog.includes("STEP PASS")) setStatus("SUCCESS"); + if (lastLog.includes("STEP FAIL")) setStatus("FAILED"); + } } } catch (e) { - console.error("Polling error", e); + console.error(e); } }; const triggerFlow = async (flowName: string) => { setLoading(true); setStatus(`STARTING ${flowName}...`); - addLog(`Requesting ${flowName}...`); try { const res = await fetch("http://localhost:8000/api/trigger", { @@ -40,152 +77,224 @@ export default function Home() { }); const data = await res.json(); setStatus("RUNNING"); - addLog(`Flow Triggered: ${data.flow}`); - pollLogs(); + fetchLogs(); } catch (e) { - console.error(e); setStatus("ERROR"); - addLog("Failed to connect to backend."); } finally { setLoading(false); } }; - const addLog = (msg: string) => { - setLogs((prev) => [`[${new Date().toLocaleTimeString()}] ${msg}`, ...prev]); + const savePrompt = async () => { + if (!promptInput.trim()) return; + setPromptStatus("Saving..."); + try { + const res = await fetch("http://localhost:8000/api/save_prompt", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ raw_prompt: promptInput }), + }); + const data = await res.json(); + if (data.status === "success") { + setPromptStatus("Saved & Enhanced!"); + setPromptInput(""); + setTimeout(() => setPromptStatus(""), 3000); + } else { + setPromptStatus("Error saving."); + } + } catch (e) { + setPromptStatus("Error connecting."); + } }; return ( -
-
- {/* Glow Effect */} -
- -
-
-
-

- L.A.Z.Y. Automation -

-

Samsung S25 Ultra • Phase 1: LinkedIn

+
+
+ + {/* Left Column: Controls & Info */} +
+ + {/* Header Card */} +
+
+
+
+

+ RoboticDevice +

+

Autonomous Mobile Operations

+
+
+ +
-
- {/* Button 1: Health Check */} - - - {/* Button 2: LinkedIn Scroll */} - - - {/* Button 3: Test Open App */} - - - {/* Button 4: Test Failure */} - - - {/* Button 5: Job Hunter */} - - - {/* Button 6: AI Engage */} - + {/* Device Status */} +
+
+ + Device: + + {deviceStatus} + +
+
+ + +
+
-
-
- SYSTEM LOGS - + {/* Action Grid */} +
+ } + color="text-emerald-400" + borderColor="hover:border-emerald-500/50" + bgHover="hover:bg-emerald-500/10" + onClick={() => triggerFlow("health_check")} + disabled={loading || status === "RUNNING"} + /> + } + color="text-blue-400" + borderColor="hover:border-blue-500/50" + bgHover="hover:bg-blue-500/10" + onClick={() => triggerFlow("open_app")} + disabled={loading || status === "RUNNING"} + /> + } + color="text-indigo-400" + borderColor="hover:border-indigo-500/50" + bgHover="hover:bg-indigo-500/10" + onClick={() => triggerFlow("job_search")} + disabled={loading || status === "RUNNING"} + /> + } + color="text-purple-400" + borderColor="hover:border-purple-500/50" + bgHover="hover:bg-purple-500/10" + onClick={() => triggerFlow("linkedin_engage")} + disabled={loading || status === "RUNNING"} + /> + } + color="text-red-400" + borderColor="hover:border-red-500/50" + bgHover="hover:bg-red-500/10" + onClick={() => triggerFlow("failure_test")} + disabled={loading || status === "RUNNING"} + /> +
+ + {/* Prompt Saver */} +
+

+ 📝 Prompt Saver +

+

+ Enter a raw prompt. The AI Brain will enhance it and save it to saved_prompts.md. +

+
+