From a486d78b06488aead982633501829138df7ff01f Mon Sep 17 00:00:00 2001 From: Gabo_Tech Date: Fri, 28 Jun 2024 21:47:48 +0200 Subject: [PATCH 01/19] Initial commit with project files --- .gitignore | 9 + LICENCE | 21 ++ README.md | 103 +++++++- config.json | 361 ++++++++++++++++++++++++- configExample.json | 41 +++ main.py | 645 ++++++++++++++++++++++++++++++++++----------- 6 files changed, 1015 insertions(+), 165 deletions(-) create mode 100644 .gitignore create mode 100644 LICENCE create mode 100644 configExample.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..54f2283 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +# Ignore everything +* + +# Allow specific files +!.gitignore +!main.py +!configExample.json +!README.md +!LICENCE \ No newline at end of file diff --git a/LICENCE b/LICENCE new file mode 100644 index 0000000..8df1a01 --- /dev/null +++ b/LICENCE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 dilaratznr + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index 8729077..6375aae 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,108 @@ # EasyApply-Linkedin -With this tool you can easily automate the process of applying for jobs on LinkedIn! +With this tool, you can easily automate the process of applying for jobs on LinkedIn! -## Getting started +## Getting Started These instructions will get you a copy of the project up and running on your local machine for development and testing purposes. ### Prerequisites 1. Install selenium. I used `pip` to install the selenium package. - -`pip install selenium` + ```sh + pip install selenium + ``` 2. Selenium requires a driver to interface with the chosen browser. Make sure the driver is in your path, you will need to add your `driver_path` to the `config.json` file. -I used the Chrome driver, you can download it [here](https://sites.google.com/a/chromium.org/chromedriver/downloads). You can also download [Edge](https://developer.microsoft.com/en-us/microsoft-edge/tools/webdriver/), [Firefox](https://github.com/mozilla/geckodriver/releases) or [Safari](https://webkit.org/blog/6900/webdriver-support-in-safari-10/). Depends on your preferred browser. + I used the Chrome driver, you can download it [here](https://sites.google.com/a/chromium.org/chromedriver/downloads). You can also download drivers for [Edge](https://developer.microsoft.com/en-us/microsoft-edge/tools/webdriver/), [Firefox](https://github.com/mozilla/geckodriver/releases), or [Safari](https://webkit.org/blog/6900/webdriver-support-in-safari-10/), depending on your preferred browser. + +### Installation + +1. Clone the repository: + ```sh + git clone https://github.com/your_username/easyapply-linkedin.git + cd easyapply-linkedin + ``` + +2. Install the necessary packages: + ```sh + pip install -r requirements.txt + ``` + +3. Update the `config.json` file with your information: + ```json + { + "email": "example@example.com", + "password": "securePassword123!", + "keywords": ["Web Developer", "JavaScript", "React"], + "locations": ["New York", "Los Angeles", "San Francisco"], + "driver_path": "/usr/local/bin/chromedriver", + "sortBy": "Alphabetical", + "filters": { + "easy_apply": true, + "experience": ["Internship", "Entry Level", "Associate", "Mid-Senior Level", "Director", "Executive"], + "jobType": ["Full-Time", "Part-Time", "Contract", "Internship", "Temporary"], + "timePostedRange": ["Any Time", "Last Month", "Past Week", "Past 24 Hours"], + "workplaceType": ["Remote", "Hybrid", "On-site"], + "less_than_10_applicants": true, + "commitments": ["Full-Time", "Part-Time", "Contract", "Temporary", "Volunteer"] + }, + "experience": [ + { + "title": "Junior Web Developer", + "description": "Developing responsive web applications using JavaScript and React.", + "date": "Jan 2023 - Present", + "company": "Example Company" + } + ], + "projects": [ + { + "title": "Project Alpha", + "desc": "A project description here...", + "link": "#", + "skills": ["JavaScript", "React", "Node.js"] + } + ], + "skills": [ + "JavaScript", + "React", + "Node.js", + "Express", + "MongoDB" + ], + "user_inputs": {} + } + ``` ### Usage -Fork and clone/download the repository and change the configuration file with: +1. Run the application: + ```sh + python main.py + ``` + +### Features + +- **Automated Job Applications**: Automatically apply to jobs that match your keywords and location. +- **Filter Options**: Customize filters for experience level, job type, time posted, workplace type, and more. +- **Logging**: Keep track of errors and the companies you've applied to. + +### Customization + +You can customize the job search and application process by editing the `config.json` file: +- **email**: Your LinkedIn email address. +- **password**: Your LinkedIn password. +- **keywords**: Keywords for finding specific job titles (e.g., "Machine Learning Engineer", "Data Scientist"). +- **locations**: Locations where you are currently looking for a position. +- **driver_path**: Path to your downloaded WebDriver. +- **sortBy**: Sort order for job listings. +- **filters**: Various filters to narrow down the job search (e.g., easy apply, experience level, job type, etc.). + +### Contributing -* Your email linked to LinkedIn. -* Your password. -* Keywords for finding specific job titles fx. Machine Learning Engineer, Data Scientist, etc. -* The location where you are currently looking for a position. -* The driver path to your downloaded webdriver. +Please feel free to comment or give suggestions/issues. Fork and submit pull requests for any enhancements or bug fixes. -Run `python main.py`. +### License -Please feel free to comment or give suggestions/issues. +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. diff --git a/config.json b/config.json index 0b432a4..f32f4bf 100644 --- a/config.json +++ b/config.json @@ -1,7 +1,358 @@ { - "email" : "your_email", - "password" : "your_password", - "keywords" : "your_keywords", - "location" : "your_location", - "driver_path" : "your_path_to_webdriver" + "email": "sendmessage@gabo.email", + "password": "*******,********", + "keywords": [ + "TypeScript Engineer", + "Angular Frontend", + "React Frontend", + "React Native", + "Node backend", + "JavaScript Full-Stack" + ], + "locations": [ + "Belgium", + "Netherlands", + "DACH", + "Benelux", + "United Kingdom", + "Switzerland", + "Spain", + "United States", + "European Union", + "European Economic Area", + "Germany" + ], + "driver_path": "/usr/local/bin/geckodriver", + "sortBy": "R", + "filters": { + "easy_apply": true, + "experience": [ + "Entry level", + "Associate" + ], + "jobType": [ + "Full-time", + "Contract" + ], + "timePostedRange": [ + "Past Week" + ], + "workplaceType": [ + "Remote" + ], + "less_than_10_applicants": false + }, + "experience": [ + { + "title": "Full TypeScript Stack Engineer", + "description": "As a Full-Stack TypeScript Engineer at Beyondbmi, I spearhead the development of an advanced online weight loss clinic platform. Utilizing Angular and Bootstrap for the front-end and Express with TypeORM for the back-end, I ensure consistent and reliable performance across the stack with TypeScript. My role involves implementing HIPAA-compliant encryption protocols to safeguard patient data, developing an Android application with React Native, and deploying AWS Lambda functions and AWS Cognito for secure authentication. My contributions have enhanced user experience, data security, and the platform's scalability.", + "date": "Nov 2022 - Present", + "company": "Beyondbmi" + }, + { + "title": "Software Engineer", + "description": "At GABO, I collaborate on a variety of projects, focusing on developing e-commerce sites, corporate websites, and online presences for small businesses. My work emphasizes Search Engine Optimization (SEO) to improve visibility and engagement. Using JavaScript frameworks, WordPress, PHP, and other tools, I deliver tailored, optimized web solutions. My role has honed my versatility in web development and client relationship management, ensuring long-term client satisfaction and successful project outcomes.", + "date": "Nov 2022 - Present", + "company": "GABO" + }, + { + "title": "User Interface Engineer", + "description": "During my internship at talenTeal, I led the comprehensive redesign of the company\u2019s landing page to enhance user experience (UX) and user interface (UI). My focus was on creating an intuitive and navigable website, resulting in improved accessibility and user engagement. This project demonstrated my ability to apply front-end development skills effectively to achieve significant improvements in digital interaction and user satisfaction.", + "date": "Jul 2022 - Aug 2022", + "company": "talenTeal" + }, + { + "title": "Technical Support Specialist", + "description": "At Lujo Network, I enhanced the company\u2019s security infrastructure by implementing firewalls and establishing robust backup systems. I also managed databases, developed website segments, and conducted security training sessions. My efforts improved the company\u2019s digital security framework and online presence, showcasing my ability to handle both technical support and development tasks effectively.", + "date": "Feb 2022 - Mar 2022", + "company": "Lujo Network" + }, + { + "title": "Freelancer", + "description": "I provided web solutions, applying a range of technologies to address client requirements.", + "date": "Feb 2021 - Nov 2022" + } + ], + "projects": [ + { + "title": "BEYONDBMI", + "desc": "As a Full-Stack TypeScript Engineer at Beyondbmi, I spearhead the development of an advanced online weight loss clinic platform. Utilizing Angular and Bootstrap for the front-end and Express with TypeORM for the back-end, I ensure consistent and reliable performance across the stack with TypeScript. My role involves implementing HIPAA-compliant encryption protocols to safeguard patient data, developing an Android application with React Native, and deploying AWS Lambda functions and AWS Cognito for secure authentication. My contributions have enhanced user experience, data security, and the platform's scalability.", + "link": "", + "skills": [ + "JavaScript", + "Angular", + "TypeScript", + "Bootstrap", + "React Native", + "Metro", + "AWS", + "Express", + "TypeORM", + "Postgres", + "Jest", + "CI/CD", + "Docker", + "Jira", + "Bitbucket", + "Git", + "Stripe" + ] + }, + { + "title": "GABO", + "desc": "The GABO Landing Page is a modern, visually appealing website built using the Astro framework, leveraging Solid.js for reactive UI components, Tailwind CSS for efficient styling, and Vercel for scalable hosting. This project integrates Better SQLite3 for lightweight database management, Gray Matter and Marked for markdown content handling, and Next.js with React for dynamic, interactive components. Featuring fast load times, smooth user experience, and improved SEO, the site employs Vercel Analytics and Speed Insights for monitoring performance. Key technologies include Astro, Solid.js, Tailwind CSS, Vercel, Next.js, React, Git, and GitHub.", + "link": "", + "skills": [ + "JavaScript", + "Astro", + "Solid.js", + "Tailwind CSS", + "Vercel", + "Next.js", + "React", + "TypeScript", + "Git", + "GitHub" + ] + }, + { + "title": "TALENTEAL", + "desc": "I led the comprehensive redesign of the company\u2019s landing page to enhance user experience (UX) and user interface (UI). My focus was on creating an intuitive and navigable website, resulting in improved accessibility and user engagement. This project demonstrated my ability to apply front-end development skills effectively to achieve significant improvements in digital interaction and user satisfaction.", + "link": "", + "skills": [ + "JavaScript", + "React", + "Redux", + "Django", + "UI/UX Design", + "Sass", + "Git", + "GitHub" + ] + }, + { + "title": "LUJO NETWORK", + "desc": "At Lujo Network, I led the design and development of the sign-in and sign-up pages, implementing secure forms and internationalization in several languages, along with data validation for the forms. I enhanced the company\u2019s security infrastructure by implementing firewalls and establishing robust backup systems. I also managed databases, developed website segments, and conducted security training sessions. My efforts improved the company\u2019s digital security framework and online presence, showcasing my ability to handle both technical support and development tasks effectively.", + "link": "", + "skills": [ + "JavaScript", + "HTML", + "CSS", + "Database Management", + "Cybersecurity" + ] + }, + { + "title": "EXA MONSTER", + "desc": "Exa Monster is a secure cloud storage solution that prioritizes user privacy. Developed as a SaaS application using Laravel, WordPress, and PHP, and deployed with Hetzner Cloud, it offers robust and secure storage capabilities. The platform also includes a mobile app built with React Native, providing users with seamless access to their data on the go.", + "link": "", + "skills": [ + "Laravel", + "WordPress", + "PHP", + "React Native", + "Hetzner", + "SaaS", + "Stripe" + ] + }, + { + "title": "IMALEVANTE", + "desc": "I designed and developed the Imalevante company landing page using WordPress and PHP. The project involved creating a visually appealing and functional website that effectively represents the company's brand and services.", + "link": "", + "skills": [ + "WordPress", + "PHP" + ] + }, + { + "title": "ROUTEU", + "desc": "RouteU is a fully open-sourced route management site built with the MERN stack. This project offers users the ability to manage and share routes. It is deployed on Vercel and Heroku, providing high availability and performance. The site features a modern UI developed with React, Bootstrap, and Ant Design, and it uses MongoDB Atlas for robust data management.", + "link": "https://route-u.vercel.app", + "skills": [ + "React.js", + "CSS", + "Bootstrap", + "Ant Design", + "Grommet", + "Sass", + "HTML", + "Axios", + "React Router", + "MongoDB", + "Redux", + "Express", + "Node.js" + ] + }, + { + "title": "FIGHT GAME", + "desc": "StreetFighter-like OSG (Open Sourced Game) is a simple, open-sourced fighting game inspired by Street Fighter. Developed with HTML, CSS, and Vanilla JavaScript, the game is designed for two players. It is hosted on GitHub Pages, offering easy access and a fun gaming experience.", + "link": "https://gabo-tech.github.io/StreetFighter-like-OSG", + "skills": [ + "HTML", + "CSS", + "JavaScript" + ] + }, + { + "title": "QUIZ", + "desc": "Custom Quiz is an open-sourced quiz game that uses the Open Trivia API and supports custom questions. Developed with HTML, CSS, and Vanilla JavaScript, the game offers a fun and interactive way to test knowledge on various topics. It is hosted on GitHub Pages.", + "link": "https://gabo-tech.github.io/Custom-Quizz", + "skills": [ + "HTML", + "CSS", + "JavaScript" + ] + }, + { + "title": "GABO SL", + "desc": "Gabo's Social Life is a fully open-sourced social media site built with the MERN stack. This project provides a platform for users to share and interact with content. It is deployed on Vercel and Heroku, ensuring high availability and performance. The site features modern UI components and comprehensive functionality, including user authentication, content sharing, and real-time updates.", + "link": "https://gabosl.com", + "skills": [ + "React.js", + "Sass", + "HTML", + "Axios", + "React Router", + "MongoDB", + "Redux", + "Cypress", + "Express", + "Node.js", + "Material UI", + "Swagger" + ] + }, + { + "title": "DGABO Token", + "desc": "Decentralyized GABO Token is a personal cryptocurrency project developed using JavaScript and Motoko for smart contracts. Although not deployed due to high blockchain fees, this project demonstrates the process of creating and managing a token on the Internet Computer blockchain. The project includes a web mockup and emulator setup instructions for local development.", + "link": "https://github.com/Gabo-Tech/Web3-Token-and-Faucet", + "skills": [ + "JavaScript", + "Motoko", + "HTML", + "CSS", + "React" + ] + }, + { + "title": "My Portfolio", + "desc": "This portfolio it's been designed with tailwinds and framer motion, includes several animaitons and some interactivity with background music to make the experience a bit more chilling. Has been developed with Next.js 14", + "link": "", + "skills": [ + "JavaScript", + "React", + "Next.js", + "Tailwind CSS", + "Framer Motion" + ] + }, + { + "title": "CHURCH OF JESUS CHRIST", + "desc": "This desktop application, developed using Tauri and Svelte, provides a convenient and efficient way to access and read the scriptures from the website of The Church of Jesus Christ of Latter-day Saints. The app features cross-platform compatibility (Windows, macOS, Linux), easy navigation, and a clean interface.", + "link": "https://github.com/Gabo-Tech/Scriptures-of-The-Church-of-Jesus-Christ-of-Latter-Day-Saints", + "skills": [ + "Tauri", + "Svelte", + "JavaScript", + "Node.js" + ] + } + ], + "skills": [ + "React", + "Angular", + "Svelte", + "Solid.js", + "Next.js", + "Astro", + "Tailwind CSS", + "Bootstrap", + "Ant Design", + "Material UI", + "Metro", + "React Native", + "CSS", + "HTML", + "Markdown", + "Sass", + "Grommet", + "Node.js", + "Express", + "TypeORM", + "PostgreSQL", + "Laravel", + "WordPress", + "PHP", + "Hetzner", + "Sequelize", + "Spring Boot", + "GraphQL", + "Apollo", + "JWT", + "Jest", + "Cypress", + "Playwright", + "Postman", + "Firebase", + "MongoDB", + "Redux", + "Axios", + "Swagger", + "Motoko", + "CI/CD", + "Git", + "Docker", + "Jira", + "Bitbucket", + "GitHub Actions", + "AWS", + "Vercel", + "DigitalOcean", + "Heroku", + "Netlify", + "Expo", + "Tauri", + "Recoil", + "Zustand", + "Vite", + "Vitest", + "Bun", + "MySQL", + "MariaDB", + "Mocha", + "NPM", + "Yarn", + "Webpack" + ], + "user_inputs": { + "United Kingdom": { + "City\nCity": "Valencia, Valencian Community, Spain", + "What is your gender?\nWhat is your gender?": "Male", + "Do you consider yourself to be disabled as defined by the Equality Act 2010? The Equality Act defines disability as 'A physical or mental impairment which has a substantial and long-term effect on the person's ability to carry out normal day-to-day activities'.\nDo you consider yourself to be disabled as defined by the Equality Act 2010? The Equality Act defines disability as 'A physical or mental impairment which has a substantial and long-term effect on the person's ability to carry out normal day-to-day activities'.": "No", + "Do you require any particular arrangements to support you in the recruitment and selection process?\nDo you require any particular arrangements to support you in the recruitment and selection process?\nRequired": "No", + "Please provide details of the arrangements you require. If you are invited to interview, we will confirm the arrangements with you ahead of the appointment date and time.": "No arrangements required.", + "What is your ethnic origin?\nWhat is your ethnic origin?": "White: Any other background", + "I Agree Terms & Conditions": true, + "TV Advert": false, + "CV Library": false, + "CW Jobs": false, + "Indeed": false, + "Jobsite": false, + "LinkedIn": true, + "Will you now or in the future require sponsorship for employment visa status?\nWill you now or in the future require sponsorship for employment visa status?\nRequired": "No", + "What is your current location?": "Valencia, Valencian Community, Spain", + "Have you completed the following level of education: Bachelor's Degree?\nHave you completed the following level of education: Bachelor's Degree?\nRequired": "No", + "How many years of work experience do you have with Oracle Cloud?": "0", + "How many years of work experience do you have with Oracle ERP Implementations?": "0", + "How many years of work experience do you have with Source to Pay?": "0", + "Are you legally authorized to work in United Kingdom?\nAre you legally authorized to work in United Kingdom?\nRequired": "Yes", + "Legal Name (if different than above)": "Gabriel Clemente Ramos", + "How did you hear about this job?": "LinkedIn", + "This job post is for positions in the EMEA region. Please confirm if you have the right to work in Portugal and/or UK\nThis job post is for positions in the EMEA region. Please confirm if you have the right to work in Portugal and/or UK": "Yes", + "Do you now or will you in the future require immigration sponsorship to work at Cloudflare?\nDo you now or will you in the future require immigration sponsorship to work at Cloudflare?": "No", + "Acknowledge/Confirm": true + } + } } \ No newline at end of file diff --git a/configExample.json b/configExample.json new file mode 100644 index 0000000..fd36f61 --- /dev/null +++ b/configExample.json @@ -0,0 +1,41 @@ +{ + "email": "example@example.com", + "password": "securePassword123!", + "keywords": ["Web Developer", "JavaScript", "React"], + "locations": ["New York", "Los Angeles", "San Francisco"], + "driver_path": "/usr/local/bin/chromedriver", + "sortBy": "Alphabetical", + "filters": { + "easy_apply": true, + "experience": ["Internship", "Entry Level", "Associate", "Mid-Senior Level", "Director", "Executive"], + "jobType": ["Full-Time", "Part-Time", "Contract", "Internship", "Temporary"], + "timePostedRange": ["Any Time", "Last Month", "Past Week", "Past 24 Hours"], + "workplaceType": ["Remote", "Hybrid", "On-site"], + "less_than_10_applicants": true, + "commitments": ["Full-Time", "Part-Time", "Contract", "Temporary", "Volunteer"] + }, + "experience": [ + { + "title": "Junior Web Developer", + "description": "Developing responsive web applications using JavaScript and React.", + "date": "Jan 2023 - Present", + "company": "Example Company" + } + ], + "projects": [ + { + "title": "Project Alpha", + "desc": "A project description here...", + "link": "#", + "skills": ["JavaScript", "React", "Node.js"] + } + ], + "skills": [ + "JavaScript", + "React", + "Node.js", + "Express", + "MongoDB" + ], + "user_inputs": {} + } \ No newline at end of file diff --git a/main.py b/main.py index 0232efa..99e0df4 100644 --- a/main.py +++ b/main.py @@ -1,179 +1,530 @@ +import json +import time +import urllib.parse +import logging +from datetime import datetime, timedelta +from pathlib import Path from selenium import webdriver +from selenium.webdriver.common.by import By from selenium.webdriver.common.keys import Keys from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.support.ui import WebDriverWait -from selenium.webdriver.common.by import By -from selenium.common.exceptions import NoSuchElementException, ElementClickInterceptedException, NoSuchElementException -from selenium.webdriver.common.action_chains import ActionChains -import time -import re -import json +from selenium.common.exceptions import NoSuchElementException, ElementNotInteractableException, StaleElementReferenceException, TimeoutException, ElementClickInterceptedException +from selenium.webdriver.firefox.service import Service as FirefoxService class EasyApplyLinkedin: + BASE_URL = "https://www.linkedin.com/jobs/search/" + ERROR_LOG_PATH = Path("error_log.json") + APPLIED_COMPANIES_LOG_PATH = Path("applied_companies_log.json") - def __init__(self, data): - """Parameter initialization""" + TIME_POSTED_MAPPING = { + "Any Time": "", + "Last Month": "r2592000", + "Past Week": "r604800", + "Past 24 hours": "r86400" + } + + EXPERIENCE_MAPPING = { + "Internship": "1", + "Entry level": "2", + "Associate": "3", + "Mid-Senior level": "4", + "Director": "5", + "Executive": "6" + } + + WORKPLACE_TYPE_MAPPING = { + "Remote": "2", + "Hybrid": "3", + "On-site": "1" + } + + JOB_TYPE_MAPPING = { + "Full-time": "F", + "Part-time": "P", + "Contract": "C", + "Internship": "I", + "Temporary": "T" + } + + TITLE_MAPPING = { + "Engineer": "9", + "Developer": "25201", + "Manager": "25170", + "Specialist": "1456", + "Consultant": "3731" + } + COMMITMENTS_MAPPING = { + "Full-time": "1", + "Part-time": "2", + "Contract": "3", + "Temporary": "4", + "Volunteer": "5" + } + + LOCATION_MAPPING = { + "Switzerland": "106693272", + "Spain": "105646813", + "United States": "103644278", + "United Kingdom": "101165590", + "European Union": "91000000", + "European Economic Area": "91000002", + "DACH": "91000006", + "Benelux": "91000005", + "Netherlands": "102890719", + "Belgium":"100565514", + "Germany": "101282230" + } + + def __init__(self, data): + """Initialize the EasyApplyLinkedin instance with user data.""" self.email = data['email'] self.password = data['password'] - self.keywords = data['keywords'] - self.location = data['location'] - self.driver = webdriver.Chrome(data['driver_path']) + self.keywords = ' OR '.join(data['keywords']) + self.locations = data['locations'] + self.filters = data['filters'] + self.sort_by = data['sortBy'] + self.context_data = data + self.current_location_index = 0 + if 'user_inputs' not in self.context_data: + self.context_data['user_inputs'] = {} + firefox_service = FirefoxService(executable_path=data['driver_path']) + self.driver = webdriver.Firefox(service=firefox_service) + self.init_logging() + + def init_logging(self): + """Initialize logging for error and applied companies.""" + logging.basicConfig(level=logging.ERROR) + self.error_logger = logging.getLogger("ErrorLogger") + self.applied_companies = self.load_json(self.APPLIED_COMPANIES_LOG_PATH) + + def load_json(self, path): + """Load JSON data from the specified path.""" + if path.exists(): + with path.open('r') as file: + return json.load(file) + return {} + + def save_json(self, path, data): + """Save JSON data to the specified path.""" + with path.open('w') as file: + json.dump(data, file, indent=4) + + def log_error(self, error_msg): + """Log error messages with a timestamp.""" + errors = self.load_json(self.ERROR_LOG_PATH) + errors[str(datetime.now())] = error_msg + self.save_json(self.ERROR_LOG_PATH, errors) + self.cleanup_error_log() + + def cleanup_error_log(self): + """Clean up old error logs older than 1 day.""" + errors = self.load_json(self.ERROR_LOG_PATH) + cutoff = datetime.now() - timedelta(days=1) + errors = {k: v for k, v in errors.items() if datetime.fromisoformat(k) > cutoff} + self.save_json(self.ERROR_LOG_PATH, errors) + + def log_applied_company(self, company): + """Log the company to which an application was submitted.""" + self.applied_companies[company] = str(datetime.now()) + self.save_json(self.APPLIED_COMPANIES_LOG_PATH, self.applied_companies) + self.cleanup_applied_companies_log() + + def cleanup_applied_companies_log(self): + """Clean up logs of applied companies older than 2 weeks.""" + cutoff = datetime.now() - timedelta(weeks=2) + self.applied_companies = {k: v for k, v in self.applied_companies.items() if datetime.fromisoformat(v) > cutoff} + self.save_json(self.APPLIED_COMPANIES_LOG_PATH, self.applied_companies) def login_linkedin(self): - """This function logs into your personal LinkedIn profile""" - - # go to the LinkedIn login url - self.driver.get("https://www.linkedin.com/login") - - # introduce email and password and hit enter - login_email = self.driver.find_element_by_name('session_key') - login_email.clear() - login_email.send_keys(self.email) - login_pass = self.driver.find_element_by_name('session_password') - login_pass.clear() - login_pass.send_keys(self.password) - login_pass.send_keys(Keys.RETURN) - + """Log in to LinkedIn using the provided credentials.""" + try: + self.driver.get("https://www.linkedin.com/login") + WebDriverWait(self.driver, 10).until(EC.presence_of_element_located((By.NAME, 'session_key'))) + login_email = self.driver.find_element(By.NAME, 'session_key') + login_email.clear() + login_email.send_keys(self.email) + WebDriverWait(self.driver, 10).until(EC.presence_of_element_located((By.NAME, 'session_password'))) + login_pass = self.driver.find_element(By.NAME, 'session_password') + login_pass.clear() + login_pass.send_keys(self.password) + login_pass.send_keys(Keys.RETURN) + WebDriverWait(self.driver, 30).until(EC.presence_of_element_located((By.LINK_TEXT, 'Jobs'))) + except Exception as e: + self.log_error(f"Login error: {e}") + def job_search(self): - """This function goes to the 'Jobs' section a looks for all the jobs that matches the keywords and location""" - - # go to Jobs - jobs_link = self.driver.find_element_by_link_text('Jobs') - jobs_link.click() - - # search based on keywords and location and hit enter - search_keywords = self.driver.find_element_by_css_selector(".jobs-search-box__text-input[aria-label='Search jobs']") - search_keywords.clear() - search_keywords.send_keys(self.keywords) - search_location = self.driver.find_element_by_css_selector(".jobs-search-box__text-input[aria-label='Search location']") - search_location.clear() - search_location.send_keys(self.location) - search_location.send_keys(Keys.RETURN) - - def filter(self): - """This function filters all the job results by 'Easy Apply'""" - - # select all filters, click on Easy Apply and apply the filter - all_filters_button = self.driver.find_element_by_xpath("//button[@data-control-name='all_filters']") - all_filters_button.click() - time.sleep(1) - easy_apply_button = self.driver.find_element_by_xpath("//label[@for='f_LF-f_AL']") - easy_apply_button.click() - time.sleep(1) - apply_filter_button = self.driver.find_element_by_xpath("//button[@data-control-name='all_filters_apply']") - apply_filter_button.click() + """Perform job search based on keywords and locations.""" + while self.current_location_index < len(self.locations): + try: + WebDriverWait(self.driver, 20).until(EC.presence_of_element_located((By.LINK_TEXT, 'Jobs'))) + jobs_link = self.driver.find_element(By.LINK_TEXT, 'Jobs') + jobs_link.click() + WebDriverWait(self.driver, 20).until(EC.presence_of_element_located((By.CSS_SELECTOR, "input[aria-label='Search by title, skill, or company']"))) + search_keywords = self.driver.find_element(By.CSS_SELECTOR, "input[aria-label='Search by title, skill, or company']") + search_keywords.clear() + search_keywords.send_keys(self.keywords) + WebDriverWait(self.driver, 20).until(EC.presence_of_element_located((By.CSS_SELECTOR, "input[aria-label='City, state, or zip code']"))) + search_location = self.driver.find_element(By.CSS_SELECTOR, "input[aria-label='City, state, or zip code']") + search_location.clear() + search_location.send_keys(self.locations[self.current_location_index]) + search_keywords.click() + search_keywords.send_keys(Keys.RETURN) + + if not self.check_no_results(): + break + else: + print(f"No matching jobs found in {self.locations[self.current_location_index]}.") + self.current_location_index += 1 + + except TimeoutException: + print("Timeout while trying to access the Jobs page or elements on it.") + self.current_location_index += 1 + except Exception as e: + self.log_error(f"Job search error: {e}") + self.current_location_index += 1 + + def construct_url(self): + """Construct the URL for job search with applied filters.""" + current_location = self.locations[self.current_location_index] + params = { + "keywords": self.keywords, + "origin": "JOB_SEARCH_PAGE_JOB_FILTER", + "refresh": "true", + "sortBy": self.sort_by + } + + if self.filters.get("easy_apply"): + params["f_AL"] = "true" + + if self.filters.get("experience"): + params["f_E"] = ",".join([self.EXPERIENCE_MAPPING[exp] for exp in self.filters["experience"]]) + + if self.filters.get("jobType"): + params["f_JT"] = ",".join([self.JOB_TYPE_MAPPING[jt] for jt in self.filters["jobType"]]) + + if self.filters.get("timePostedRange"): + params["f_TPR"] = ",".join([self.TIME_POSTED_MAPPING[time] for time in self.filters["timePostedRange"]]) + + if self.filters.get("workplaceType"): + params["f_WT"] = ",".join([self.WORKPLACE_TYPE_MAPPING[wt] for wt in self.filters["workplaceType"]]) + + if self.filters.get("less_than_10_applicants"): + params["f_EA"] = "true" + + if current_location in self.LOCATION_MAPPING: + params["geoId"] = self.LOCATION_MAPPING[current_location] + + query_string = urllib.parse.urlencode(params, safe=",") + url = f"{self.BASE_URL}?{query_string}" + return url + + def apply_filters_and_search(self): + """Apply filters to the job search and navigate to the search URL.""" + while self.current_location_index < len(self.locations): + search_url = self.construct_url() + self.driver.get(search_url) + + if self.check_no_results(): + print(f"No matching jobs found in {self.locations[self.current_location_index]}.") + self.current_location_index += 1 + else: + break + + def check_no_results(self): + """Check if the job search resulted in no matches.""" + try: + no_results_element = self.driver.find_element(By.CSS_SELECTOR, "div.jobs-search-no-results-banner") + return no_results_element.is_displayed() + except NoSuchElementException: + return False + + def get_response_for_label(self, label_text): + """Get user response for a given label text.""" + current_location = self.locations[self.current_location_index] + if current_location in self.context_data['user_inputs']: + location_specific_inputs = self.context_data['user_inputs'][current_location] + if label_text in location_specific_inputs: + return location_specific_inputs[label_text] + + user_input = input(f"Please provide the answer for '{label_text}': ") + if current_location not in self.context_data['user_inputs']: + self.context_data['user_inputs'][current_location] = {} + self.context_data['user_inputs'][current_location][label_text] = user_input + self.update_config_file() + return user_input + + def get_checkbox_response_for_label(self, label_text): + """Get user response for a checkbox labeled by the given text.""" + current_location = self.locations[self.current_location_index] + if current_location in self.context_data['user_inputs']: + location_specific_inputs = self.context_data['user_inputs'][current_location] + if label_text in location_specific_inputs: + return location_specific_inputs[label_text] + + while True: + user_input = input(f"Do you want to check the box for '{label_text}'? (yes/no): ").strip().lower() + if user_input in ['yes', 'no']: + response = user_input == 'yes' + if current_location not in self.context_data['user_inputs']: + self.context_data['user_inputs'][current_location] = {} + self.context_data['user_inputs'][current_location][label_text] = response + self.update_config_file() + return response + + def get_radio_response_for_label(self, label_text, options): + """Get user response for a radio button group labeled by the given text.""" + current_location = self.locations[self.current_location_index] + if current_location in self.context_data['user_inputs']: + location_specific_inputs = self.context_data['user_inputs'][current_location] + if label_text in location_specific_inputs: + return location_specific_inputs[label_text] + + while True: + print(f"Please select an option for '{label_text}':") + for i, option in enumerate(options): + print(f"{i + 1}. {option}") + user_input = input("Enter the number of your choice: ").strip() + if user_input.isdigit() and 1 <= int(user_input) <= len(options): + response = options[int(user_input) - 1] + if current_location not in self.context_data['user_inputs']: + self.context_data['user_inputs'][current_location] = {} + self.context_data['user_inputs'][current_location][label_text] = response + self.update_config_file() + return response + + def update_config_file(self): + """Update the configuration file with the latest user inputs.""" + with open('config.json', 'w') as config_file: + json.dump(self.context_data, config_file, indent=4) def find_offers(self): - """This function finds all the offers through all the pages result of the search and filter""" - - # find the total amount of results (if the results are above 24-more than one page-, we will scroll trhough all available pages) - total_results = self.driver.find_element_by_class_name("display-flex.t-12.t-black--light.t-normal") - total_results_int = int(total_results.text.split(' ',1)[0].replace(",","")) - print(total_results_int) - - time.sleep(2) - # get results for the first page - current_page = self.driver.current_url - results = self.driver.find_elements_by_class_name("occludable-update.artdeco-list__item--offset-4.artdeco-list__item.p0.ember-view") - - # for each job add, submits application if no questions asked - for result in results: - hover = ActionChains(self.driver).move_to_element(result) - hover.perform() - titles = result.find_elements_by_class_name('job-card-search__title.artdeco-entity-lockup__title.ember-view') - for title in titles: - self.submit_apply(title) - - # if there is more than one page, find the pages and apply to the results of each page - if total_results_int > 24: - time.sleep(2) + """Find and apply to job offers.""" + while self.current_location_index < len(self.locations): + self.apply_filters_and_search() - # find the last page and construct url of each page based on the total amount of pages - find_pages = self.driver.find_elements_by_class_name("artdeco-pagination__indicator.artdeco-pagination__indicator--number") - total_pages = find_pages[len(find_pages)-1].text - total_pages_int = int(re.sub(r"[^\d.]", "", total_pages)) - get_last_page = self.driver.find_element_by_xpath("//button[@aria-label='Page "+str(total_pages_int)+"']") - get_last_page.send_keys(Keys.RETURN) - time.sleep(2) - last_page = self.driver.current_url - total_jobs = int(last_page.split('start=',1)[1]) - - # go through all available pages and job offers and apply - for page_number in range(25,total_jobs+25,25): - self.driver.get(current_page+'&start='+str(page_number)) - time.sleep(2) - results_ext = self.driver.find_elements_by_class_name("occludable-update.artdeco-list__item--offset-4.artdeco-list__item.p0.ember-view") - for result_ext in results_ext: - hover_ext = ActionChains(self.driver).move_to_element(result_ext) - hover_ext.perform() - titles_ext = result_ext.find_elements_by_class_name('job-card-search__title.artdeco-entity-lockup__title.ember-view') - for title_ext in titles_ext: - self.submit_apply(title_ext) - else: - self.close_session() - - def submit_apply(self,job_add): - """This function submits the application for the job add found""" - - print('You are applying to the position of: ', job_add.text) - job_add.click() - time.sleep(2) - - # click on the easy apply button, skip if already applied to the position + while True: + try: + WebDriverWait(self.driver, 10).until( + EC.presence_of_element_located((By.CLASS_NAME, "scaffold-layout__list-container")) + ) + + job_list_container = self.driver.find_element(By.CLASS_NAME, "scaffold-layout__list-container") + job_list_items = job_list_container.find_elements(By.TAG_NAME, "li") + + for index in range(len(job_list_items)): + try: + job_list_container = self.driver.find_element(By.CLASS_NAME, "scaffold-layout__list-container") + job_list_items = job_list_container.find_elements(By.TAG_NAME, "li") + + job_item = job_list_items[index] + job_item.click() + time.sleep(2) + + WebDriverWait(self.driver, 10).until( + EC.presence_of_element_located((By.CLASS_NAME, "jobs-search__job-details--wrapper")) + ) + + if self.job_already_applied(job_item): + print('Job already applied to, moving to next job...') + continue + + company_name = self.get_company_name(job_item) + if company_name and company_name in self.applied_companies: + print(f"Already applied to a job at {company_name}, skipping...") + continue + + job_details_wrapper = self.driver.find_element(By.CLASS_NAME, "jobs-search__job-details--wrapper") + + try: + apply_button = job_details_wrapper.find_element(By.CSS_SELECTOR, "button.jobs-apply-button.artdeco-button--primary") + apply_button.click() + time.sleep(2) + + WebDriverWait(self.driver, 10).until( + EC.presence_of_element_located((By.CSS_SELECTOR, "div.jobs-easy-apply-modal")) + ) + + self.handle_easy_apply() + + if company_name: + self.log_applied_company(company_name) + + except NoSuchElementException: + print('No apply button found, continuing to next job...') + continue + + except (NoSuchElementException, ElementNotInteractableException, StaleElementReferenceException) as e: + print(f'Exception occurred: {e}, continuing to next job...') + self.log_error(f"Find offers error: {e}") + continue + + try: + pagination_container = self.driver.find_element(By.CLASS_NAME, "artdeco-pagination__pages") + next_page_button = pagination_container.find_element(By.XPATH, "//li[contains(@class, 'artdeco-pagination__indicator') and not(contains(@class, 'active selected'))]/button") + self.driver.execute_script("arguments[0].click();", next_page_button) + time.sleep(2) + except NoSuchElementException: + print("No more pages left.") + break + except TimeoutException: + print("Timeout while waiting for job list container.") + self.log_error("Timeout while waiting for job list container.") + break + + self.current_location_index += 1 + + def get_company_name(self, job_item): + """Extract the company name from a job listing.""" try: - in_apply = self.driver.find_element_by_xpath("//button[@data-control-name='jobdetails_topcard_inapply']") - in_apply.click() + company_element = job_item.find_element(By.CSS_SELECTOR, "div.artdeco-entity-lockup__subtitle span.job-card-container__primary-description") + return company_element.text.strip() except NoSuchElementException: - print('You already applied to this job, go to next...') - pass - time.sleep(1) + return None - # try to submit if submit application is available... + def job_already_applied(self, job_item): + """Check if a job has already been applied to.""" try: - submit = self.driver.find_element_by_xpath("//button[@data-control-name='submit_unify']") - submit.send_keys(Keys.RETURN) - - # ... if not available, discard application and go to next + applied_element = job_item.find_element(By.CSS_SELECTOR, "li.job-card-container__footer-item.job-card-container__footer-job-state.t-bold") + if "Applied" in applied_element.text: + return True except NoSuchElementException: - print('Not direct application, going to next...') + pass + + return False + + def handle_easy_apply(self): + """Handle the easy apply process.""" + while True: try: - discard = self.driver.find_element_by_xpath("//button[@data-test-modal-close-btn]") - discard.send_keys(Keys.RETURN) - time.sleep(1) - discard_confirm = self.driver.find_element_by_xpath("//button[@data-test-dialog-primary-btn]") - discard_confirm.send_keys(Keys.RETURN) - time.sleep(1) - except NoSuchElementException: - pass + modal_dialog = WebDriverWait(self.driver, 10).until( + EC.presence_of_element_located((By.CSS_SELECTOR, "div.artdeco-modal--layer-default.jobs-easy-apply-modal")) + ) + try: + next_button = modal_dialog.find_element(By.CSS_SELECTOR, "button[data-easy-apply-next-button]") + self.driver.execute_script("arguments[0].click();", next_button) + time.sleep(2) + except NoSuchElementException: + try: + review_button = modal_dialog.find_element(By.CSS_SELECTOR, "button[aria-label='Review your application']") + self.driver.execute_script("arguments[0].click();", review_button) + time.sleep(2) + except NoSuchElementException: + try: + submit_button = modal_dialog.find_element(By.CSS_SELECTOR, "button[aria-label='Submit application']") + self.driver.execute_script("arguments[0].click();", submit_button) + time.sleep(2) + print("Application submitted.") + self.handle_done_button() + break + except NoSuchElementException: + print('Submit button not found, continuing to next job...') + break + self.fill_form(modal_dialog) + except TimeoutException: + print('No more steps found, exiting...') + break - time.sleep(1) + def fill_form(self, modal_dialog): + """Fill out the application form.""" + form_elements = modal_dialog.find_elements(By.CSS_SELECTOR, "div[data-test-form-element]") + for element in form_elements: + try: + label = element.find_element(By.CSS_SELECTOR, "label, legend") + input_field = element.find_element(By.CSS_SELECTOR, "input, select, textarea") + label_text = label.text.strip() - def close_session(self): - """This function closes the actual session""" - - print('End of the session, see you later!') - self.driver.close() + if input_field.tag_name == "input" and input_field.get_attribute("type") == "text": + response = self.get_response_for_label(label_text) + if input_field.get_attribute("value") == "": + input_field.send_keys(response) + time.sleep(1) + input_field.send_keys(Keys.ARROW_DOWN) + input_field.send_keys(Keys.RETURN) + + elif input_field.tag_name == "select": + response = self.get_response_for_label(label_text) + select_options = input_field.find_elements(By.TAG_NAME, "option") + for option in select_options: + if option.get_attribute("value") == response: + option.click() + break - def apply(self): - """Apply to job offers""" + elif input_field.tag_name == "textarea": + response = self.get_response_for_label(label_text) + if input_field.get_attribute("value") == "": + input_field.send_keys(response) - self.driver.maximize_window() - self.login_linkedin() - time.sleep(5) - self.job_search() - time.sleep(5) - self.filter() - time.sleep(2) - self.find_offers() - time.sleep(2) - self.close_session() + elif input_field.tag_name == "input" and input_field.get_attribute("type") == "checkbox": + checkboxes = element.find_elements(By.CSS_SELECTOR, "input[type='checkbox']") + for checkbox in checkboxes: + checkbox_label = checkbox.find_element(By.XPATH, "./following-sibling::label").text.strip() + response = self.get_checkbox_response_for_label(checkbox_label) + if response is not None: + try: + if response and not checkbox.is_selected(): + self.driver.execute_script("arguments[0].click();", checkbox) + elif not response and checkbox.is_selected(): + self.driver.execute_script("arguments[0].click();", checkbox) + except ElementClickInterceptedException: + self.driver.execute_script("arguments[0].click();", checkbox) + except StaleElementReferenceException: + checkbox = element.find_element(By.XPATH, f".//input[@type='checkbox' and ./following-sibling::label[text()='{checkbox_label}']]") + if response and not checkbox.is_selected(): + self.driver.execute_script("arguments[0].click();", checkbox) + elif not response and checkbox.is_selected(): + self.driver.execute_script("arguments[0].click();", checkbox) + elif input_field.tag_name == "input" and input_field.get_attribute("type") == "radio": + radio_buttons = element.find_elements(By.CSS_SELECTOR, "input[type='radio']") + for radio in radio_buttons: + radio_label = radio.find_element(By.XPATH, "./following-sibling::label").text.strip() + response = self.get_radio_response_for_label(label_text, [rb.find_element(By.XPATH, "./following-sibling::label").text.strip() for rb in radio_buttons]) + if response.lower() == radio_label.lower(): + try: + radio.click() + except ElementClickInterceptedException: + self.driver.execute_script("arguments[0].click();", radio) + break -if __name__ == '__main__': + except NoSuchElementException: + continue + try: + next_button = modal_dialog.find_element(By.CSS_SELECTOR, "button[data-easy-apply-next-button]") + next_button.click() + time.sleep(2) + except NoSuchElementException: + print('Next button not found, form might be complete or there is an issue.') + + def handle_done_button(self): + """Handle the final done button after application submission.""" + try: + done_button = WebDriverWait(self.driver, 10).until( + EC.presence_of_element_located((By.CSS_SELECTOR, "button.artdeco-button.artdeco-button--primary")) + ) + done_button.click() + time.sleep(2) + except TimeoutException: + print("Done button not found, skipping to next job.") + + def close_session(self): + """Close the browser session.""" + print('End of the session') + self.driver.close() + self.driver.quit() + + def handle_captcha(self): + """Handle CAPTCHA prompts manually.""" + print("CAPTCHA detected. Please solve the CAPTCHA manually and then press Enter to continue...") + input() + +if __name__ == "__main__": with open('config.json') as config_file: data = json.load(config_file) - bot = EasyApplyLinkedin(data) - bot.apply() \ No newline at end of file + bot.login_linkedin() + bot.job_search() + bot.find_offers() + bot.close_session() From a37abde50c5ea4fbee4e1b64c1acd0eb9829655c Mon Sep 17 00:00:00 2001 From: Gabo_Tech Date: Fri, 28 Jun 2024 21:48:33 +0200 Subject: [PATCH 02/19] Initial commit with project files --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 54f2283..802732e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ # Ignore everything * - +config.json # Allow specific files !.gitignore !main.py From a3b29d5aa1058885b565a681bc640c1161047bc5 Mon Sep 17 00:00:00 2001 From: Gabriel Clemente Date: Fri, 28 Jun 2024 21:50:40 +0200 Subject: [PATCH 03/19] Delete config.json --- config.json | 358 ---------------------------------------------------- 1 file changed, 358 deletions(-) delete mode 100644 config.json diff --git a/config.json b/config.json deleted file mode 100644 index f32f4bf..0000000 --- a/config.json +++ /dev/null @@ -1,358 +0,0 @@ -{ - "email": "sendmessage@gabo.email", - "password": "*******,********", - "keywords": [ - "TypeScript Engineer", - "Angular Frontend", - "React Frontend", - "React Native", - "Node backend", - "JavaScript Full-Stack" - ], - "locations": [ - "Belgium", - "Netherlands", - "DACH", - "Benelux", - "United Kingdom", - "Switzerland", - "Spain", - "United States", - "European Union", - "European Economic Area", - "Germany" - ], - "driver_path": "/usr/local/bin/geckodriver", - "sortBy": "R", - "filters": { - "easy_apply": true, - "experience": [ - "Entry level", - "Associate" - ], - "jobType": [ - "Full-time", - "Contract" - ], - "timePostedRange": [ - "Past Week" - ], - "workplaceType": [ - "Remote" - ], - "less_than_10_applicants": false - }, - "experience": [ - { - "title": "Full TypeScript Stack Engineer", - "description": "As a Full-Stack TypeScript Engineer at Beyondbmi, I spearhead the development of an advanced online weight loss clinic platform. Utilizing Angular and Bootstrap for the front-end and Express with TypeORM for the back-end, I ensure consistent and reliable performance across the stack with TypeScript. My role involves implementing HIPAA-compliant encryption protocols to safeguard patient data, developing an Android application with React Native, and deploying AWS Lambda functions and AWS Cognito for secure authentication. My contributions have enhanced user experience, data security, and the platform's scalability.", - "date": "Nov 2022 - Present", - "company": "Beyondbmi" - }, - { - "title": "Software Engineer", - "description": "At GABO, I collaborate on a variety of projects, focusing on developing e-commerce sites, corporate websites, and online presences for small businesses. My work emphasizes Search Engine Optimization (SEO) to improve visibility and engagement. Using JavaScript frameworks, WordPress, PHP, and other tools, I deliver tailored, optimized web solutions. My role has honed my versatility in web development and client relationship management, ensuring long-term client satisfaction and successful project outcomes.", - "date": "Nov 2022 - Present", - "company": "GABO" - }, - { - "title": "User Interface Engineer", - "description": "During my internship at talenTeal, I led the comprehensive redesign of the company\u2019s landing page to enhance user experience (UX) and user interface (UI). My focus was on creating an intuitive and navigable website, resulting in improved accessibility and user engagement. This project demonstrated my ability to apply front-end development skills effectively to achieve significant improvements in digital interaction and user satisfaction.", - "date": "Jul 2022 - Aug 2022", - "company": "talenTeal" - }, - { - "title": "Technical Support Specialist", - "description": "At Lujo Network, I enhanced the company\u2019s security infrastructure by implementing firewalls and establishing robust backup systems. I also managed databases, developed website segments, and conducted security training sessions. My efforts improved the company\u2019s digital security framework and online presence, showcasing my ability to handle both technical support and development tasks effectively.", - "date": "Feb 2022 - Mar 2022", - "company": "Lujo Network" - }, - { - "title": "Freelancer", - "description": "I provided web solutions, applying a range of technologies to address client requirements.", - "date": "Feb 2021 - Nov 2022" - } - ], - "projects": [ - { - "title": "BEYONDBMI", - "desc": "As a Full-Stack TypeScript Engineer at Beyondbmi, I spearhead the development of an advanced online weight loss clinic platform. Utilizing Angular and Bootstrap for the front-end and Express with TypeORM for the back-end, I ensure consistent and reliable performance across the stack with TypeScript. My role involves implementing HIPAA-compliant encryption protocols to safeguard patient data, developing an Android application with React Native, and deploying AWS Lambda functions and AWS Cognito for secure authentication. My contributions have enhanced user experience, data security, and the platform's scalability.", - "link": "", - "skills": [ - "JavaScript", - "Angular", - "TypeScript", - "Bootstrap", - "React Native", - "Metro", - "AWS", - "Express", - "TypeORM", - "Postgres", - "Jest", - "CI/CD", - "Docker", - "Jira", - "Bitbucket", - "Git", - "Stripe" - ] - }, - { - "title": "GABO", - "desc": "The GABO Landing Page is a modern, visually appealing website built using the Astro framework, leveraging Solid.js for reactive UI components, Tailwind CSS for efficient styling, and Vercel for scalable hosting. This project integrates Better SQLite3 for lightweight database management, Gray Matter and Marked for markdown content handling, and Next.js with React for dynamic, interactive components. Featuring fast load times, smooth user experience, and improved SEO, the site employs Vercel Analytics and Speed Insights for monitoring performance. Key technologies include Astro, Solid.js, Tailwind CSS, Vercel, Next.js, React, Git, and GitHub.", - "link": "", - "skills": [ - "JavaScript", - "Astro", - "Solid.js", - "Tailwind CSS", - "Vercel", - "Next.js", - "React", - "TypeScript", - "Git", - "GitHub" - ] - }, - { - "title": "TALENTEAL", - "desc": "I led the comprehensive redesign of the company\u2019s landing page to enhance user experience (UX) and user interface (UI). My focus was on creating an intuitive and navigable website, resulting in improved accessibility and user engagement. This project demonstrated my ability to apply front-end development skills effectively to achieve significant improvements in digital interaction and user satisfaction.", - "link": "", - "skills": [ - "JavaScript", - "React", - "Redux", - "Django", - "UI/UX Design", - "Sass", - "Git", - "GitHub" - ] - }, - { - "title": "LUJO NETWORK", - "desc": "At Lujo Network, I led the design and development of the sign-in and sign-up pages, implementing secure forms and internationalization in several languages, along with data validation for the forms. I enhanced the company\u2019s security infrastructure by implementing firewalls and establishing robust backup systems. I also managed databases, developed website segments, and conducted security training sessions. My efforts improved the company\u2019s digital security framework and online presence, showcasing my ability to handle both technical support and development tasks effectively.", - "link": "", - "skills": [ - "JavaScript", - "HTML", - "CSS", - "Database Management", - "Cybersecurity" - ] - }, - { - "title": "EXA MONSTER", - "desc": "Exa Monster is a secure cloud storage solution that prioritizes user privacy. Developed as a SaaS application using Laravel, WordPress, and PHP, and deployed with Hetzner Cloud, it offers robust and secure storage capabilities. The platform also includes a mobile app built with React Native, providing users with seamless access to their data on the go.", - "link": "", - "skills": [ - "Laravel", - "WordPress", - "PHP", - "React Native", - "Hetzner", - "SaaS", - "Stripe" - ] - }, - { - "title": "IMALEVANTE", - "desc": "I designed and developed the Imalevante company landing page using WordPress and PHP. The project involved creating a visually appealing and functional website that effectively represents the company's brand and services.", - "link": "", - "skills": [ - "WordPress", - "PHP" - ] - }, - { - "title": "ROUTEU", - "desc": "RouteU is a fully open-sourced route management site built with the MERN stack. This project offers users the ability to manage and share routes. It is deployed on Vercel and Heroku, providing high availability and performance. The site features a modern UI developed with React, Bootstrap, and Ant Design, and it uses MongoDB Atlas for robust data management.", - "link": "https://route-u.vercel.app", - "skills": [ - "React.js", - "CSS", - "Bootstrap", - "Ant Design", - "Grommet", - "Sass", - "HTML", - "Axios", - "React Router", - "MongoDB", - "Redux", - "Express", - "Node.js" - ] - }, - { - "title": "FIGHT GAME", - "desc": "StreetFighter-like OSG (Open Sourced Game) is a simple, open-sourced fighting game inspired by Street Fighter. Developed with HTML, CSS, and Vanilla JavaScript, the game is designed for two players. It is hosted on GitHub Pages, offering easy access and a fun gaming experience.", - "link": "https://gabo-tech.github.io/StreetFighter-like-OSG", - "skills": [ - "HTML", - "CSS", - "JavaScript" - ] - }, - { - "title": "QUIZ", - "desc": "Custom Quiz is an open-sourced quiz game that uses the Open Trivia API and supports custom questions. Developed with HTML, CSS, and Vanilla JavaScript, the game offers a fun and interactive way to test knowledge on various topics. It is hosted on GitHub Pages.", - "link": "https://gabo-tech.github.io/Custom-Quizz", - "skills": [ - "HTML", - "CSS", - "JavaScript" - ] - }, - { - "title": "GABO SL", - "desc": "Gabo's Social Life is a fully open-sourced social media site built with the MERN stack. This project provides a platform for users to share and interact with content. It is deployed on Vercel and Heroku, ensuring high availability and performance. The site features modern UI components and comprehensive functionality, including user authentication, content sharing, and real-time updates.", - "link": "https://gabosl.com", - "skills": [ - "React.js", - "Sass", - "HTML", - "Axios", - "React Router", - "MongoDB", - "Redux", - "Cypress", - "Express", - "Node.js", - "Material UI", - "Swagger" - ] - }, - { - "title": "DGABO Token", - "desc": "Decentralyized GABO Token is a personal cryptocurrency project developed using JavaScript and Motoko for smart contracts. Although not deployed due to high blockchain fees, this project demonstrates the process of creating and managing a token on the Internet Computer blockchain. The project includes a web mockup and emulator setup instructions for local development.", - "link": "https://github.com/Gabo-Tech/Web3-Token-and-Faucet", - "skills": [ - "JavaScript", - "Motoko", - "HTML", - "CSS", - "React" - ] - }, - { - "title": "My Portfolio", - "desc": "This portfolio it's been designed with tailwinds and framer motion, includes several animaitons and some interactivity with background music to make the experience a bit more chilling. Has been developed with Next.js 14", - "link": "", - "skills": [ - "JavaScript", - "React", - "Next.js", - "Tailwind CSS", - "Framer Motion" - ] - }, - { - "title": "CHURCH OF JESUS CHRIST", - "desc": "This desktop application, developed using Tauri and Svelte, provides a convenient and efficient way to access and read the scriptures from the website of The Church of Jesus Christ of Latter-day Saints. The app features cross-platform compatibility (Windows, macOS, Linux), easy navigation, and a clean interface.", - "link": "https://github.com/Gabo-Tech/Scriptures-of-The-Church-of-Jesus-Christ-of-Latter-Day-Saints", - "skills": [ - "Tauri", - "Svelte", - "JavaScript", - "Node.js" - ] - } - ], - "skills": [ - "React", - "Angular", - "Svelte", - "Solid.js", - "Next.js", - "Astro", - "Tailwind CSS", - "Bootstrap", - "Ant Design", - "Material UI", - "Metro", - "React Native", - "CSS", - "HTML", - "Markdown", - "Sass", - "Grommet", - "Node.js", - "Express", - "TypeORM", - "PostgreSQL", - "Laravel", - "WordPress", - "PHP", - "Hetzner", - "Sequelize", - "Spring Boot", - "GraphQL", - "Apollo", - "JWT", - "Jest", - "Cypress", - "Playwright", - "Postman", - "Firebase", - "MongoDB", - "Redux", - "Axios", - "Swagger", - "Motoko", - "CI/CD", - "Git", - "Docker", - "Jira", - "Bitbucket", - "GitHub Actions", - "AWS", - "Vercel", - "DigitalOcean", - "Heroku", - "Netlify", - "Expo", - "Tauri", - "Recoil", - "Zustand", - "Vite", - "Vitest", - "Bun", - "MySQL", - "MariaDB", - "Mocha", - "NPM", - "Yarn", - "Webpack" - ], - "user_inputs": { - "United Kingdom": { - "City\nCity": "Valencia, Valencian Community, Spain", - "What is your gender?\nWhat is your gender?": "Male", - "Do you consider yourself to be disabled as defined by the Equality Act 2010? The Equality Act defines disability as 'A physical or mental impairment which has a substantial and long-term effect on the person's ability to carry out normal day-to-day activities'.\nDo you consider yourself to be disabled as defined by the Equality Act 2010? The Equality Act defines disability as 'A physical or mental impairment which has a substantial and long-term effect on the person's ability to carry out normal day-to-day activities'.": "No", - "Do you require any particular arrangements to support you in the recruitment and selection process?\nDo you require any particular arrangements to support you in the recruitment and selection process?\nRequired": "No", - "Please provide details of the arrangements you require. If you are invited to interview, we will confirm the arrangements with you ahead of the appointment date and time.": "No arrangements required.", - "What is your ethnic origin?\nWhat is your ethnic origin?": "White: Any other background", - "I Agree Terms & Conditions": true, - "TV Advert": false, - "CV Library": false, - "CW Jobs": false, - "Indeed": false, - "Jobsite": false, - "LinkedIn": true, - "Will you now or in the future require sponsorship for employment visa status?\nWill you now or in the future require sponsorship for employment visa status?\nRequired": "No", - "What is your current location?": "Valencia, Valencian Community, Spain", - "Have you completed the following level of education: Bachelor's Degree?\nHave you completed the following level of education: Bachelor's Degree?\nRequired": "No", - "How many years of work experience do you have with Oracle Cloud?": "0", - "How many years of work experience do you have with Oracle ERP Implementations?": "0", - "How many years of work experience do you have with Source to Pay?": "0", - "Are you legally authorized to work in United Kingdom?\nAre you legally authorized to work in United Kingdom?\nRequired": "Yes", - "Legal Name (if different than above)": "Gabriel Clemente Ramos", - "How did you hear about this job?": "LinkedIn", - "This job post is for positions in the EMEA region. Please confirm if you have the right to work in Portugal and/or UK\nThis job post is for positions in the EMEA region. Please confirm if you have the right to work in Portugal and/or UK": "Yes", - "Do you now or will you in the future require immigration sponsorship to work at Cloudflare?\nDo you now or will you in the future require immigration sponsorship to work at Cloudflare?": "No", - "Acknowledge/Confirm": true - } - } -} \ No newline at end of file From 5f0dcee6074718ff454038d6fb8f2fbcc7d8e693 Mon Sep 17 00:00:00 2001 From: Gabriel Clemente Date: Fri, 28 Jun 2024 22:20:15 +0200 Subject: [PATCH 04/19] Update README.md --- README.md | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 6375aae..da38449 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,8 @@ These instructions will get you a copy of the project up and running on your loc 1. Clone the repository: ```sh - git clone https://github.com/your_username/easyapply-linkedin.git - cd easyapply-linkedin + git clone https://github.com/Gabo-Tech/EasyApply-Linkedin.git + cd EasyApply-Linkedin ``` 2. Install the necessary packages: @@ -75,6 +75,25 @@ These instructions will get you a copy of the project up and running on your loc } ``` +4. Update the locations code in the script: + ```python + LOCATION_MAPPING = { + "Switzerland": "106693272", + "Spain": "105646813", + "United States": "103644278", + "United Kingdom": "101165590", + "European Union": "91000000", + "European Economic Area": "91000002", + "DACH": "91000006", + "Benelux": "91000005", + "Netherlands": "102890719", + "Belgium":"100565514", + "Germany": "101282230" + } + ``` + This you can find the code in the geoId found in the LinkedIn url after doing a job search. + These are the right ones if you don't want to look in other places, but there are many more. + ### Usage 1. Run the application: @@ -105,4 +124,4 @@ Please feel free to comment or give suggestions/issues. Fork and submit pull req ### License -This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. +This project is licensed under the MIT License - see the [LICENSE](/LICENSE) file for details. From ef9d1bdb0bf89d8f5f209898c011d311653f9868 Mon Sep 17 00:00:00 2001 From: Gabriel Clemente Date: Fri, 28 Jun 2024 22:37:32 +0200 Subject: [PATCH 05/19] Create requirements.txt --- requirements.txt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 requirements.txt diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..53af604 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,17 @@ +attrs==23.2.0 +certifi==2024.6.2 +click==8.1.7 +exceptiongroup==1.2.1 +h11==0.14.0 +idna==3.7 +outcome==1.3.0.post0 +PySocks==1.7.1 +selenium==4.22.0 +sniffio==1.3.1 +sortedcontainers==2.4.0 +trio==0.25.1 +trio-websocket==0.11.1 +typing_extensions==4.12.2 +urllib3==2.2.2 +websocket-client==1.8.0 +wsproto==1.2.0 From bce8c6739f32f38d8118679da9c4a8eea07d95bb Mon Sep 17 00:00:00 2001 From: Gabriel Clemente Date: Fri, 28 Jun 2024 22:38:04 +0200 Subject: [PATCH 06/19] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index da38449..cdf7e6f 100644 --- a/README.md +++ b/README.md @@ -124,4 +124,4 @@ Please feel free to comment or give suggestions/issues. Fork and submit pull req ### License -This project is licensed under the MIT License - see the [LICENSE](/LICENSE) file for details. +This project is licensed under the MIT License - see the [LICENSE](https://github.com/Gabo-Tech/EasyApply-Linkedin/blob/master/LICENCE) file for details. From 539bfa931cfe6708097a157fd4c64d80d20b78ed Mon Sep 17 00:00:00 2001 From: Gabriel Clemente Date: Fri, 28 Jun 2024 22:42:16 +0200 Subject: [PATCH 07/19] Update .gitignore --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 802732e..daadb96 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ config.json !main.py !configExample.json !README.md -!LICENCE \ No newline at end of file +!LICENCE +!requirements.txt From 89b11c658a29bcb9bb7230f53ae7462157e26f11 Mon Sep 17 00:00:00 2001 From: Gabo_Tech Date: Sun, 30 Jun 2024 02:47:35 +0200 Subject: [PATCH 08/19] added excluding filters to create more complex and specific search queries and added tests too --- .gitignore | 2 + README.md | 54 ++++++--- configExample.json | 88 ++++++++------ main.py | 296 ++++++++++++++++++++++++++++++++------------- requirements.txt | 6 + 5 files changed, 308 insertions(+), 138 deletions(-) diff --git a/.gitignore b/.gitignore index daadb96..bb768c9 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ config.json !README.md !LICENCE !requirements.txt +!e2e_tests.txt +!unit_tests.txt \ No newline at end of file diff --git a/README.md b/README.md index cdf7e6f..ca05ef7 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ These instructions will get you a copy of the project up and running on your loc 2. Selenium requires a driver to interface with the chosen browser. Make sure the driver is in your path, you will need to add your `driver_path` to the `config.json` file. - I used the Chrome driver, you can download it [here](https://sites.google.com/a/chromium.org/chromedriver/downloads). You can also download drivers for [Edge](https://developer.microsoft.com/en-us/microsoft-edge/tools/webdriver/), [Firefox](https://github.com/mozilla/geckodriver/releases), or [Safari](https://webkit.org/blog/6900/webdriver-support-in-safari-10/), depending on your preferred browser. + I used the Firefox driver, you can download it [here](https://github.com/mozilla/geckodriver/releases). You can also download drivers for [Chrome](https://sites.google.com/a/chromium.org/chromedriver/downloads), [Edge](https://developer.microsoft.com/en-us/microsoft-edge/tools/webdriver/), or [Safari](https://webkit.org/blog/6900/webdriver-support-in-safari-10/), depending on your preferred browser. ### Installation @@ -36,8 +36,9 @@ These instructions will get you a copy of the project up and running on your loc "email": "example@example.com", "password": "securePassword123!", "keywords": ["Web Developer", "JavaScript", "React"], + "keywordsToAvoid": ["C++", ".NET"], "locations": ["New York", "Los Angeles", "San Francisco"], - "driver_path": "/usr/local/bin/chromedriver", + "driver_path": "/usr/local/bin/geckodriver", "sortBy": "Alphabetical", "filters": { "easy_apply": true, @@ -77,19 +78,19 @@ These instructions will get you a copy of the project up and running on your loc 4. Update the locations code in the script: ```python - LOCATION_MAPPING = { - "Switzerland": "106693272", - "Spain": "105646813", - "United States": "103644278", - "United Kingdom": "101165590", - "European Union": "91000000", - "European Economic Area": "91000002", - "DACH": "91000006", - "Benelux": "91000005", - "Netherlands": "102890719", - "Belgium":"100565514", - "Germany": "101282230" - } + LOCATION_MAPPING = { + "Switzerland": "106693272", + "Spain": "105646813", + "United States": "103644278", + "United Kingdom": "101165590", + "European Union": "91000000", + "European Economic Area": "91000002", + "DACH": "91000006", + "Benelux": "91000005", + "Netherlands": "102890719", + "Belgium": "100565514", + "Germany": "101282230" + } ``` This you can find the code in the geoId found in the LinkedIn url after doing a job search. These are the right ones if you don't want to look in other places, but there are many more. @@ -113,15 +114,36 @@ You can customize the job search and application process by editing the `config. - **email**: Your LinkedIn email address. - **password**: Your LinkedIn password. - **keywords**: Keywords for finding specific job titles (e.g., "Machine Learning Engineer", "Data Scientist"). +- **keywordsToAvoid**: Keywords to exclude from your search. - **locations**: Locations where you are currently looking for a position. - **driver_path**: Path to your downloaded WebDriver. - **sortBy**: Sort order for job listings. - **filters**: Various filters to narrow down the job search (e.g., easy apply, experience level, job type, etc.). +### Testing + +#### Unit Tests + +Unit tests mock the Selenium WebDriver to test methods in isolation without making actual web requests. + +Run the unit tests: +```bash +python unit_tests.py +``` + +#### E2E Tests + +End-to-end tests using `pytest` and `selenium` require an actual web browser to run. + +Run the E2E tests: +```bash +pytest e2e_tests.py +``` + ### Contributing Please feel free to comment or give suggestions/issues. Fork and submit pull requests for any enhancements or bug fixes. ### License -This project is licensed under the MIT License - see the [LICENSE](https://github.com/Gabo-Tech/EasyApply-Linkedin/blob/master/LICENCE) file for details. +This project is licensed under the MIT License - see the [LICENSE](https://github.com/Gabo-Tech/EasyApply-Linkedin/blob/master/LICENCE) file for details. \ No newline at end of file diff --git a/configExample.json b/configExample.json index fd36f61..2810b50 100644 --- a/configExample.json +++ b/configExample.json @@ -1,41 +1,55 @@ { - "email": "example@example.com", - "password": "securePassword123!", - "keywords": ["Web Developer", "JavaScript", "React"], - "locations": ["New York", "Los Angeles", "San Francisco"], - "driver_path": "/usr/local/bin/chromedriver", - "sortBy": "Alphabetical", - "filters": { - "easy_apply": true, - "experience": ["Internship", "Entry Level", "Associate", "Mid-Senior Level", "Director", "Executive"], - "jobType": ["Full-Time", "Part-Time", "Contract", "Internship", "Temporary"], - "timePostedRange": ["Any Time", "Last Month", "Past Week", "Past 24 Hours"], - "workplaceType": ["Remote", "Hybrid", "On-site"], - "less_than_10_applicants": true, - "commitments": ["Full-Time", "Part-Time", "Contract", "Temporary", "Volunteer"] - }, + "email": "example@example.com", + "password": "securePassword123!", + "keywords": ["Web Developer", "JavaScript", "React"], + "keywordsToAvoid": ["C++", ".NET", "Analyst", "PHP", "Python", "C", "Java"], + "locations": ["New York", "Los Angeles", "San Francisco"], + "driver_path": "/usr/local/bin/chromedriver", + "sortBy": "Alphabetical", + "filters": { + "easy_apply": true, "experience": [ - { - "title": "Junior Web Developer", - "description": "Developing responsive web applications using JavaScript and React.", - "date": "Jan 2023 - Present", - "company": "Example Company" - } + "Internship", + "Entry Level", + "Associate", + "Mid-Senior Level", + "Director", + "Executive" ], - "projects": [ - { - "title": "Project Alpha", - "desc": "A project description here...", - "link": "#", - "skills": ["JavaScript", "React", "Node.js"] - } + "jobType": [ + "Full-Time", + "Part-Time", + "Contract", + "Internship", + "Temporary" ], - "skills": [ - "JavaScript", - "React", - "Node.js", - "Express", - "MongoDB" - ], - "user_inputs": {} - } \ No newline at end of file + "timePostedRange": ["Any Time", "Last Month", "Past Week", "Past 24 Hours"], + "workplaceType": ["Remote", "Hybrid", "On-site"], + "less_than_10_applicants": true, + "commitments": [ + "Full-Time", + "Part-Time", + "Contract", + "Temporary", + "Volunteer" + ] + }, + "experience": [ + { + "title": "Junior Web Developer", + "description": "Developing responsive web applications using JavaScript and React.", + "date": "Jan 2023 - Present", + "company": "Example Company" + } + ], + "projects": [ + { + "title": "Project Alpha", + "desc": "A project description here...", + "link": "#", + "skills": ["JavaScript", "React", "Node.js"] + } + ], + "skills": ["JavaScript", "React", "Node.js", "Express", "MongoDB"], + "user_inputs": {} +} diff --git a/main.py b/main.py index 99e0df4..aa9c644 100644 --- a/main.py +++ b/main.py @@ -9,9 +9,16 @@ from selenium.webdriver.common.keys import Keys from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.support.ui import WebDriverWait -from selenium.common.exceptions import NoSuchElementException, ElementNotInteractableException, StaleElementReferenceException, TimeoutException, ElementClickInterceptedException +from selenium.common.exceptions import ( + NoSuchElementException, + ElementNotInteractableException, + StaleElementReferenceException, + TimeoutException, + ElementClickInterceptedException, +) from selenium.webdriver.firefox.service import Service as FirefoxService + class EasyApplyLinkedin: BASE_URL = "https://www.linkedin.com/jobs/search/" ERROR_LOG_PATH = Path("error_log.json") @@ -21,7 +28,7 @@ class EasyApplyLinkedin: "Any Time": "", "Last Month": "r2592000", "Past Week": "r604800", - "Past 24 hours": "r86400" + "Past 24 hours": "r86400", } EXPERIENCE_MAPPING = { @@ -30,13 +37,13 @@ class EasyApplyLinkedin: "Associate": "3", "Mid-Senior level": "4", "Director": "5", - "Executive": "6" + "Executive": "6", } WORKPLACE_TYPE_MAPPING = { "Remote": "2", "Hybrid": "3", - "On-site": "1" + "On-site": "1", } JOB_TYPE_MAPPING = { @@ -44,7 +51,7 @@ class EasyApplyLinkedin: "Part-time": "P", "Contract": "C", "Internship": "I", - "Temporary": "T" + "Temporary": "T", } TITLE_MAPPING = { @@ -52,7 +59,7 @@ class EasyApplyLinkedin: "Developer": "25201", "Manager": "25170", "Specialist": "1456", - "Consultant": "3731" + "Consultant": "3731", } COMMITMENTS_MAPPING = { @@ -60,7 +67,7 @@ class EasyApplyLinkedin: "Part-time": "2", "Contract": "3", "Temporary": "4", - "Volunteer": "5" + "Volunteer": "5", } LOCATION_MAPPING = { @@ -73,23 +80,24 @@ class EasyApplyLinkedin: "DACH": "91000006", "Benelux": "91000005", "Netherlands": "102890719", - "Belgium":"100565514", - "Germany": "101282230" + "Belgium": "100565514", + "Germany": "101282230", } def __init__(self, data): """Initialize the EasyApplyLinkedin instance with user data.""" - self.email = data['email'] - self.password = data['password'] - self.keywords = ' OR '.join(data['keywords']) - self.locations = data['locations'] - self.filters = data['filters'] - self.sort_by = data['sortBy'] + self.email = data["email"] + self.password = data["password"] + self.keywords = " OR ".join(data["keywords"]) + self.keywords_to_avoid = " NOT ".join(data["keywordsToAvoid"]) + self.locations = data["locations"] + self.filters = data["filters"] + self.sort_by = data["sortBy"] self.context_data = data self.current_location_index = 0 - if 'user_inputs' not in self.context_data: - self.context_data['user_inputs'] = {} - firefox_service = FirefoxService(executable_path=data['driver_path']) + if "user_inputs" not in self.context_data: + self.context_data["user_inputs"] = {} + firefox_service = FirefoxService(executable_path=data["driver_path"]) self.driver = webdriver.Firefox(service=firefox_service) self.init_logging() @@ -102,13 +110,13 @@ def init_logging(self): def load_json(self, path): """Load JSON data from the specified path.""" if path.exists(): - with path.open('r') as file: + with path.open("r") as file: return json.load(file) return {} def save_json(self, path, data): """Save JSON data to the specified path.""" - with path.open('w') as file: + with path.open("w") as file: json.dump(data, file, indent=4) def log_error(self, error_msg): @@ -134,23 +142,33 @@ def log_applied_company(self, company): def cleanup_applied_companies_log(self): """Clean up logs of applied companies older than 2 weeks.""" cutoff = datetime.now() - timedelta(weeks=2) - self.applied_companies = {k: v for k, v in self.applied_companies.items() if datetime.fromisoformat(v) > cutoff} + self.applied_companies = { + k: v + for k, v in self.applied_companies.items() + if datetime.fromisoformat(v) > cutoff + } self.save_json(self.APPLIED_COMPANIES_LOG_PATH, self.applied_companies) def login_linkedin(self): """Log in to LinkedIn using the provided credentials.""" try: self.driver.get("https://www.linkedin.com/login") - WebDriverWait(self.driver, 10).until(EC.presence_of_element_located((By.NAME, 'session_key'))) - login_email = self.driver.find_element(By.NAME, 'session_key') + WebDriverWait(self.driver, 10).until( + EC.presence_of_element_located((By.NAME, "session_key")) + ) + login_email = self.driver.find_element(By.NAME, "session_key") login_email.clear() login_email.send_keys(self.email) - WebDriverWait(self.driver, 10).until(EC.presence_of_element_located((By.NAME, 'session_password'))) - login_pass = self.driver.find_element(By.NAME, 'session_password') + WebDriverWait(self.driver, 10).until( + EC.presence_of_element_located((By.NAME, "session_password")) + ) + login_pass = self.driver.find_element(By.NAME, "session_password") login_pass.clear() login_pass.send_keys(self.password) login_pass.send_keys(Keys.RETURN) - WebDriverWait(self.driver, 30).until(EC.presence_of_element_located((By.LINK_TEXT, 'Jobs'))) + WebDriverWait(self.driver, 30).until( + EC.presence_of_element_located((By.LINK_TEXT, "Jobs")) + ) except Exception as e: self.log_error(f"Login error: {e}") @@ -158,15 +176,31 @@ def job_search(self): """Perform job search based on keywords and locations.""" while self.current_location_index < len(self.locations): try: - WebDriverWait(self.driver, 20).until(EC.presence_of_element_located((By.LINK_TEXT, 'Jobs'))) - jobs_link = self.driver.find_element(By.LINK_TEXT, 'Jobs') + WebDriverWait(self.driver, 20).until( + EC.presence_of_element_located((By.LINK_TEXT, "Jobs")) + ) + jobs_link = self.driver.find_element(By.LINK_TEXT, "Jobs") jobs_link.click() - WebDriverWait(self.driver, 20).until(EC.presence_of_element_located((By.CSS_SELECTOR, "input[aria-label='Search by title, skill, or company']"))) - search_keywords = self.driver.find_element(By.CSS_SELECTOR, "input[aria-label='Search by title, skill, or company']") + WebDriverWait(self.driver, 20).until( + EC.presence_of_element_located( + (By.CSS_SELECTOR, "input[aria-label='Search by title, skill, or company']") + ) + ) + search_keywords = self.driver.find_element( + By.CSS_SELECTOR, "input[aria-label='Search by title, skill, or company']" + ) search_keywords.clear() search_keywords.send_keys(self.keywords) - WebDriverWait(self.driver, 20).until(EC.presence_of_element_located((By.CSS_SELECTOR, "input[aria-label='City, state, or zip code']"))) - search_location = self.driver.find_element(By.CSS_SELECTOR, "input[aria-label='City, state, or zip code']") + search_keywords.send_keys(" NOT ") + search_keywords.send_keys(self.keywords_to_avoid) + WebDriverWait(self.driver, 20).until( + EC.presence_of_element_located( + (By.CSS_SELECTOR, "input[aria-label='City, state, or zip code']") + ) + ) + search_location = self.driver.find_element( + By.CSS_SELECTOR, "input[aria-label='City, state, or zip code']" + ) search_location.clear() search_location.send_keys(self.locations[self.current_location_index]) search_keywords.click() @@ -175,7 +209,9 @@ def job_search(self): if not self.check_no_results(): break else: - print(f"No matching jobs found in {self.locations[self.current_location_index]}.") + print( + f"No matching jobs found in {self.locations[self.current_location_index]}." + ) self.current_location_index += 1 except TimeoutException: @@ -189,26 +225,34 @@ def construct_url(self): """Construct the URL for job search with applied filters.""" current_location = self.locations[self.current_location_index] params = { - "keywords": self.keywords, + "keywords": f"({self.keywords}) NOT ({self.keywords_to_avoid})", "origin": "JOB_SEARCH_PAGE_JOB_FILTER", "refresh": "true", - "sortBy": self.sort_by + "sortBy": self.sort_by, } if self.filters.get("easy_apply"): params["f_AL"] = "true" if self.filters.get("experience"): - params["f_E"] = ",".join([self.EXPERIENCE_MAPPING[exp] for exp in self.filters["experience"]]) + params["f_E"] = ",".join( + [self.EXPERIENCE_MAPPING[exp] for exp in self.filters["experience"]] + ) if self.filters.get("jobType"): - params["f_JT"] = ",".join([self.JOB_TYPE_MAPPING[jt] for jt in self.filters["jobType"]]) + params["f_JT"] = ",".join( + [self.JOB_TYPE_MAPPING[jt] for jt in self.filters["jobType"]] + ) if self.filters.get("timePostedRange"): - params["f_TPR"] = ",".join([self.TIME_POSTED_MAPPING[time] for time in self.filters["timePostedRange"]]) + params["f_TPR"] = ",".join( + [self.TIME_POSTED_MAPPING[time] for time in self.filters["timePostedRange"]] + ) if self.filters.get("workplaceType"): - params["f_WT"] = ",".join([self.WORKPLACE_TYPE_MAPPING[wt] for wt in self.filters["workplaceType"]]) + params["f_WT"] = ",".join( + [self.WORKPLACE_TYPE_MAPPING[wt] for wt in self.filters["workplaceType"]] + ) if self.filters.get("less_than_10_applicants"): params["f_EA"] = "true" @@ -235,7 +279,9 @@ def apply_filters_and_search(self): def check_no_results(self): """Check if the job search resulted in no matches.""" try: - no_results_element = self.driver.find_element(By.CSS_SELECTOR, "div.jobs-search-no-results-banner") + no_results_element = self.driver.find_element( + By.CSS_SELECTOR, "div.jobs-search-no-results-banner" + ) return no_results_element.is_displayed() except NoSuchElementException: return False @@ -243,41 +289,43 @@ def check_no_results(self): def get_response_for_label(self, label_text): """Get user response for a given label text.""" current_location = self.locations[self.current_location_index] - if current_location in self.context_data['user_inputs']: - location_specific_inputs = self.context_data['user_inputs'][current_location] + if current_location in self.context_data["user_inputs"]: + location_specific_inputs = self.context_data["user_inputs"][current_location] if label_text in location_specific_inputs: return location_specific_inputs[label_text] user_input = input(f"Please provide the answer for '{label_text}': ") - if current_location not in self.context_data['user_inputs']: - self.context_data['user_inputs'][current_location] = {} - self.context_data['user_inputs'][current_location][label_text] = user_input + if current_location not in self.context_data["user_inputs"]: + self.context_data["user_inputs"][current_location] = {} + self.context_data["user_inputs"][current_location][label_text] = user_input self.update_config_file() return user_input def get_checkbox_response_for_label(self, label_text): """Get user response for a checkbox labeled by the given text.""" current_location = self.locations[self.current_location_index] - if current_location in self.context_data['user_inputs']: - location_specific_inputs = self.context_data['user_inputs'][current_location] + if current_location in self.context_data["user_inputs"]: + location_specific_inputs = self.context_data["user_inputs"][current_location] if label_text in location_specific_inputs: return location_specific_inputs[label_text] while True: - user_input = input(f"Do you want to check the box for '{label_text}'? (yes/no): ").strip().lower() - if user_input in ['yes', 'no']: - response = user_input == 'yes' - if current_location not in self.context_data['user_inputs']: - self.context_data['user_inputs'][current_location] = {} - self.context_data['user_inputs'][current_location][label_text] = response + user_input = input( + f"Do you want to check the box for '{label_text}'? (yes/no): " + ).strip().lower() + if user_input in ["yes", "no"]: + response = user_input == "yes" + if current_location not in self.context_data["user_inputs"]: + self.context_data["user_inputs"][current_location] = {} + self.context_data["user_inputs"][current_location][label_text] = response self.update_config_file() return response def get_radio_response_for_label(self, label_text, options): """Get user response for a radio button group labeled by the given text.""" current_location = self.locations[self.current_location_index] - if current_location in self.context_data['user_inputs']: - location_specific_inputs = self.context_data['user_inputs'][current_location] + if current_location in self.context_data["user_inputs"]: + location_specific_inputs = self.context_data["user_inputs"][current_location] if label_text in location_specific_inputs: return location_specific_inputs[label_text] @@ -288,15 +336,15 @@ def get_radio_response_for_label(self, label_text, options): user_input = input("Enter the number of your choice: ").strip() if user_input.isdigit() and 1 <= int(user_input) <= len(options): response = options[int(user_input) - 1] - if current_location not in self.context_data['user_inputs']: - self.context_data['user_inputs'][current_location] = {} - self.context_data['user_inputs'][current_location][label_text] = response + if current_location not in self.context_data["user_inputs"]: + self.context_data["user_inputs"][current_location] = {} + self.context_data["user_inputs"][current_location][label_text] = response self.update_config_file() return response def update_config_file(self): """Update the configuration file with the latest user inputs.""" - with open('config.json', 'w') as config_file: + with open("config.json", "w") as config_file: json.dump(self.context_data, config_file, indent=4) def find_offers(self): @@ -310,40 +358,56 @@ def find_offers(self): EC.presence_of_element_located((By.CLASS_NAME, "scaffold-layout__list-container")) ) - job_list_container = self.driver.find_element(By.CLASS_NAME, "scaffold-layout__list-container") + job_list_container = self.driver.find_element( + By.CLASS_NAME, "scaffold-layout__list-container" + ) job_list_items = job_list_container.find_elements(By.TAG_NAME, "li") for index in range(len(job_list_items)): try: - job_list_container = self.driver.find_element(By.CLASS_NAME, "scaffold-layout__list-container") + job_list_container = self.driver.find_element( + By.CLASS_NAME, "scaffold-layout__list-container" + ) job_list_items = job_list_container.find_elements(By.TAG_NAME, "li") - + job_item = job_list_items[index] job_item.click() time.sleep(2) WebDriverWait(self.driver, 10).until( - EC.presence_of_element_located((By.CLASS_NAME, "jobs-search__job-details--wrapper")) + EC.presence_of_element_located( + (By.CLASS_NAME, "jobs-search__job-details--wrapper") + ) ) if self.job_already_applied(job_item): - print('Job already applied to, moving to next job...') + print("Job already applied to, moving to next job...") + self.close_application_modal() continue company_name = self.get_company_name(job_item) if company_name and company_name in self.applied_companies: - print(f"Already applied to a job at {company_name}, skipping...") + print( + f"Already applied to a job at {company_name}, skipping..." + ) + self.close_application_modal() continue - job_details_wrapper = self.driver.find_element(By.CLASS_NAME, "jobs-search__job-details--wrapper") + job_details_wrapper = self.driver.find_element( + By.CLASS_NAME, "jobs-search__job-details--wrapper" + ) try: - apply_button = job_details_wrapper.find_element(By.CSS_SELECTOR, "button.jobs-apply-button.artdeco-button--primary") + apply_button = job_details_wrapper.find_element( + By.CSS_SELECTOR, "button.jobs-apply-button.artdeco-button--primary" + ) apply_button.click() time.sleep(2) WebDriverWait(self.driver, 10).until( - EC.presence_of_element_located((By.CSS_SELECTOR, "div.jobs-easy-apply-modal")) + EC.presence_of_element_located( + (By.CSS_SELECTOR, "div.jobs-easy-apply-modal") + ) ) self.handle_easy_apply() @@ -352,17 +416,26 @@ def find_offers(self): self.log_applied_company(company_name) except NoSuchElementException: - print('No apply button found, continuing to next job...') + print("No apply button found, continuing to next job...") continue - except (NoSuchElementException, ElementNotInteractableException, StaleElementReferenceException) as e: - print(f'Exception occurred: {e}, continuing to next job...') + except ( + NoSuchElementException, + ElementNotInteractableException, + StaleElementReferenceException, + ) as e: + print(f"Exception occurred: {e}, continuing to next job...") self.log_error(f"Find offers error: {e}") continue try: - pagination_container = self.driver.find_element(By.CLASS_NAME, "artdeco-pagination__pages") - next_page_button = pagination_container.find_element(By.XPATH, "//li[contains(@class, 'artdeco-pagination__indicator') and not(contains(@class, 'active selected'))]/button") + pagination_container = self.driver.find_element( + By.CLASS_NAME, "artdeco-pagination__pages" + ) + next_page_button = pagination_container.find_element( + By.XPATH, + "//li[contains(@class, 'artdeco-pagination__indicator') and not(contains(@class, 'active selected'))]/button", + ) self.driver.execute_script("arguments[0].click();", next_page_button) time.sleep(2) except NoSuchElementException: @@ -378,7 +451,10 @@ def find_offers(self): def get_company_name(self, job_item): """Extract the company name from a job listing.""" try: - company_element = job_item.find_element(By.CSS_SELECTOR, "div.artdeco-entity-lockup__subtitle span.job-card-container__primary-description") + company_element = job_item.find_element( + By.CSS_SELECTOR, + "div.artdeco-entity-lockup__subtitle span.job-card-container__primary-description", + ) return company_element.text.strip() except NoSuchElementException: return None @@ -386,7 +462,10 @@ def get_company_name(self, job_item): def job_already_applied(self, job_item): """Check if a job has already been applied to.""" try: - applied_element = job_item.find_element(By.CSS_SELECTOR, "li.job-card-container__footer-item.job-card-container__footer-job-state.t-bold") + applied_element = job_item.find_element( + By.CSS_SELECTOR, + "li.job-card-container__footer-item.job-card-container__footer-job-state.t-bold", + ) if "Applied" in applied_element.text: return True except NoSuchElementException: @@ -399,36 +478,52 @@ def handle_easy_apply(self): while True: try: modal_dialog = WebDriverWait(self.driver, 10).until( - EC.presence_of_element_located((By.CSS_SELECTOR, "div.artdeco-modal--layer-default.jobs-easy-apply-modal")) + EC.presence_of_element_located( + (By.CSS_SELECTOR, "div.artdeco-modal--layer-default.jobs-easy-apply-modal") + ) ) try: - next_button = modal_dialog.find_element(By.CSS_SELECTOR, "button[data-easy-apply-next-button]") + next_button = modal_dialog.find_element( + By.CSS_SELECTOR, "button[data-easy-apply-next-button]" + ) self.driver.execute_script("arguments[0].click();", next_button) time.sleep(2) except NoSuchElementException: try: - review_button = modal_dialog.find_element(By.CSS_SELECTOR, "button[aria-label='Review your application']") + review_button = modal_dialog.find_element( + By.CSS_SELECTOR, "button[aria-label='Review your application']" + ) self.driver.execute_script("arguments[0].click();", review_button) time.sleep(2) except NoSuchElementException: try: - submit_button = modal_dialog.find_element(By.CSS_SELECTOR, "button[aria-label='Submit application']") + submit_button = modal_dialog.find_element( + By.CSS_SELECTOR, "button[aria-label='Submit application']" + ) self.driver.execute_script("arguments[0].click();", submit_button) time.sleep(2) print("Application submitted.") self.handle_done_button() break except NoSuchElementException: - print('Submit button not found, continuing to next job...') + print("Submit button not found, continuing to next job...") + self.close_application_modal() break self.fill_form(modal_dialog) except TimeoutException: - print('No more steps found, exiting...') + print("No more steps found, exiting...") + break + except Exception as e: + print(f"Error during easy apply: {e}, skipping to next job...") + self.log_error(f"Easy apply error: {e}") + self.close_application_modal() break def fill_form(self, modal_dialog): """Fill out the application form.""" - form_elements = modal_dialog.find_elements(By.CSS_SELECTOR, "div[data-test-form-element]") + form_elements = modal_dialog.find_elements( + By.CSS_SELECTOR, "div[data-test-form-element], fieldset[data-test-form-builder-radio-button-form-component], fieldset[data-test-checkbox-form-component]" + ) for element in form_elements: try: label = element.find_element(By.CSS_SELECTOR, "label, legend") @@ -496,7 +591,7 @@ def fill_form(self, modal_dialog): next_button.click() time.sleep(2) except NoSuchElementException: - print('Next button not found, form might be complete or there is an issue.') + print("Next button not found, form might be complete or there is an issue.") def handle_done_button(self): """Handle the final done button after application submission.""" @@ -509,9 +604,39 @@ def handle_done_button(self): except TimeoutException: print("Done button not found, skipping to next job.") + def close_application_modal(self): + """Close the application modal.""" + try: + close_button = WebDriverWait(self.driver, 10).until( + EC.presence_of_element_located( + ( + By.CSS_SELECTOR, + "button.artdeco-button.artdeco-button--circle.artdeco-button--muted.artdeco-button--2.artdeco-button--tertiary.artdeco-modal__dismiss", + ) + ) + ) + close_button.click() + time.sleep(2) + self.handle_discard_dialog() + except TimeoutException: + print("Close button not found, skipping to next job.") + + def handle_discard_dialog(self): + """Handle the discard dialog when closing the application modal.""" + try: + discard_button = WebDriverWait(self.driver, 10).until( + EC.presence_of_element_located( + (By.CSS_SELECTOR, "button[data-control-name='discard_application_confirm_btn']") + ) + ) + discard_button.click() + time.sleep(2) + except TimeoutException: + print("Discard button not found, skipping to next job.") + def close_session(self): """Close the browser session.""" - print('End of the session') + print("End of the session") self.driver.close() self.driver.quit() @@ -520,8 +645,9 @@ def handle_captcha(self): print("CAPTCHA detected. Please solve the CAPTCHA manually and then press Enter to continue...") input() + if __name__ == "__main__": - with open('config.json') as config_file: + with open("config.json") as config_file: data = json.load(config_file) bot = EasyApplyLinkedin(data) bot.login_linkedin() diff --git a/requirements.txt b/requirements.txt index 53af604..4ebcdf2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,3 +15,9 @@ typing_extensions==4.12.2 urllib3==2.2.2 websocket-client==1.8.0 wsproto==1.2.0 +iniconfig==1.1.1 +packaging==21.0 +pluggy==0.13.1 +py==1.10.0 +pyparsing==2.4.7 +pytest==6.2.4 From e61c848bc9de04bb3872bf0a8cff5a3b1ed8206f Mon Sep 17 00:00:00 2001 From: Gabo_Tech Date: Sun, 30 Jun 2024 02:48:24 +0200 Subject: [PATCH 09/19] added excluding filters to create more complex and specific search queries and added tests too --- .gitignore | 4 ++-- e2e_tests.py | 52 ++++++++++++++++++++++++++++++++++++++++++++++++ unit_tests.py | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 e2e_tests.py create mode 100644 unit_tests.py diff --git a/.gitignore b/.gitignore index bb768c9..08a27fb 100644 --- a/.gitignore +++ b/.gitignore @@ -8,5 +8,5 @@ config.json !README.md !LICENCE !requirements.txt -!e2e_tests.txt -!unit_tests.txt \ No newline at end of file +!e2e_tests.py +!unit_tests.py \ No newline at end of file diff --git a/e2e_tests.py b/e2e_tests.py new file mode 100644 index 0000000..67293a0 --- /dev/null +++ b/e2e_tests.py @@ -0,0 +1,52 @@ +import pytest +from selenium import webdriver +from selenium.webdriver.firefox.service import Service as FirefoxService +from easy_apply_linkedin import EasyApplyLinkedin + +@pytest.fixture +def setup_browser(): + service = FirefoxService(executable_path="/usr/local/bin/geckodriver") + driver = webdriver.Firefox(service=service) + yield driver + driver.quit() + +@pytest.fixture +def setup_bot(setup_browser): + data = { + "email": "sendmessage@gabo.email", + "password": "bp8v9fvk#?QaKe7", + "keywords": ["TypeScript", "Angular", "React"], + "keywordsToAvoid": ["C++", ".NET"], + "locations": ["Switzerland", "Belgium"], + "driver_path": "/usr/local/bin/geckodriver", + "sortBy": "R", + "filters": { + "easy_apply": True, + "experience": [], + "jobType": ["Full-time", "Contract"], + "timePostedRange": [], + "workplaceType": ["Remote", "Hybrid"], + "less_than_10_applicants": False + } + } + bot = EasyApplyLinkedin(data) + bot.driver = setup_browser + return bot + +def test_login_linkedin(setup_bot): + setup_bot.login_linkedin() + assert "feed" in setup_bot.driver.current_url + +def test_job_search(setup_bot): + setup_bot.login_linkedin() + setup_bot.job_search() + assert "jobs/search" in setup_bot.driver.current_url + +def test_find_offers(setup_bot): + setup_bot.login_linkedin() + setup_bot.job_search() + setup_bot.find_offers() + assert len(setup_bot.applied_companies) > 0 + +if __name__ == "__main__": + pytest.main() diff --git a/unit_tests.py b/unit_tests.py new file mode 100644 index 0000000..0401d34 --- /dev/null +++ b/unit_tests.py @@ -0,0 +1,55 @@ +import unittest +from unittest.mock import patch, MagicMock +from easy_apply_linkedin import EasyApplyLinkedin + +class TestEasyApplyLinkedin(unittest.TestCase): + def setUp(self): + self.data = { + "email": "sendmessage@gabo.email", + "password": "bp8v9fvk#?QaKe7", + "keywords": ["TypeScript", "Angular", "React"], + "keywordsToAvoid": ["C++", ".NET"], + "locations": ["Switzerland", "Belgium"], + "driver_path": "/usr/local/bin/geckodriver", + "sortBy": "R", + "filters": { + "easy_apply": True, + "experience": [], + "jobType": ["Full-time", "Contract"], + "timePostedRange": [], + "workplaceType": ["Remote", "Hybrid"], + "less_than_10_applicants": False + } + } + self.bot = EasyApplyLinkedin(self.data) + + @patch('easy_apply_linkedin.webdriver.Firefox') + def test_login_linkedin(self, MockWebDriver): + mock_driver = MockWebDriver.return_value + mock_driver.find_element.return_value = MagicMock() + self.bot.login_linkedin() + mock_driver.get.assert_called_with("https://www.linkedin.com/login") + self.assertTrue(mock_driver.find_element.called) + + @patch('easy_apply_linkedin.webdriver.Firefox') + def test_construct_url(self, MockWebDriver): + url = self.bot.construct_url() + self.assertIn("keywords=TypeScript%20OR%20Angular%20OR%20React", url) + self.assertIn("geoId=106693272", url) + self.assertIn("f_AL=true", url) + + @patch('easy_apply_linkedin.webdriver.Firefox') + def test_apply_filters_and_search_no_results(self, MockWebDriver): + mock_driver = MockWebDriver.return_value + mock_driver.find_element.side_effect = NoSuchElementException + self.bot.apply_filters_and_search() + self.assertEqual(self.bot.current_location_index, 1) + + @patch('easy_apply_linkedin.webdriver.Firefox') + def test_log_error(self, MockWebDriver): + self.bot.log_error("Test error") + errors = self.bot.load_json(self.bot.ERROR_LOG_PATH) + self.assertTrue(any("Test error" in v for v in errors.values())) + +if __name__ == "__main__": + unittest.main() From 8ebd677455c21f459a1c8556763a54cb62aca2f3 Mon Sep 17 00:00:00 2001 From: Gabo_Tech Date: Thu, 4 Jul 2024 00:39:30 +0200 Subject: [PATCH 10/19] pagination bug solved last stable version --- configExample.json | 138 ++++++++++++++++++++++++------------ main.py | 173 +++++++++++++++++++++++++++------------------ requirements.txt | 4 ++ 3 files changed, 201 insertions(+), 114 deletions(-) diff --git a/configExample.json b/configExample.json index 2810b50..3d533a0 100644 --- a/configExample.json +++ b/configExample.json @@ -1,55 +1,103 @@ { - "email": "example@example.com", - "password": "securePassword123!", - "keywords": ["Web Developer", "JavaScript", "React"], - "keywordsToAvoid": ["C++", ".NET", "Analyst", "PHP", "Python", "C", "Java"], - "locations": ["New York", "Los Angeles", "San Francisco"], - "driver_path": "/usr/local/bin/chromedriver", - "sortBy": "Alphabetical", + "email": "your_email@example.com", + "password": "your_secure_password", + "keywords": [ + "keyword1", + "keyword2", + "keyword3" + ], + "keywordsToAvoid": [ + "keyword1_to_avoid", + "keyword2_to_avoid", + "keyword3_to_avoid" + ], + "locations": [ + "location1", + "location2", + "location3" + ], + "driver_path": "path/to/driver", + "sortBy": "sort_preference", "filters": { "easy_apply": true, + "experience": [], + "jobType": ["job_type1", "job_type2"], + "timePostedRange": [], + "workplaceType": ["type1", "type2"], + "less_than_10_applicants": false + }, + "aiContext": { + "preferences": { + "workplaceType": "preference1", + "workplaceTypeAlternative": ["alternative1", "alternative2"], + "jobType": "preference2", + "jobTypeAlternative": ["alternative3", "alternative4"], + "prereferredEnd": "preference3", + "prereferredEndAlternative": ["alternative5", "alternative6"] + }, "experience": [ - "Internship", - "Entry Level", - "Associate", - "Mid-Senior Level", - "Director", - "Executive" + { + "title": "job_title", + "description": "job_description", + "date": "date_range", + "company": "company_name", + "location": "location", + "skills": [ + "skill1", + "skill2", + "skill3" + ] + } ], - "jobType": [ - "Full-Time", - "Part-Time", - "Contract", - "Internship", - "Temporary" + "education": [ + { + "title": "education_title", + "description": "education_description", + "date": "date_range", + "company": "institution_name", + "skills": [ + "skill1", + "skill2", + "skill3" + ] + }, + { + "title": "certification_title", + "description": "certification_description", + "date": "date_range", + "company": "certifying_body", + "skills": [ + "skill1", + "skill2", + "skill3" + ] + } ], - "timePostedRange": ["Any Time", "Last Month", "Past Week", "Past 24 Hours"], - "workplaceType": ["Remote", "Hybrid", "On-site"], - "less_than_10_applicants": true, - "commitments": [ - "Full-Time", - "Part-Time", - "Contract", - "Temporary", - "Volunteer" + "projects": [ + { + "name": "project_name", + "description": "project_description", + "technologies": [ + "technology1", + "technology2", + "technology3" + ] + } + ], + "skills": [ + "skill1", + "skill2", + "skill3" ] }, - "experience": [ - { - "title": "Junior Web Developer", - "description": "Developing responsive web applications using JavaScript and React.", - "date": "Jan 2023 - Present", - "company": "Example Company" - } - ], - "projects": [ - { - "title": "Project Alpha", - "desc": "A project description here...", - "link": "#", - "skills": ["JavaScript", "React", "Node.js"] + "user_inputs": { + "country1": { + "field1": "value1", + "field2": "value2" + }, + "country2": { + "field1": "value1", + "field2": "value2" } - ], - "skills": ["JavaScript", "React", "Node.js", "Express", "MongoDB"], - "user_inputs": {} + } } diff --git a/main.py b/main.py index aa9c644..4e4a6cf 100644 --- a/main.py +++ b/main.py @@ -23,6 +23,7 @@ class EasyApplyLinkedin: BASE_URL = "https://www.linkedin.com/jobs/search/" ERROR_LOG_PATH = Path("error_log.json") APPLIED_COMPANIES_LOG_PATH = Path("applied_companies_log.json") + FAILED_APPLICATIONS_LOG_PATH = Path("failed_applications_log.json") TIME_POSTED_MAPPING = { "Any Time": "", @@ -103,9 +104,10 @@ def __init__(self, data): def init_logging(self): """Initialize logging for error and applied companies.""" - logging.basicConfig(level=logging.ERROR) + logging.basicConfig(level=logging.INFO) self.error_logger = logging.getLogger("ErrorLogger") self.applied_companies = self.load_json(self.APPLIED_COMPANIES_LOG_PATH) + self.failed_applications = self.load_json(self.FAILED_APPLICATIONS_LOG_PATH) def load_json(self, path): """Load JSON data from the specified path.""" @@ -121,11 +123,16 @@ def save_json(self, path, data): def log_error(self, error_msg): """Log error messages with a timestamp.""" + self.error_logger.error(error_msg) errors = self.load_json(self.ERROR_LOG_PATH) errors[str(datetime.now())] = error_msg self.save_json(self.ERROR_LOG_PATH, errors) self.cleanup_error_log() + def log_info(self, message): + """Log informational messages.""" + logging.info(message) + def cleanup_error_log(self): """Clean up old error logs older than 1 day.""" errors = self.load_json(self.ERROR_LOG_PATH) @@ -149,6 +156,22 @@ def cleanup_applied_companies_log(self): } self.save_json(self.APPLIED_COMPANIES_LOG_PATH, self.applied_companies) + def log_failed_application(self, company): + """Log the company where application failed.""" + self.failed_applications[company] = str(datetime.now()) + self.save_json(self.FAILED_APPLICATIONS_LOG_PATH, self.failed_applications) + self.cleanup_failed_applications_log() + + def cleanup_failed_applications_log(self): + """Clean up logs of failed applications older than 2 weeks.""" + cutoff = datetime.now() - timedelta(weeks=2) + self.failed_applications = { + k: v + for k, v in self.failed_applications.items() + if datetime.fromisoformat(v) > cutoff + } + self.save_json(self.FAILED_APPLICATIONS_LOG_PATH, self.failed_applications) + def login_linkedin(self): """Log in to LinkedIn using the provided credentials.""" try: @@ -209,13 +232,11 @@ def job_search(self): if not self.check_no_results(): break else: - print( - f"No matching jobs found in {self.locations[self.current_location_index]}." - ) + self.log_info(f"No matching jobs found in {self.locations[self.current_location_index]}.") self.current_location_index += 1 except TimeoutException: - print("Timeout while trying to access the Jobs page or elements on it.") + self.log_info("Timeout while trying to access the Jobs page or elements on it.") self.current_location_index += 1 except Exception as e: self.log_error(f"Job search error: {e}") @@ -271,7 +292,7 @@ def apply_filters_and_search(self): self.driver.get(search_url) if self.check_no_results(): - print(f"No matching jobs found in {self.locations[self.current_location_index]}.") + self.log_info(f"No matching jobs found in {self.locations[self.current_location_index]}.") self.current_location_index += 1 else: break @@ -286,6 +307,15 @@ def check_no_results(self): except NoSuchElementException: return False + def find_element_with_retry(self, by, value, retries=3, delay=2): + """Find an element with retry logic.""" + for _ in range(retries): + try: + return self.driver.find_element(by, value) + except (NoSuchElementException, StaleElementReferenceException): + time.sleep(delay) + raise NoSuchElementException(f"Element not found: {by}, {value}") + def get_response_for_label(self, label_text): """Get user response for a given label text.""" current_location = self.locations[self.current_location_index] @@ -310,9 +340,7 @@ def get_checkbox_response_for_label(self, label_text): return location_specific_inputs[label_text] while True: - user_input = input( - f"Do you want to check the box for '{label_text}'? (yes/no): " - ).strip().lower() + user_input = input(f"Do you want to check the box for '{label_text}'? (yes/no): ").strip().lower() if user_input in ["yes", "no"]: response = user_input == "yes" if current_location not in self.context_data["user_inputs"]: @@ -341,6 +369,23 @@ def get_radio_response_for_label(self, label_text, options): self.context_data["user_inputs"][current_location][label_text] = response self.update_config_file() return response + else: + print("Invalid input, please try again.") + + def get_file_response_for_label(self, label_text): + """Get user response for a file upload labeled by the given text.""" + current_location = self.locations[self.current_location_index] + if current_location in self.context_data["user_inputs"]: + location_specific_inputs = self.context_data["user_inputs"][current_location] + if label_text in location_specific_inputs: + return location_specific_inputs[label_text] + + user_input = input(f"Please provide the file location for '{label_text}': ") + if current_location not in self.context_data["user_inputs"]: + self.context_data["user_inputs"][current_location] = {} + self.context_data["user_inputs"][current_location][label_text] = user_input + self.update_config_file() + return user_input def update_config_file(self): """Update the configuration file with the latest user inputs.""" @@ -351,6 +396,8 @@ def find_offers(self): """Find and apply to job offers.""" while self.current_location_index < len(self.locations): self.apply_filters_and_search() + + current_page = 1 while True: try: @@ -358,20 +405,27 @@ def find_offers(self): EC.presence_of_element_located((By.CLASS_NAME, "scaffold-layout__list-container")) ) - job_list_container = self.driver.find_element( - By.CLASS_NAME, "scaffold-layout__list-container" - ) + job_list_container = self.find_element_with_retry(By.CLASS_NAME, "scaffold-layout__list-container") job_list_items = job_list_container.find_elements(By.TAG_NAME, "li") for index in range(len(job_list_items)): try: - job_list_container = self.driver.find_element( - By.CLASS_NAME, "scaffold-layout__list-container" - ) + job_list_container = self.find_element_with_retry(By.CLASS_NAME, "scaffold-layout__list-container") job_list_items = job_list_container.find_elements(By.TAG_NAME, "li") job_item = job_list_items[index] - job_item.click() + + # Scroll the element into view + self.driver.execute_script("arguments[0].scrollIntoView(true);", job_item) + time.sleep(1) + + try: + # Attempt to click the element with JavaScript + self.driver.execute_script("arguments[0].click();", job_item) + except ElementClickInterceptedException: + self.log_info("Element click intercepted, skipping to next job...") + continue + time.sleep(2) WebDriverWait(self.driver, 10).until( @@ -380,22 +434,14 @@ def find_offers(self): ) ) - if self.job_already_applied(job_item): - print("Job already applied to, moving to next job...") - self.close_application_modal() - continue - company_name = self.get_company_name(job_item) - if company_name and company_name in self.applied_companies: - print( - f"Already applied to a job at {company_name}, skipping..." - ) + + if company_name in self.applied_companies: + self.log_info(f"Already applied to a job at {company_name}, skipping...") self.close_application_modal() continue - job_details_wrapper = self.driver.find_element( - By.CLASS_NAME, "jobs-search__job-details--wrapper" - ) + job_details_wrapper = self.find_element_with_retry(By.CLASS_NAME, "jobs-search__job-details--wrapper") try: apply_button = job_details_wrapper.find_element( @@ -410,44 +456,42 @@ def find_offers(self): ) ) - self.handle_easy_apply() - - if company_name: + try: + self.handle_easy_apply() self.log_applied_company(company_name) + except Exception as e: + self.log_info(f"Failed to apply at {company_name}: {str(e)}") + self.log_failed_application(company_name) except NoSuchElementException: - print("No apply button found, continuing to next job...") + self.log_info("No apply button found, continuing to next job...") continue - except ( - NoSuchElementException, - ElementNotInteractableException, - StaleElementReferenceException, - ) as e: - print(f"Exception occurred: {e}, continuing to next job...") + except (NoSuchElementException, ElementNotInteractableException, StaleElementReferenceException) as e: + self.log_info(f"Exception occurred: {e}, continuing to next job...") self.log_error(f"Find offers error: {e}") continue try: - pagination_container = self.driver.find_element( - By.CLASS_NAME, "artdeco-pagination__pages" - ) + pagination_container = self.find_element_with_retry(By.CLASS_NAME, "artdeco-pagination__pages") next_page_button = pagination_container.find_element( By.XPATH, - "//li[contains(@class, 'artdeco-pagination__indicator') and not(contains(@class, 'active selected'))]/button", + f"//button[@aria-label='Page {current_page + 1}']", ) self.driver.execute_script("arguments[0].click();", next_page_button) time.sleep(2) + current_page += 1 except NoSuchElementException: - print("No more pages left.") + self.log_info("No more pages left.") break except TimeoutException: - print("Timeout while waiting for job list container.") + self.log_info("Timeout while waiting for job list container.") self.log_error("Timeout while waiting for job list container.") break self.current_location_index += 1 + def get_company_name(self, job_item): """Extract the company name from a job listing.""" try: @@ -459,20 +503,6 @@ def get_company_name(self, job_item): except NoSuchElementException: return None - def job_already_applied(self, job_item): - """Check if a job has already been applied to.""" - try: - applied_element = job_item.find_element( - By.CSS_SELECTOR, - "li.job-card-container__footer-item.job-card-container__footer-job-state.t-bold", - ) - if "Applied" in applied_element.text: - return True - except NoSuchElementException: - pass - - return False - def handle_easy_apply(self): """Handle the easy apply process.""" while True: @@ -502,19 +532,19 @@ def handle_easy_apply(self): ) self.driver.execute_script("arguments[0].click();", submit_button) time.sleep(2) - print("Application submitted.") + self.log_info("Application submitted.") self.handle_done_button() break except NoSuchElementException: - print("Submit button not found, continuing to next job...") + self.log_info("Submit button not found, continuing to next job...") self.close_application_modal() break self.fill_form(modal_dialog) except TimeoutException: - print("No more steps found, exiting...") + self.log_info("No more steps found, exiting...") break except Exception as e: - print(f"Error during easy apply: {e}, skipping to next job...") + self.log_info(f"Error during easy apply: {e}, skipping to next job...") self.log_error(f"Easy apply error: {e}") self.close_application_modal() break @@ -583,6 +613,12 @@ def fill_form(self, modal_dialog): self.driver.execute_script("arguments[0].click();", radio) break + elif input_field.tag_name == "input" and input_field.get_attribute("type") == "file": + # Handle file upload + response = self.get_file_response_for_label(label_text) + input_field.send_keys(response) + time.sleep(1) + except NoSuchElementException: continue @@ -591,7 +627,7 @@ def fill_form(self, modal_dialog): next_button.click() time.sleep(2) except NoSuchElementException: - print("Next button not found, form might be complete or there is an issue.") + self.log_info("Next button not found, form might be complete or there is an issue.") def handle_done_button(self): """Handle the final done button after application submission.""" @@ -602,7 +638,7 @@ def handle_done_button(self): done_button.click() time.sleep(2) except TimeoutException: - print("Done button not found, skipping to next job.") + self.log_info("Done button not found, skipping to next job.") def close_application_modal(self): """Close the application modal.""" @@ -619,7 +655,7 @@ def close_application_modal(self): time.sleep(2) self.handle_discard_dialog() except TimeoutException: - print("Close button not found, skipping to next job.") + self.log_info("Close button not found, skipping to next job.") def handle_discard_dialog(self): """Handle the discard dialog when closing the application modal.""" @@ -632,18 +668,17 @@ def handle_discard_dialog(self): discard_button.click() time.sleep(2) except TimeoutException: - print("Discard button not found, skipping to next job.") + self.log_info("Discard button not found, skipping to next job.") def close_session(self): """Close the browser session.""" - print("End of the session") + self.log_info("End of the session") self.driver.close() self.driver.quit() def handle_captcha(self): """Handle CAPTCHA prompts manually.""" - print("CAPTCHA detected. Please solve the CAPTCHA manually and then press Enter to continue...") - input() + input("CAPTCHA detected. Please solve the CAPTCHA manually and then press Enter to continue...") if __name__ == "__main__": diff --git a/requirements.txt b/requirements.txt index 4ebcdf2..fa05aa0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,3 +21,7 @@ pluggy==0.13.1 py==1.10.0 pyparsing==2.4.7 pytest==6.2.4 +transformers==4.28.1 +torch==2.0.1 +langdetect==1.0.9 +googletrans==4.0.0-rc1 From 38f5dae084c4b9ec40c917c211058fa372843370 Mon Sep 17 00:00:00 2001 From: Gabo_Tech Date: Thu, 4 Jul 2024 17:18:54 +0200 Subject: [PATCH 11/19] Bugs solved --- main.py | 1 - 1 file changed, 1 deletion(-) diff --git a/main.py b/main.py index 4e4a6cf..75c06d6 100644 --- a/main.py +++ b/main.py @@ -491,7 +491,6 @@ def find_offers(self): self.current_location_index += 1 - def get_company_name(self, job_item): """Extract the company name from a job listing.""" try: From f9fd28af688d756ee1ee1bc0100caf9c025e8789 Mon Sep 17 00:00:00 2001 From: Gabo_Tech Date: Thu, 4 Jul 2024 20:46:02 +0200 Subject: [PATCH 12/19] bugs troubleshoot it last working version --- README.md | 62 ++++------- configExample.json | 267 ++++++++++++++++++++++++++++++--------------- e2e_tests.py | 75 +++++++++++-- main.py | 18 +-- 4 files changed, 277 insertions(+), 145 deletions(-) diff --git a/README.md b/README.md index ca05ef7..4ba0d6a 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ These instructions will get you a copy of the project up and running on your loc ### Prerequisites -1. Install selenium. I used `pip` to install the selenium package. +1. Install Selenium. Use `pip` to install the Selenium package: ```sh pip install selenium ``` @@ -39,62 +39,38 @@ These instructions will get you a copy of the project up and running on your loc "keywordsToAvoid": ["C++", ".NET"], "locations": ["New York", "Los Angeles", "San Francisco"], "driver_path": "/usr/local/bin/geckodriver", - "sortBy": "Alphabetical", + "sortBy": "R", "filters": { "easy_apply": true, - "experience": ["Internship", "Entry Level", "Associate", "Mid-Senior Level", "Director", "Executive"], - "jobType": ["Full-Time", "Part-Time", "Contract", "Internship", "Temporary"], - "timePostedRange": ["Any Time", "Last Month", "Past Week", "Past 24 Hours"], + "experience": ["Internship", "Entry level", "Associate", "Mid-Senior level", "Director", "Executive"], + "jobType": ["Full-time", "Part-time", "Contract", "Internship", "Temporary"], + "timePostedRange": ["Any Time", "Last Month", "Past Week", "Past 24 hours"], "workplaceType": ["Remote", "Hybrid", "On-site"], - "less_than_10_applicants": true, - "commitments": ["Full-Time", "Part-Time", "Contract", "Temporary", "Volunteer"] - }, - "experience": [ - { - "title": "Junior Web Developer", - "description": "Developing responsive web applications using JavaScript and React.", - "date": "Jan 2023 - Present", - "company": "Example Company" - } - ], - "projects": [ - { - "title": "Project Alpha", - "desc": "A project description here...", - "link": "#", - "skills": ["JavaScript", "React", "Node.js"] - } - ], - "skills": [ - "JavaScript", - "React", - "Node.js", - "Express", - "MongoDB" - ], - "user_inputs": {} + "less_than_10_applicants": true + } } ``` -4. Update the locations code in the script: +4. Update the location codes in the script: ```python LOCATION_MAPPING = { + "Canada": "101174742", + "Portugal": "100364837", "Switzerland": "106693272", - "Spain": "105646813", "United States": "103644278", - "United Kingdom": "101165590", - "European Union": "91000000", - "European Economic Area": "91000002", + "Belgium": "100565514", + "Netherlands": "102890719", "DACH": "91000006", "Benelux": "91000005", - "Netherlands": "102890719", - "Belgium": "100565514", - "Germany": "101282230" + "European Union": "91000000", + "European Economic Area": "91000002", + "Germany": "101282230", + "Spain": "105646813", + "United Kingdom": "101165590" } ``` - This you can find the code in the geoId found in the LinkedIn url after doing a job search. - These are the right ones if you don't want to look in other places, but there are many more. - + You can find the code in the `geoId` found in the LinkedIn URL after doing a job search. These are the correct ones if you don't want to search elsewhere, but there are many more. + ### Usage 1. Run the application: diff --git a/configExample.json b/configExample.json index 3d533a0..01c3f67 100644 --- a/configExample.json +++ b/configExample.json @@ -1,103 +1,194 @@ { - "email": "your_email@example.com", - "password": "your_secure_password", + "email": "example@domain.com", + "password": "YourSecurePassword123!", "keywords": [ - "keyword1", - "keyword2", - "keyword3" + "TypeScript", + "Angular", + "React", + "React Native", + "Node", + "JavaScript", + "Frontend Engineer", + "Full-Stack Engineer", + "Backend Engineer" ], "keywordsToAvoid": [ - "keyword1_to_avoid", - "keyword2_to_avoid", - "keyword3_to_avoid" + "C++", + ".NET", + "Analyst", + "PHP", + "Python", + "C", + "Java", + "Go", + "Rust", + "Kotlin", + "Swift", + "Objective-C", + "Robotic", + "Data", + "Science", + "Cloud", + "AI", + "ML", + "DL", + "NLP", + "CV", + "DevOps", + "Solidity" ], "locations": [ - "location1", - "location2", - "location3" + "Canada", + "Portugal", + "Switzerland", + "Belgium", + "Netherlands", + "DACH", + "Benelux", + "European Union", + "European Economic Area", + "Germany", + "Spain", + "United States", + "United Kingdom" ], - "driver_path": "path/to/driver", - "sortBy": "sort_preference", + "driver_path": "/path/to/geckodriver", + "sortBy": "R", "filters": { - "easy_apply": true, - "experience": [], - "jobType": ["job_type1", "job_type2"], - "timePostedRange": [], - "workplaceType": ["type1", "type2"], - "less_than_10_applicants": false + "easy_apply": true, + "experience": [], + "jobType": [ + "Full-time", + "Contract" + ], + "timePostedRange": [], + "workplaceType": [ + "Remote", + "Hybrid" + ], + "less_than_10_applicants": false }, "aiContext": { - "preferences": { - "workplaceType": "preference1", - "workplaceTypeAlternative": ["alternative1", "alternative2"], - "jobType": "preference2", - "jobTypeAlternative": ["alternative3", "alternative4"], - "prereferredEnd": "preference3", - "prereferredEndAlternative": ["alternative5", "alternative6"] - }, - "experience": [ - { - "title": "job_title", - "description": "job_description", - "date": "date_range", - "company": "company_name", - "location": "location", - "skills": [ - "skill1", - "skill2", - "skill3" - ] - } - ], - "education": [ - { - "title": "education_title", - "description": "education_description", - "date": "date_range", - "company": "institution_name", - "skills": [ - "skill1", - "skill2", - "skill3" - ] + "preferences": { + "workplaceType": "Remote", + "workplaceTypeAlternative": [ + "Hybrid" + ], + "jobType": "Contract", + "jobTypeAlternative": [ + "Full-time" + ], + "prereferredEnd": "Backend", + "prereferredEndAlternative": [ + "Full-Stack", + "Frontend" + ] }, - { - "title": "certification_title", - "description": "certification_description", - "date": "date_range", - "company": "certifying_body", - "skills": [ - "skill1", - "skill2", - "skill3" - ] - } - ], - "projects": [ - { - "name": "project_name", - "description": "project_description", - "technologies": [ - "technology1", - "technology2", - "technology3" - ] - } - ], - "skills": [ - "skill1", - "skill2", - "skill3" - ] + "currentLocation": "Fake City, Country", + "willingToRelocate": true, + "experience": [ + { + "title": "Full-Stack Developer", + "description": "Developed an e-commerce platform using MERN stack.", + "date": "Jan 2021 - Present", + "company": "Tech Solutions", + "location": "Remote", + "skills": [ + "TypeScript", + "React", + "Node.js", + "Express.js", + "MongoDB" + ] + }, + { + "title": "Frontend Engineer", + "description": "Designed and implemented user interfaces with Angular.", + "date": "Jun 2019 - Dec 2020", + "company": "Web Creators", + "location": "San Francisco, CA", + "skills": [ + "JavaScript", + "Angular", + "HTML", + "CSS" + ] + } + ], + "education": [ + { + "title": "Bachelor of Science - Computer Science", + "description": "Studied various aspects of computer science, including algorithms, data structures, and web development.", + "date": "Sep 2015 - Jun 2019", + "company": "University of Somewhere", + "skills": [ + "Algorithms", + "Data Structures", + "Web Development", + "Machine Learning" + ] + } + ], + "projects": [ + { + "name": "Project Alpha", + "description": "A project management tool developed using React and Node.js.", + "technologies": [ + "React", + "Node.js", + "Express", + "MongoDB", + "Docker" + ] + } + ], + "skills": [ + "TypeScript", + "JavaScript", + "Angular", + "React", + "Node.js", + "Express.js", + "MongoDB", + "HTML", + "CSS" + ] }, "user_inputs": { - "country1": { - "field1": "value1", - "field2": "value2" - }, - "country2": { - "field1": "value1", - "field2": "value2" - } + "United States": { + "City\nCity": "Fake City, USA", + "What is your gender?\nWhat is your gender?": "Prefer not to say", + "Do you consider yourself to be disabled as defined by the Equality Act 2010?": "No", + "Do you require any particular arrangements to support you in the recruitment and selection process?": "No", + "What is your ethnic origin?\nWhat is your ethnic origin?": "White", + "I Agree Terms & Conditions": true, + "LinkedIn": true + }, + "Belgium": { + "What is your preferred name?": "John Doe", + "Do you now, or will you in the future, require visa sponsorship to work for our company in the country this role is advertised for?": "No", + "What are your salary expectations?": "60000", + "English": true, + "City\nCity": "Fake City, Belgium", + "I Agree Terms & Conditions": true, + "What language(s) do you speak and/or understand? What is your level?": "English, French - fluent", + "Are you legally authorized to work in the country of the job?": "Yes" + }, + "Netherlands": { + "What is your current location?": "Fake City, Netherlands", + "City\nCity": "Fake City, Netherlands", + "Legal Name (if different than above)": "John Doe", + "How did you hear about this job?": "LinkedIn", + "Do you now or will you in the future require immigration sponsorship to work at Company?": "No", + "What are your salary expectations?": "65000", + "This vacancy is for an internal position and we do not contract freelancers for this position. Do you acknowledge this statement?": "Yes" + }, + "Spain": { + "What is your level of proficiency in English?\nWhat is your level of proficiency in English?": "Native or bilingual", + "City\nCity": "Fake City, Spain", + "Indica tus expectativas salariales frente a un cambio.": "50000", + "Are you legally authorized to work in Spain?": "Yes", + "What is your salary expectation?": "50000" + } } } diff --git a/e2e_tests.py b/e2e_tests.py index 67293a0..a17eff9 100644 --- a/e2e_tests.py +++ b/e2e_tests.py @@ -1,7 +1,7 @@ import pytest from selenium import webdriver from selenium.webdriver.firefox.service import Service as FirefoxService -from easy_apply_linkedin import EasyApplyLinkedin +from main import EasyApplyLinkedin @pytest.fixture def setup_browser(): @@ -14,18 +14,79 @@ def setup_browser(): def setup_bot(setup_browser): data = { "email": "sendmessage@gabo.email", - "password": "bp8v9fvk#?QaKe7", - "keywords": ["TypeScript", "Angular", "React"], - "keywordsToAvoid": ["C++", ".NET"], - "locations": ["Switzerland", "Belgium"], + "password": "***,****,****", + "keywords": [ + "TypeScript", + "Angular", + "React", + "React Native", + "Node", + "JavaScript", + "Frontend Engineer", + "Full-Stack Engineer", + "Backend Engineer" + ], + "keywordsToAvoid": [ + "C++", + ".NET", + "Analyst", + "PHP", + "Python", + "C", + "Java", + "Go", + "Rust", + "Kotlin", + "Swift", + "Objective-C", + "Rust", + "Kotlin", + "Swift", + "C#", + ".Net", + ".net", + "Robotic", + "Data", + "Science", + "Cloud", + "Robotics", + "AI", + "ML", + "DL", + "NLP", + "CV", + "DevOps", + "Solidity" + ], + "locations": [ + "Canada", + "Portugal", + "Switzerland", + "Belgium", + "Netherlands", + "DACH", + "Benelux", + "European Union", + "European Economic Area", + "Germany", + "Spain", + "United States", + "United Kingdom" + ], "driver_path": "/usr/local/bin/geckodriver", "sortBy": "R", "filters": { "easy_apply": True, "experience": [], - "jobType": ["Full-time", "Contract"], + "jobType": [ + "Full-time", + "Contract" + ], "timePostedRange": [], - "workplaceType": ["Remote", "Hybrid"], + "workplaceType": [ + "Remote", + "Hybrid" + ], "less_than_10_applicants": False } } diff --git a/main.py b/main.py index 75c06d6..7d6a42b 100644 --- a/main.py +++ b/main.py @@ -72,17 +72,19 @@ class EasyApplyLinkedin: } LOCATION_MAPPING = { + "Canada": "101174742", + "Portugal": "100364837", "Switzerland": "106693272", - "Spain": "105646813", "United States": "103644278", - "United Kingdom": "101165590", - "European Union": "91000000", - "European Economic Area": "91000002", + "Belgium": "100565514", + "Netherlands": "102890719", "DACH": "91000006", "Benelux": "91000005", - "Netherlands": "102890719", - "Belgium": "100565514", + "European Union": "91000000", + "European Economic Area": "91000002", "Germany": "101282230", + "Spain": "105646813", + "United Kingdom": "101165590", } def __init__(self, data): @@ -413,6 +415,9 @@ def find_offers(self): job_list_container = self.find_element_with_retry(By.CLASS_NAME, "scaffold-layout__list-container") job_list_items = job_list_container.find_elements(By.TAG_NAME, "li") + if index >= len(job_list_items): + break + job_item = job_list_items[index] # Scroll the element into view @@ -613,7 +618,6 @@ def fill_form(self, modal_dialog): break elif input_field.tag_name == "input" and input_field.get_attribute("type") == "file": - # Handle file upload response = self.get_file_response_for_label(label_text) input_field.send_keys(response) time.sleep(1) From 7c349db7278fb180f57271ea56dd57cad5e1d44b Mon Sep 17 00:00:00 2001 From: Gabo_Tech Date: Thu, 11 Jul 2024 10:49:43 +0200 Subject: [PATCH 13/19] Dark mode added --- main.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/main.py b/main.py index 7d6a42b..752ed8c 100644 --- a/main.py +++ b/main.py @@ -72,6 +72,7 @@ class EasyApplyLinkedin: } LOCATION_MAPPING = { + "Texas":"102748797", "Canada": "101174742", "Portugal": "100364837", "Switzerland": "106693272", @@ -178,6 +179,16 @@ def login_linkedin(self): """Log in to LinkedIn using the provided credentials.""" try: self.driver.get("https://www.linkedin.com/login") + self.driver.add_cookie({ + 'name': 'li_theme', + 'value': 'dark', + 'domain': '.linkedin.com', + 'path': '/', + 'expires': int(time.time() + 365 * 24 * 60 * 60), + 'secure': True, + 'httpOnly': False + }) + self.driver.refresh() WebDriverWait(self.driver, 10).until( EC.presence_of_element_located((By.NAME, "session_key")) ) @@ -247,8 +258,12 @@ def job_search(self): def construct_url(self): """Construct the URL for job search with applied filters.""" current_location = self.locations[self.current_location_index] + keywords_query = f'({self.keywords})' + keywords_to_avoid_query = f'NOT ({self.keywords_to_avoid})' + combined_keywords = f'{keywords_query} {keywords_to_avoid_query}' + params = { - "keywords": f"({self.keywords}) NOT ({self.keywords_to_avoid})", + "keywords": combined_keywords, "origin": "JOB_SEARCH_PAGE_JOB_FILTER", "refresh": "true", "sortBy": self.sort_by, @@ -691,4 +706,4 @@ def handle_captcha(self): bot.login_linkedin() bot.job_search() bot.find_offers() - bot.close_session() + bot.close_session() \ No newline at end of file From da5f924ce5ccc502d7ae46cdc7f7509625a9ebf4 Mon Sep 17 00:00:00 2001 From: Gabo_Tech Date: Fri, 12 Jul 2024 01:53:30 +0200 Subject: [PATCH 14/19] one less bug one reason less to crash --- main.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/main.py b/main.py index 752ed8c..d06c4fd 100644 --- a/main.py +++ b/main.py @@ -115,8 +115,12 @@ def init_logging(self): def load_json(self, path): """Load JSON data from the specified path.""" if path.exists(): - with path.open("r") as file: - return json.load(file) + try: + with path.open("r") as file: + return json.load(file) + except json.JSONDecodeError: + self.log_error(f"Error decoding JSON from {path}") + return {} return {} def save_json(self, path, data): @@ -242,6 +246,7 @@ def job_search(self): search_keywords.click() search_keywords.send_keys(Keys.RETURN) + time.sleep(5) # wait for the search results to load if not self.check_no_results(): break else: @@ -307,6 +312,7 @@ def apply_filters_and_search(self): while self.current_location_index < len(self.locations): search_url = self.construct_url() self.driver.get(search_url) + time.sleep(5) # wait for the search results to load if self.check_no_results(): self.log_info(f"No matching jobs found in {self.locations[self.current_location_index]}.") @@ -706,4 +712,4 @@ def handle_captcha(self): bot.login_linkedin() bot.job_search() bot.find_offers() - bot.close_session() \ No newline at end of file + bot.close_session() From c1997c19d2dbc59b331246108a472640e77d9e7b Mon Sep 17 00:00:00 2001 From: Gabo_Tech Date: Fri, 12 Jul 2024 03:09:19 +0200 Subject: [PATCH 15/19] one less bug fixed crash with fielsets of checkboxes --- main.py | 138 +++++++++++++++++++++++++++++--------------------------- 1 file changed, 72 insertions(+), 66 deletions(-) diff --git a/main.py b/main.py index d06c4fd..eff9e78 100644 --- a/main.py +++ b/main.py @@ -72,7 +72,7 @@ class EasyApplyLinkedin: } LOCATION_MAPPING = { - "Texas":"102748797", + "Texas": "102748797", "Canada": "101174742", "Portugal": "100364837", "Switzerland": "106693272", @@ -246,7 +246,7 @@ def job_search(self): search_keywords.click() search_keywords.send_keys(Keys.RETURN) - time.sleep(5) # wait for the search results to load + time.sleep(5) if not self.check_no_results(): break else: @@ -312,7 +312,7 @@ def apply_filters_and_search(self): while self.current_location_index < len(self.locations): search_url = self.construct_url() self.driver.get(search_url) - time.sleep(5) # wait for the search results to load + time.sleep(5) if self.check_no_results(): self.log_info(f"No matching jobs found in {self.locations[self.current_location_index]}.") @@ -419,7 +419,7 @@ def find_offers(self): """Find and apply to job offers.""" while self.current_location_index < len(self.locations): self.apply_filters_and_search() - + current_page = 1 while True: @@ -440,13 +440,11 @@ def find_offers(self): break job_item = job_list_items[index] - - # Scroll the element into view + self.driver.execute_script("arguments[0].scrollIntoView(true);", job_item) time.sleep(1) - + try: - # Attempt to click the element with JavaScript self.driver.execute_script("arguments[0].click();", job_item) except ElementClickInterceptedException: self.log_info("Element click intercepted, skipping to next job...") @@ -461,14 +459,14 @@ def find_offers(self): ) company_name = self.get_company_name(job_item) - + if company_name in self.applied_companies: self.log_info(f"Already applied to a job at {company_name}, skipping...") self.close_application_modal() continue job_details_wrapper = self.find_element_with_retry(By.CLASS_NAME, "jobs-search__job-details--wrapper") - + try: apply_button = job_details_wrapper.find_element( By.CSS_SELECTOR, "button.jobs-apply-button.artdeco-button--primary" @@ -503,7 +501,7 @@ def find_offers(self): next_page_button = pagination_container.find_element( By.XPATH, f"//button[@aria-label='Page {current_page + 1}']", - ) + ) self.driver.execute_script("arguments[0].click();", next_page_button) time.sleep(2) current_page += 1 @@ -582,66 +580,50 @@ def fill_form(self, modal_dialog): for element in form_elements: try: label = element.find_element(By.CSS_SELECTOR, "label, legend") - input_field = element.find_element(By.CSS_SELECTOR, "input, select, textarea") label_text = label.text.strip() - if input_field.tag_name == "input" and input_field.get_attribute("type") == "text": - response = self.get_response_for_label(label_text) - if input_field.get_attribute("value") == "": - input_field.send_keys(response) - time.sleep(1) - input_field.send_keys(Keys.ARROW_DOWN) - input_field.send_keys(Keys.RETURN) - - elif input_field.tag_name == "select": - response = self.get_response_for_label(label_text) - select_options = input_field.find_elements(By.TAG_NAME, "option") - for option in select_options: - if option.get_attribute("value") == response: - option.click() - break + if "data-test-checkbox-form-component" in element.get_attribute("outerHTML"): + self.handle_checkboxes(element) + else: + input_field = element.find_element(By.CSS_SELECTOR, "input, select, textarea") - elif input_field.tag_name == "textarea": - response = self.get_response_for_label(label_text) - if input_field.get_attribute("value") == "": - input_field.send_keys(response) + if input_field.tag_name == "input" and input_field.get_attribute("type") == "text": + response = self.get_response_for_label(label_text) + if input_field.get_attribute("value") == "": + input_field.send_keys(response) + time.sleep(1) + input_field.send_keys(Keys.ARROW_DOWN) + input_field.send_keys(Keys.RETURN) + + elif input_field.tag_name == "select": + response = self.get_response_for_label(label_text) + select_options = input_field.find_elements(By.TAG_NAME, "option") + for option in select_options: + if option.get_attribute("value") == response: + option.click() + break - elif input_field.tag_name == "input" and input_field.get_attribute("type") == "checkbox": - checkboxes = element.find_elements(By.CSS_SELECTOR, "input[type='checkbox']") - for checkbox in checkboxes: - checkbox_label = checkbox.find_element(By.XPATH, "./following-sibling::label").text.strip() - response = self.get_checkbox_response_for_label(checkbox_label) - if response is not None: - try: - if response and not checkbox.is_selected(): - self.driver.execute_script("arguments[0].click();", checkbox) - elif not response and checkbox.is_selected(): - self.driver.execute_script("arguments[0].click();", checkbox) - except ElementClickInterceptedException: - self.driver.execute_script("arguments[0].click();", checkbox) - except StaleElementReferenceException: - checkbox = element.find_element(By.XPATH, f".//input[@type='checkbox' and ./following-sibling::label[text()='{checkbox_label}']]") - if response and not checkbox.is_selected(): - self.driver.execute_script("arguments[0].click();", checkbox) - elif not response and checkbox.is_selected(): - self.driver.execute_script("arguments[0].click();", checkbox) - - elif input_field.tag_name == "input" and input_field.get_attribute("type") == "radio": - radio_buttons = element.find_elements(By.CSS_SELECTOR, "input[type='radio']") - for radio in radio_buttons: - radio_label = radio.find_element(By.XPATH, "./following-sibling::label").text.strip() - response = self.get_radio_response_for_label(label_text, [rb.find_element(By.XPATH, "./following-sibling::label").text.strip() for rb in radio_buttons]) - if response.lower() == radio_label.lower(): - try: - radio.click() - except ElementClickInterceptedException: - self.driver.execute_script("arguments[0].click();", radio) - break + elif input_field.tag_name == "textarea": + response = self.get_response_for_label(label_text) + if input_field.get_attribute("value") == "": + input_field.send_keys(response) + + elif input_field.tag_name == "input" and input_field.get_attribute("type") == "radio": + radio_buttons = element.find_elements(By.CSS_SELECTOR, "input[type='radio']") + for radio in radio_buttons: + radio_label = radio.find_element(By.XPATH, "./following-sibling::label").text.strip() + response = self.get_radio_response_for_label(label_text, [rb.find_element(By.XPATH, "./following-sibling::label").text.strip() for rb in radio_buttons]) + if response.lower() == radio_label.lower(): + try: + radio.click() + except ElementClickInterceptedException: + self.driver.execute_script("arguments[0].click();", radio) + break - elif input_field.tag_name == "input" and input_field.get_attribute("type") == "file": - response = self.get_file_response_for_label(label_text) - input_field.send_keys(response) - time.sleep(1) + elif input_field.tag_name == "input" and input_field.get_attribute("type") == "file": + response = self.get_file_response_for_label(label_text) + input_field.send_keys(response) + time.sleep(1) except NoSuchElementException: continue @@ -653,6 +635,30 @@ def fill_form(self, modal_dialog): except NoSuchElementException: self.log_info("Next button not found, form might be complete or there is an issue.") + def handle_checkboxes(self, element): + """Handle multiple checkbox inputs.""" + checkboxes = element.find_elements(By.CSS_SELECTOR, "input[type='checkbox']") + for checkbox in checkboxes: + try: + checkbox_label = checkbox.find_element(By.XPATH, "./following-sibling::label").text.strip() + response = self.get_checkbox_response_for_label(checkbox_label) + if response is not None: + if response and not checkbox.is_selected(): + self.driver.execute_script("arguments[0].click();", checkbox) + elif not response and checkbox.is_selected(): + self.driver.execute_script("arguments[0].click();", checkbox) + except (ElementClickInterceptedException, StaleElementReferenceException): + self.log_info(f"Checkbox interaction failed for {checkbox_label}, attempting to retry.") + try: + checkbox = element.find_element(By.XPATH, f".//input[@type='checkbox' and ./following-sibling::label[text()='{checkbox_label}']]") + if response and not checkbox.is_selected(): + self.driver.execute_script("arguments[0].click();", checkbox) + elif not response and checkbox.is_selected(): + self.driver.execute_script("arguments[0].click();", checkbox) + except NoSuchElementException: + self.log_info(f"Checkbox not found after retry for {checkbox_label}.") + continue + def handle_done_button(self): """Handle the final done button after application submission.""" try: From ba7c2afeba66d8c25dae3fdfe440094eb01cb38c Mon Sep 17 00:00:00 2001 From: Gabo_Tech Date: Fri, 12 Jul 2024 08:33:28 +0200 Subject: [PATCH 16/19] fieldset bug fixed --- main.py | 106 +++++++++++++++++++++++++++++++++----------------------- 1 file changed, 62 insertions(+), 44 deletions(-) diff --git a/main.py b/main.py index eff9e78..38267c6 100644 --- a/main.py +++ b/main.py @@ -72,7 +72,7 @@ class EasyApplyLinkedin: } LOCATION_MAPPING = { - "Texas": "102748797", + "Texas":"102748797", "Canada": "101174742", "Portugal": "100364837", "Switzerland": "106693272", @@ -246,7 +246,7 @@ def job_search(self): search_keywords.click() search_keywords.send_keys(Keys.RETURN) - time.sleep(5) + time.sleep(5) # wait for the search results to load if not self.check_no_results(): break else: @@ -312,7 +312,7 @@ def apply_filters_and_search(self): while self.current_location_index < len(self.locations): search_url = self.construct_url() self.driver.get(search_url) - time.sleep(5) + time.sleep(5) # wait for the search results to load if self.check_no_results(): self.log_info(f"No matching jobs found in {self.locations[self.current_location_index]}.") @@ -354,24 +354,6 @@ def get_response_for_label(self, label_text): self.update_config_file() return user_input - def get_checkbox_response_for_label(self, label_text): - """Get user response for a checkbox labeled by the given text.""" - current_location = self.locations[self.current_location_index] - if current_location in self.context_data["user_inputs"]: - location_specific_inputs = self.context_data["user_inputs"][current_location] - if label_text in location_specific_inputs: - return location_specific_inputs[label_text] - - while True: - user_input = input(f"Do you want to check the box for '{label_text}'? (yes/no): ").strip().lower() - if user_input in ["yes", "no"]: - response = user_input == "yes" - if current_location not in self.context_data["user_inputs"]: - self.context_data["user_inputs"][current_location] = {} - self.context_data["user_inputs"][current_location][label_text] = response - self.update_config_file() - return response - def get_radio_response_for_label(self, label_text, options): """Get user response for a radio button group labeled by the given text.""" current_location = self.locations[self.current_location_index] @@ -419,7 +401,7 @@ def find_offers(self): """Find and apply to job offers.""" while self.current_location_index < len(self.locations): self.apply_filters_and_search() - + current_page = 1 while True: @@ -440,11 +422,13 @@ def find_offers(self): break job_item = job_list_items[index] - + + # Scroll the element into view self.driver.execute_script("arguments[0].scrollIntoView(true);", job_item) time.sleep(1) - + try: + # Attempt to click the element with JavaScript self.driver.execute_script("arguments[0].click();", job_item) except ElementClickInterceptedException: self.log_info("Element click intercepted, skipping to next job...") @@ -459,14 +443,14 @@ def find_offers(self): ) company_name = self.get_company_name(job_item) - + if company_name in self.applied_companies: self.log_info(f"Already applied to a job at {company_name}, skipping...") self.close_application_modal() continue job_details_wrapper = self.find_element_with_retry(By.CLASS_NAME, "jobs-search__job-details--wrapper") - + try: apply_button = job_details_wrapper.find_element( By.CSS_SELECTOR, "button.jobs-apply-button.artdeco-button--primary" @@ -501,7 +485,7 @@ def find_offers(self): next_page_button = pagination_container.find_element( By.XPATH, f"//button[@aria-label='Page {current_page + 1}']", - ) + ) self.driver.execute_script("arguments[0].click();", next_page_button) time.sleep(2) current_page += 1 @@ -638,26 +622,60 @@ def fill_form(self, modal_dialog): def handle_checkboxes(self, element): """Handle multiple checkbox inputs.""" checkboxes = element.find_elements(By.CSS_SELECTOR, "input[type='checkbox']") - for checkbox in checkboxes: + for index in range(len(checkboxes)): + checkbox_label = None try: + checkbox = checkboxes[index] checkbox_label = checkbox.find_element(By.XPATH, "./following-sibling::label").text.strip() response = self.get_checkbox_response_for_label(checkbox_label) if response is not None: - if response and not checkbox.is_selected(): - self.driver.execute_script("arguments[0].click();", checkbox) - elif not response and checkbox.is_selected(): - self.driver.execute_script("arguments[0].click();", checkbox) - except (ElementClickInterceptedException, StaleElementReferenceException): - self.log_info(f"Checkbox interaction failed for {checkbox_label}, attempting to retry.") - try: - checkbox = element.find_element(By.XPATH, f".//input[@type='checkbox' and ./following-sibling::label[text()='{checkbox_label}']]") - if response and not checkbox.is_selected(): - self.driver.execute_script("arguments[0].click();", checkbox) - elif not response and checkbox.is_selected(): - self.driver.execute_script("arguments[0].click();", checkbox) - except NoSuchElementException: - self.log_info(f"Checkbox not found after retry for {checkbox_label}.") - continue + self.set_checkbox_state(checkbox, checkbox_label, response) + except (ElementClickInterceptedException, StaleElementReferenceException) as e: + self.log_info(f"Checkbox interaction failed for {checkbox_label}, attempting to retry. Error: {e}") + self.retry_checkbox_interaction(element, index) + + def retry_checkbox_interaction(self, element, index): + """Retry interaction with the checkbox in case of exceptions.""" + retries = 3 + while retries > 0: + retries -= 1 + try: + checkboxes = element.find_elements(By.CSS_SELECTOR, "input[type='checkbox']") + checkbox = checkboxes[index] + checkbox_label = checkbox.find_element(By.XPATH, "./following-sibling::label").text.strip() + response = self.get_checkbox_response_for_label(checkbox_label) + if response is not None: + self.set_checkbox_state(checkbox, checkbox_label, response) + return + except (NoSuchElementException, StaleElementReferenceException) as e: + self.log_info(f"Retry failed for {checkbox_label}. Error: {e}") + if retries == 0: + self.log_info(f"Skipping {checkbox_label} after multiple retries.") + + def set_checkbox_state(self, checkbox, checkbox_label, response): + """Set the state of a checkbox.""" + if response and not checkbox.is_selected(): + self.driver.execute_script("arguments[0].click();", checkbox) + elif not response and checkbox.is_selected(): + self.driver.execute_script("arguments[0].click();", checkbox) + + def get_checkbox_response_for_label(self, label_text): + """Get user response for a checkbox labeled by the given text.""" + current_location = self.locations[self.current_location_index] + if current_location not in self.context_data["user_inputs"]: + self.context_data["user_inputs"][current_location] = {} + + location_specific_inputs = self.context_data["user_inputs"][current_location] + if label_text in location_specific_inputs: + return location_specific_inputs[label_text] + + while True: + user_input = input(f"Do you want to check the box for '{label_text}'? (yes/no): ").strip().lower() + if user_input in ["yes", "no"]: + response = user_input == "yes" + location_specific_inputs[label_text] = response + self.update_config_file() + return response def handle_done_button(self): """Handle the final done button after application submission.""" @@ -718,4 +736,4 @@ def handle_captcha(self): bot.login_linkedin() bot.job_search() bot.find_offers() - bot.close_session() + bot.close_session() \ No newline at end of file From 95fe614a62260b2d0262f3450911f4282e115fb2 Mon Sep 17 00:00:00 2001 From: Gabo_Tech Date: Tue, 16 Jul 2024 14:49:21 +0200 Subject: [PATCH 17/19] updated logging dropdown options to terminal --- main.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/main.py b/main.py index 38267c6..9e7882d 100644 --- a/main.py +++ b/main.py @@ -72,7 +72,7 @@ class EasyApplyLinkedin: } LOCATION_MAPPING = { - "Texas":"102748797", + "Texas": "102748797", "Canada": "101174742", "Portugal": "100364837", "Switzerland": "106693272", @@ -188,7 +188,7 @@ def login_linkedin(self): 'value': 'dark', 'domain': '.linkedin.com', 'path': '/', - 'expires': int(time.time() + 365 * 24 * 60 * 60), + 'expires': int(time.time() + 365 * 24 * 60 * 60), 'secure': True, 'httpOnly': False }) @@ -263,9 +263,7 @@ def job_search(self): def construct_url(self): """Construct the URL for job search with applied filters.""" current_location = self.locations[self.current_location_index] - keywords_query = f'({self.keywords})' - keywords_to_avoid_query = f'NOT ({self.keywords_to_avoid})' - combined_keywords = f'{keywords_query} {keywords_to_avoid_query}' + combined_keywords = f'{self.keywords} NOT {self.keywords_to_avoid}' params = { "keywords": combined_keywords, @@ -559,15 +557,23 @@ def handle_easy_apply(self): def fill_form(self, modal_dialog): """Fill out the application form.""" form_elements = modal_dialog.find_elements( - By.CSS_SELECTOR, "div[data-test-form-element], fieldset[data-test-form-builder-radio-button-form-component], fieldset[data-test-checkbox-form-component]" + By.CSS_SELECTOR, "div[data-test-form-element], fieldset[data-test-form-builder-radio-button-form-component], fieldset[data-test-checkbox-form-component], div[data-test-text-entity-list-form-component]" ) for element in form_elements: try: - label = element.find_element(By.CSS_SELECTOR, "label, legend") + label = element.find_element(By.CSS_SELECTOR, "label, legend, span[aria-hidden='true']") label_text = label.text.strip() if "data-test-checkbox-form-component" in element.get_attribute("outerHTML"): self.handle_checkboxes(element) + elif "data-test-text-entity-list-form-component" in element.get_attribute("outerHTML"): + select_element = element.find_element(By.CSS_SELECTOR, "select") + options = [option.text for option in select_element.find_elements(By.TAG_NAME, "option")] + response = self.get_radio_response_for_label(label_text, options[1:]) # Exclude "Select an option" + for option in select_element.find_elements(By.TAG_NAME, "option"): + if option.text == response: + option.click() + break else: input_field = element.find_element(By.CSS_SELECTOR, "input, select, textarea") @@ -736,4 +742,4 @@ def handle_captcha(self): bot.login_linkedin() bot.job_search() bot.find_offers() - bot.close_session() \ No newline at end of file + bot.close_session() From 7d800970f8972786ad50afe6637ac7478af44b45 Mon Sep 17 00:00:00 2001 From: Gabo_Tech Date: Tue, 23 Jul 2024 00:55:19 +0200 Subject: [PATCH 18/19] Added collections filter --- configExample.json | 1 + main.py | 171 ++++++++++++++++++++++++++++++++------------- 2 files changed, 123 insertions(+), 49 deletions(-) diff --git a/configExample.json b/configExample.json index 01c3f67..603289f 100644 --- a/configExample.json +++ b/configExample.json @@ -68,6 +68,7 @@ ], "less_than_10_applicants": false }, + "collection": "", "aiContext": { "preferences": { "workplaceType": "Remote", diff --git a/main.py b/main.py index 9e7882d..fe1a178 100644 --- a/main.py +++ b/main.py @@ -18,9 +18,14 @@ ) from selenium.webdriver.firefox.service import Service as FirefoxService - class EasyApplyLinkedin: BASE_URL = "https://www.linkedin.com/jobs/search/" + COLLECTION_URLS = { + "small_business": "https://www.linkedin.com/jobs/collections/small-business", + "remote_jobs": "https://www.linkedin.com/jobs/collections/remote-jobs", + "easy_apply": "https://www.linkedin.com/jobs/collections/easy-apply", + "top_applicant": "https://www.linkedin.com/jobs/collections/top-applicant" + } ERROR_LOG_PATH = Path("error_log.json") APPLIED_COMPANIES_LOG_PATH = Path("applied_companies_log.json") FAILED_APPLICATIONS_LOG_PATH = Path("failed_applications_log.json") @@ -89,13 +94,13 @@ class EasyApplyLinkedin: } def __init__(self, data): - """Initialize the EasyApplyLinkedin instance with user data.""" self.email = data["email"] self.password = data["password"] self.keywords = " OR ".join(data["keywords"]) self.keywords_to_avoid = " NOT ".join(data["keywordsToAvoid"]) self.locations = data["locations"] self.filters = data["filters"] + self.collection = data.get("collection", "") self.sort_by = data["sortBy"] self.context_data = data self.current_location_index = 0 @@ -106,14 +111,12 @@ def __init__(self, data): self.init_logging() def init_logging(self): - """Initialize logging for error and applied companies.""" logging.basicConfig(level=logging.INFO) self.error_logger = logging.getLogger("ErrorLogger") self.applied_companies = self.load_json(self.APPLIED_COMPANIES_LOG_PATH) self.failed_applications = self.load_json(self.FAILED_APPLICATIONS_LOG_PATH) def load_json(self, path): - """Load JSON data from the specified path.""" if path.exists(): try: with path.open("r") as file: @@ -124,12 +127,10 @@ def load_json(self, path): return {} def save_json(self, path, data): - """Save JSON data to the specified path.""" with path.open("w") as file: json.dump(data, file, indent=4) def log_error(self, error_msg): - """Log error messages with a timestamp.""" self.error_logger.error(error_msg) errors = self.load_json(self.ERROR_LOG_PATH) errors[str(datetime.now())] = error_msg @@ -137,24 +138,20 @@ def log_error(self, error_msg): self.cleanup_error_log() def log_info(self, message): - """Log informational messages.""" logging.info(message) def cleanup_error_log(self): - """Clean up old error logs older than 1 day.""" errors = self.load_json(self.ERROR_LOG_PATH) cutoff = datetime.now() - timedelta(days=1) errors = {k: v for k, v in errors.items() if datetime.fromisoformat(k) > cutoff} self.save_json(self.ERROR_LOG_PATH, errors) def log_applied_company(self, company): - """Log the company to which an application was submitted.""" self.applied_companies[company] = str(datetime.now()) self.save_json(self.APPLIED_COMPANIES_LOG_PATH, self.applied_companies) self.cleanup_applied_companies_log() def cleanup_applied_companies_log(self): - """Clean up logs of applied companies older than 2 weeks.""" cutoff = datetime.now() - timedelta(weeks=2) self.applied_companies = { k: v @@ -164,13 +161,11 @@ def cleanup_applied_companies_log(self): self.save_json(self.APPLIED_COMPANIES_LOG_PATH, self.applied_companies) def log_failed_application(self, company): - """Log the company where application failed.""" self.failed_applications[company] = str(datetime.now()) self.save_json(self.FAILED_APPLICATIONS_LOG_PATH, self.failed_applications) self.cleanup_failed_applications_log() def cleanup_failed_applications_log(self): - """Clean up logs of failed applications older than 2 weeks.""" cutoff = datetime.now() - timedelta(weeks=2) self.failed_applications = { k: v @@ -180,7 +175,6 @@ def cleanup_failed_applications_log(self): self.save_json(self.FAILED_APPLICATIONS_LOG_PATH, self.failed_applications) def login_linkedin(self): - """Log in to LinkedIn using the provided credentials.""" try: self.driver.get("https://www.linkedin.com/login") self.driver.add_cookie({ @@ -213,7 +207,6 @@ def login_linkedin(self): self.log_error(f"Login error: {e}") def job_search(self): - """Perform job search based on keywords and locations.""" while self.current_location_index < len(self.locations): try: WebDriverWait(self.driver, 20).until( @@ -227,8 +220,7 @@ def job_search(self): ) ) search_keywords = self.driver.find_element( - By.CSS_SELECTOR, "input[aria-label='Search by title, skill, or company']" - ) + By.CSS_SELECTOR, "input[aria-label='Search by title, skill, or company']") search_keywords.clear() search_keywords.send_keys(self.keywords) search_keywords.send_keys(" NOT ") @@ -239,14 +231,13 @@ def job_search(self): ) ) search_location = self.driver.find_element( - By.CSS_SELECTOR, "input[aria-label='City, state, or zip code']" - ) + By.CSS_SELECTOR, "input[aria-label='City, state, or zip code']") search_location.clear() search_location.send_keys(self.locations[self.current_location_index]) search_keywords.click() search_keywords.send_keys(Keys.RETURN) - time.sleep(5) # wait for the search results to load + time.sleep(5) if not self.check_no_results(): break else: @@ -261,7 +252,6 @@ def job_search(self): self.current_location_index += 1 def construct_url(self): - """Construct the URL for job search with applied filters.""" current_location = self.locations[self.current_location_index] combined_keywords = f'{self.keywords} NOT {self.keywords_to_avoid}' @@ -306,11 +296,10 @@ def construct_url(self): return url def apply_filters_and_search(self): - """Apply filters to the job search and navigate to the search URL.""" while self.current_location_index < len(self.locations): search_url = self.construct_url() self.driver.get(search_url) - time.sleep(5) # wait for the search results to load + time.sleep(5) if self.check_no_results(): self.log_info(f"No matching jobs found in {self.locations[self.current_location_index]}.") @@ -319,7 +308,6 @@ def apply_filters_and_search(self): break def check_no_results(self): - """Check if the job search resulted in no matches.""" try: no_results_element = self.driver.find_element( By.CSS_SELECTOR, "div.jobs-search-no-results-banner" @@ -329,7 +317,6 @@ def check_no_results(self): return False def find_element_with_retry(self, by, value, retries=3, delay=2): - """Find an element with retry logic.""" for _ in range(retries): try: return self.driver.find_element(by, value) @@ -338,7 +325,6 @@ def find_element_with_retry(self, by, value, retries=3, delay=2): raise NoSuchElementException(f"Element not found: {by}, {value}") def get_response_for_label(self, label_text): - """Get user response for a given label text.""" current_location = self.locations[self.current_location_index] if current_location in self.context_data["user_inputs"]: location_specific_inputs = self.context_data["user_inputs"][current_location] @@ -353,7 +339,6 @@ def get_response_for_label(self, label_text): return user_input def get_radio_response_for_label(self, label_text, options): - """Get user response for a radio button group labeled by the given text.""" current_location = self.locations[self.current_location_index] if current_location in self.context_data["user_inputs"]: location_specific_inputs = self.context_data["user_inputs"][current_location] @@ -376,7 +361,6 @@ def get_radio_response_for_label(self, label_text, options): print("Invalid input, please try again.") def get_file_response_for_label(self, label_text): - """Get user response for a file upload labeled by the given text.""" current_location = self.locations[self.current_location_index] if current_location in self.context_data["user_inputs"]: location_specific_inputs = self.context_data["user_inputs"][current_location] @@ -391,15 +375,19 @@ def get_file_response_for_label(self, label_text): return user_input def update_config_file(self): - """Update the configuration file with the latest user inputs.""" with open("config.json", "w") as config_file: json.dump(self.context_data, config_file, indent=4) def find_offers(self): - """Find and apply to job offers.""" + if self.collection: + self.apply_collection() + else: + self.apply_filtered_jobs() + + def apply_filtered_jobs(self): while self.current_location_index < len(self.locations): self.apply_filters_and_search() - + current_page = 1 while True: @@ -420,13 +408,10 @@ def find_offers(self): break job_item = job_list_items[index] - - # Scroll the element into view self.driver.execute_script("arguments[0].scrollIntoView(true);", job_item) time.sleep(1) - + try: - # Attempt to click the element with JavaScript self.driver.execute_script("arguments[0].click();", job_item) except ElementClickInterceptedException: self.log_info("Element click intercepted, skipping to next job...") @@ -441,7 +426,7 @@ def find_offers(self): ) company_name = self.get_company_name(job_item) - + if company_name in self.applied_companies: self.log_info(f"Already applied to a job at {company_name}, skipping...") self.close_application_modal() @@ -497,8 +482,108 @@ def find_offers(self): self.current_location_index += 1 + def apply_collection(self): + collection_url = self.COLLECTION_URLS.get(self.collection) + if not collection_url: + self.log_error(f"Invalid collection: {self.collection}") + return + + self.driver.get(collection_url) + time.sleep(5) + + current_page = 1 + + while True: + try: + WebDriverWait(self.driver, 10).until( + EC.presence_of_element_located((By.CLASS_NAME, "scaffold-layout__list-container")) + ) + + job_list_container = self.find_element_with_retry(By.CLASS_NAME, "scaffold-layout__list-container") + job_list_items = job_list_container.find_elements(By.TAG_NAME, "li") + + for index in range(len(job_list_items)): + try: + job_list_container = self.find_element_with_retry(By.CLASS_NAME, "scaffold-layout__list-container") + job_list_items = job_list_container.find_elements(By.TAG_NAME, "li") + + if index >= len(job_list_items): + break + + job_item = job_list_items[index] + self.driver.execute_script("arguments[0].scrollIntoView(true);", job_item) + time.sleep(1) + + try: + self.driver.execute_script("arguments[0].click();", job_item) + except ElementClickInterceptedException: + self.log_info("Element click intercepted, skipping to next job...") + continue + + time.sleep(2) + + WebDriverWait(self.driver, 10).until( + EC.presence_of_element_located( + (By.CLASS_NAME, "jobs-search__job-details--wrapper") + ) + ) + + company_name = self.get_company_name(job_item) + + if company_name in self.applied_companies: + self.log_info(f"Already applied to a job at {company_name}, skipping...") + self.close_application_modal() + continue + + job_details_wrapper = self.find_element_with_retry(By.CLASS_NAME, "jobs-search__job-details--wrapper") + + try: + apply_button = job_details_wrapper.find_element( + By.CSS_SELECTOR, "button.jobs-apply-button.artdeco-button--primary" + ) + apply_button.click() + time.sleep(2) + + WebDriverWait(self.driver, 10).until( + EC.presence_of_element_located( + (By.CSS_SELECTOR, "div.jobs-easy-apply-modal") + ) + ) + + try: + self.handle_easy_apply() + self.log_applied_company(company_name) + except Exception as e: + self.log_info(f"Failed to apply at {company_name}: {str(e)}") + self.log_failed_application(company_name) + + except NoSuchElementException: + self.log_info("No apply button found, continuing to next job...") + continue + + except (NoSuchElementException, ElementNotInteractableException, StaleElementReferenceException) as e: + self.log_info(f"Exception occurred: {e}, continuing to next job...") + self.log_error(f"Find offers error: {e}") + continue + + try: + pagination_container = self.find_element_with_retry(By.CLASS_NAME, "artdeco-pagination__pages") + next_page_button = pagination_container.find_element( + By.XPATH, + f"//button[@aria-label='Page {current_page + 1}']", + ) + self.driver.execute_script("arguments[0].click();", next_page_button) + time.sleep(2) + current_page += 1 + except NoSuchElementException: + self.log_info("No more pages left.") + break + except TimeoutException: + self.log_info("Timeout while waiting for job list container.") + self.log_error("Timeout while waiting for job list container.") + break + def get_company_name(self, job_item): - """Extract the company name from a job listing.""" try: company_element = job_item.find_element( By.CSS_SELECTOR, @@ -509,7 +594,6 @@ def get_company_name(self, job_item): return None def handle_easy_apply(self): - """Handle the easy apply process.""" while True: try: modal_dialog = WebDriverWait(self.driver, 10).until( @@ -555,7 +639,6 @@ def handle_easy_apply(self): break def fill_form(self, modal_dialog): - """Fill out the application form.""" form_elements = modal_dialog.find_elements( By.CSS_SELECTOR, "div[data-test-form-element], fieldset[data-test-form-builder-radio-button-form-component], fieldset[data-test-checkbox-form-component], div[data-test-text-entity-list-form-component]" ) @@ -569,7 +652,7 @@ def fill_form(self, modal_dialog): elif "data-test-text-entity-list-form-component" in element.get_attribute("outerHTML"): select_element = element.find_element(By.CSS_SELECTOR, "select") options = [option.text for option in select_element.find_elements(By.TAG_NAME, "option")] - response = self.get_radio_response_for_label(label_text, options[1:]) # Exclude "Select an option" + response = self.get_radio_response_for_label(label_text, options[1:]) for option in select_element.find_elements(By.TAG_NAME, "option"): if option.text == response: option.click() @@ -626,7 +709,6 @@ def fill_form(self, modal_dialog): self.log_info("Next button not found, form might be complete or there is an issue.") def handle_checkboxes(self, element): - """Handle multiple checkbox inputs.""" checkboxes = element.find_elements(By.CSS_SELECTOR, "input[type='checkbox']") for index in range(len(checkboxes)): checkbox_label = None @@ -641,7 +723,6 @@ def handle_checkboxes(self, element): self.retry_checkbox_interaction(element, index) def retry_checkbox_interaction(self, element, index): - """Retry interaction with the checkbox in case of exceptions.""" retries = 3 while retries > 0: retries -= 1 @@ -659,14 +740,12 @@ def retry_checkbox_interaction(self, element, index): self.log_info(f"Skipping {checkbox_label} after multiple retries.") def set_checkbox_state(self, checkbox, checkbox_label, response): - """Set the state of a checkbox.""" if response and not checkbox.is_selected(): self.driver.execute_script("arguments[0].click();", checkbox) elif not response and checkbox.is_selected(): self.driver.execute_script("arguments[0].click();", checkbox) def get_checkbox_response_for_label(self, label_text): - """Get user response for a checkbox labeled by the given text.""" current_location = self.locations[self.current_location_index] if current_location not in self.context_data["user_inputs"]: self.context_data["user_inputs"][current_location] = {} @@ -684,7 +763,6 @@ def get_checkbox_response_for_label(self, label_text): return response def handle_done_button(self): - """Handle the final done button after application submission.""" try: done_button = WebDriverWait(self.driver, 10).until( EC.presence_of_element_located((By.CSS_SELECTOR, "button.artdeco-button.artdeco-button--primary")) @@ -695,7 +773,6 @@ def handle_done_button(self): self.log_info("Done button not found, skipping to next job.") def close_application_modal(self): - """Close the application modal.""" try: close_button = WebDriverWait(self.driver, 10).until( EC.presence_of_element_located( @@ -712,7 +789,6 @@ def close_application_modal(self): self.log_info("Close button not found, skipping to next job.") def handle_discard_dialog(self): - """Handle the discard dialog when closing the application modal.""" try: discard_button = WebDriverWait(self.driver, 10).until( EC.presence_of_element_located( @@ -725,16 +801,13 @@ def handle_discard_dialog(self): self.log_info("Discard button not found, skipping to next job.") def close_session(self): - """Close the browser session.""" self.log_info("End of the session") self.driver.close() self.driver.quit() def handle_captcha(self): - """Handle CAPTCHA prompts manually.""" input("CAPTCHA detected. Please solve the CAPTCHA manually and then press Enter to continue...") - if __name__ == "__main__": with open("config.json") as config_file: data = json.load(config_file) From a380d3dc320548a4f9ffa39ad886c052dd962770 Mon Sep 17 00:00:00 2001 From: Gabo-Tech Date: Tue, 11 Aug 2026 18:21:15 +0200 Subject: [PATCH 19/19] Add local AI answers and Indeed Easily Apply crawler. Support ch.indeed.com Apply with Indeed flows using live DOM selectors, share answer/resume helpers with LinkedIn, and keep language-aware CV selection. Co-authored-by: Cursor --- .gitignore | 10 +- README.md | 147 ++- answer_engine.py | 1016 ++++++++++++++++ base_easy_apply.py | 327 ++++++ configExample.json | 104 +- discover_indeed_selectors.py | 179 +++ indeed_bot.py | 841 +++++++++++++ indeed_selectors.json | 2138 ++++++++++++++++++++++++++++++++++ main.py | 260 ++--- requirements.txt | 3 + test_answer_engine.py | 333 ++++++ test_indeed_bot.py | 223 ++++ unit_tests.py | 64 +- 13 files changed, 5429 insertions(+), 216 deletions(-) create mode 100644 answer_engine.py create mode 100644 base_easy_apply.py create mode 100644 discover_indeed_selectors.py create mode 100644 indeed_bot.py create mode 100644 indeed_selectors.json create mode 100644 test_answer_engine.py create mode 100644 test_indeed_bot.py diff --git a/.gitignore b/.gitignore index 08a27fb..7e862f5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,20 @@ # Ignore everything * config.json +.cache/ # Allow specific files !.gitignore !main.py +!answer_engine.py +!base_easy_apply.py +!indeed_bot.py +!discover_indeed_selectors.py +!indeed_selectors.json !configExample.json !README.md !LICENCE !requirements.txt !e2e_tests.py -!unit_tests.py \ No newline at end of file +!unit_tests.py +!test_answer_engine.py +!test_indeed_bot.py \ No newline at end of file diff --git a/README.md b/README.md index 4ba0d6a..d478421 100644 --- a/README.md +++ b/README.md @@ -73,16 +73,140 @@ These instructions will get you a copy of the project up and running on your loc ### Usage -1. Run the application: - ```sh - python main.py - ``` +LinkedIn (default): +```sh +python main.py +# or +python main.py --platform linkedin +``` + +Indeed Switzerland (Easily apply / Indeed Apply): +```sh +python main.py --platform indeed +``` ### Features - **Automated Job Applications**: Automatically apply to jobs that match your keywords and location. +- **LinkedIn Easy Apply** and **Indeed Easily Apply** (ch.indeed.com). - **Filter Options**: Customize filters for experience level, job type, time posted, workplace type, and more. - **Logging**: Keep track of errors and the companies you've applied to. +- **Local AI answers**: When a new Easy Apply question appears, a local model suggests an answer from your `aiContext`, past `user_inputs`, and simple rules. You confirm before it is saved. + +### Indeed Easily Apply (ch.indeed.com) + +The Indeed bot reuses the same answer engine, resumes, and `user_inputs` cache (stored under buckets like `Indeed:Zurich`). + +1. Configure the `indeed` block in `config.json` (see `configExample.json`): +```json +"indeed": { + "enabled": true, + "baseUrl": "https://ch.indeed.com", + "locations": ["Zurich", "Zug", "Remote"], + "filters": { + "easyApplyOnly": true, + "fromage": 7, + "remotejob": true + } +} +``` +2. Optional: refresh live DOM anchors after Indeed UI changes: +```sh +python discover_indeed_selectors.py +``` + Log in when prompted, open an Easily apply flow, and the script updates `indeed_selectors.json` plus snapshots under `.cache/indeed/`. +3. Run: +```sh +python main.py --platform indeed +``` + +Jobs without the Easily apply / Einfach bewerben badge (external ATS redirects) are skipped. CAPTCHA/login challenges pause for manual resolution. + +Selectors live in [`indeed_selectors.json`](indeed_selectors.json) (v2, from live CH DOM): + +- Apply button: `#indeedApplyButton` / `data-testid="indeedApplyButton-test"` (label **Apply with Indeed**) +- Easily apply badge: text match `Easily apply` / `Einfach bewerben` +- Continue: `button[data-testid="continue-button"]` +- Resume step: `form[data-testid="resume-selection-form"]` + hidden file input `data-testid="resume-selection-file-resume-radio-card-file-input"` +- Search inputs: `#text-input-what`, `#text-input-where` + +Hashed Emotion/mosaic class names are avoided; DE/EN text fallbacks remain. + +### Local AI auto-answer + +When the bot hits a question that is not already in `user_inputs`, it: + +1. Tries an exact (and normalized) cache lookup +2. Tries semantic retrieval over your past answers (`sentence-transformers`) +3. Applies rule-based answers from `aiContext` (years of experience, contact info, visa, salary, etc.) +4. Asks **Ollama** (primary) to generate a short, plain answer +5. Falls back to a local **transformers** model if Ollama is unavailable +6. Shows the suggestion and asks you to **[Y]es / [e]dit / [m]anual** before saving + +File uploads are never guessed by AI; those stay manual. + +#### Ollama setup (recommended) + +```sh +curl -fsSL https://ollama.com/install.sh | sh +ollama pull qwen2.5:3b-instruct +``` + +Keep the Ollama server running (`ollama serve` if it is not already a service). The bot talks to `http://localhost:11434` by default. + +#### `aiSettings` in `config.json` + +Copy the `aiSettings` block from `configExample.json`, or use: + +```json +"aiSettings": { + "enabled": true, + "primary": "ollama", + "ollama": { + "baseUrl": "http://localhost:11434", + "model": "qwen2.5:3b-instruct", + "timeoutSeconds": 30 + }, + "fallback": { + "model": "Qwen/Qwen2.5-1.5B-Instruct", + "device": "auto" + }, + "retrieval": { + "embeddingModel": "sentence-transformers/all-MiniLM-L6-v2", + "similarityThreshold": 0.85 + }, + "defaults": { + "salaryExpectationUsd": "90000", + "hourlyRateRange": "40-60", + "requiresSponsorship": true, + "willingToRelocate": true + }, + "style": { + "maxWords": 40, + "forbiddenPatterns": ["—", "Furthermore", "I am excited", "I am passionate"] + } +} +``` + +Also keep `aiContext` filled with your profile (experience, skills, languages, preferences). That data plus your growing `user_inputs` history is what makes answers sound like you. + +#### Language-aware CVs + +Put both resumes under `resumes/` and configure: + +```json +"resumes": { + "en": "resumes/Resume_Gabriel_Clemente.pdf", + "de": "resumes/Lebenslauf_Gabriel_Clemente.pdf", + "default": "en" +} +``` + +When an Easy Apply form asks for a resume/CV/Lebenslauf, the bot detects whether the application is German or English (from the form/job text) and attaches the matching file. If unsure, it uses English. + +Set `"enabled": false` to fall back to the old fully manual prompts. + +Embedding vectors are cached under `.cache/embeddings.pkl` so startup stays fast after the first run. ### Customization @@ -95,6 +219,11 @@ You can customize the job search and application process by editing the `config. - **driver_path**: Path to your downloaded WebDriver. - **sortBy**: Sort order for job listings. - **filters**: Various filters to narrow down the job search (e.g., easy apply, experience level, job type, etc.). +- **aiContext**: Your CV-style profile used by the local answer engine. +- **aiSettings**: Local AI model, retrieval, and answer-style settings. +- **user_inputs**: Cached answers to application questions (filled manually or after you confirm an AI suggestion). +- **indeed**: Indeed CH base URL, locations, and Easily apply filters. +- **resumes**: Paths to English/German CV PDFs. ### Testing @@ -107,6 +236,16 @@ Run the unit tests: python unit_tests.py ``` +Answer-engine tests (retrieval, rules, sanitizer, mocked Ollama/transformers): +```bash +python test_answer_engine.py +``` + +Indeed helper tests (URL builder, Easily apply detection, tab switching): +```bash +python test_indeed_bot.py +``` + #### E2E Tests End-to-end tests using `pytest` and `selenium` require an actual web browser to run. diff --git a/answer_engine.py b/answer_engine.py new file mode 100644 index 0000000..9f69bf9 --- /dev/null +++ b/answer_engine.py @@ -0,0 +1,1016 @@ +"""Local AI answer engine for LinkedIn Easy Apply form questions. + +Pipeline: exact match -> semantic retrieval -> rules -> Ollama -> transformers. +""" + +from __future__ import annotations + +import hashlib +import json +import logging +import pickle +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, List, Optional, Sequence, Tuple + +logger = logging.getLogger(__name__) + +CACHE_DIR = Path(".cache") +EMBEDDINGS_CACHE_PATH = CACHE_DIR / "embeddings.pkl" + +DEFAULT_AI_SETTINGS: Dict[str, Any] = { + "enabled": True, + "primary": "ollama", + "ollama": { + "baseUrl": "http://localhost:11434", + "model": "qwen2.5:3b-instruct", + "timeoutSeconds": 30, + }, + "fallback": { + "model": "Qwen/Qwen2.5-1.5B-Instruct", + "device": "auto", + }, + "retrieval": { + "embeddingModel": "sentence-transformers/all-MiniLM-L6-v2", + "similarityThreshold": 0.85, + }, + "defaults": { + "salaryExpectationUsd": "90000", + "hourlyRateRange": "40-60", + "requiresSponsorship": False, + "willingToRelocate": True, + "noticePeriodDays": 30, + "relocateTarget": "Zug, Switzerland", + }, + "style": { + "maxWords": 40, + "forbiddenPatterns": [ + "—", + "Furthermore", + "I am excited", + "I am passionate", + ], + }, +} + + +@dataclass +class Suggestion: + answer: Any + confidence: str # "high" | "medium" | "low" + source: str # "exact" | "retrieval" | "rules" | "ollama" | "transformers" | "none" + + +def normalize_label(label: str) -> str: + """Collapse LinkedIn duplicate labels and Required suffixes.""" + if not label: + return "" + text = label.replace("\r\n", "\n").replace("\r", "\n") + parts = [p.strip() for p in text.split("\n") if p.strip()] + # Drop trailing "Required" markers + parts = [p for p in parts if p.lower() != "required"] + if not parts: + return "" + # Deduplicate consecutive identical lines (City\nCity) + deduped: List[str] = [] + for part in parts: + if not deduped or deduped[-1].lower() != part.lower(): + deduped.append(part) + return " ".join(deduped).strip() + + +def sanitize_answer( + answer: str, + field_type: str = "text", + options: Optional[Sequence[str]] = None, + max_words: int = 40, + forbidden_patterns: Optional[Sequence[str]] = None, +) -> str: + """Strip AI tells and coerce to field constraints.""" + if answer is None: + return "" + text = str(answer).strip() + + # Remove common wrappers / quotes + text = text.strip("`\"'") + text = re.sub(r"^(answer|response)\s*:\s*", "", text, flags=re.IGNORECASE) + + # Em dashes and fancy punctuation + text = text.replace("—", "-").replace("–", "-") + text = re.sub(r"\s+--\s+", " - ", text) + + # Markdown leftovers + text = re.sub(r"[#*_`]+", "", text) + text = re.sub(r"^\s*[-•]\s+", "", text, flags=re.MULTILINE) + + forbidden = list(forbidden_patterns or []) + for pattern in forbidden: + if pattern and pattern.lower() not in ("—", "--"): + text = re.sub(re.escape(pattern), "", text, flags=re.IGNORECASE) + + # Collapse whitespace + text = re.sub(r"\s+", " ", text).strip() + + # Short fields: keep first sentence-ish chunk + if field_type in ("text", "radio", "select", "checkbox") and "\n" in str(answer): + first_line = str(answer).strip().splitlines()[0] + text = re.sub(r"\s+", " ", first_line).strip() + + words = text.split() + if max_words and len(words) > max_words and field_type in ("text", "textarea"): + text = " ".join(words[:max_words]).rstrip(",.;:") + + if field_type == "checkbox": + return _coerce_bool_string(text) + + if options and field_type in ("radio", "select"): + matched = fuzzy_match_option(text, options) + return matched if matched is not None else text + + return text + + +def _coerce_bool_string(text: str) -> str: + lowered = text.strip().lower() + if lowered in ("yes", "y", "true", "1", "check", "checked"): + return "true" + if lowered in ("no", "n", "false", "0", "uncheck", "unchecked"): + return "false" + return text + + +def coerce_checkbox_answer(answer: Any) -> bool: + if isinstance(answer, bool): + return answer + text = str(answer).strip().lower() + return text in ("yes", "y", "true", "1", "check", "checked") + + +def fuzzy_match_option(answer: str, options: Sequence[str]) -> Optional[str]: + """Pick the closest option; prefer exact/substring matches.""" + if not options: + return None + cleaned = answer.strip().lower() + # Exact (case-insensitive) + for opt in options: + if opt.strip().lower() == cleaned: + return opt + # Contained + for opt in options: + opt_l = opt.strip().lower() + if cleaned in opt_l or opt_l in cleaned: + return opt + # Token overlap score + answer_tokens = set(re.findall(r"[a-z0-9]+", cleaned)) + best_opt = None + best_score = 0.0 + for opt in options: + opt_tokens = set(re.findall(r"[a-z0-9]+", opt.lower())) + if not opt_tokens: + continue + overlap = len(answer_tokens & opt_tokens) / len(opt_tokens) + if overlap > best_score: + best_score = overlap + best_opt = opt + if best_score >= 0.5: + return best_opt + return None + + +def flatten_user_inputs(user_inputs: Dict[str, Dict[str, Any]]) -> List[Tuple[str, Any]]: + """Flatten location-scoped user_inputs into (normalized_question, answer) pairs.""" + pairs: List[Tuple[str, Any]] = [] + seen = set() + for _location, answers in (user_inputs or {}).items(): + if not isinstance(answers, dict): + continue + for question, answer in answers.items(): + norm = normalize_label(question) + if not norm or norm.lower() in seen: + continue + seen.add(norm.lower()) + pairs.append((norm, answer)) + return pairs + + +def _hash_pairs(pairs: Sequence[Tuple[str, Any]]) -> str: + payload = json.dumps( + [(q, str(a)) for q, a in pairs], + sort_keys=True, + ensure_ascii=False, + ) + return hashlib.sha256(payload.encode("utf-8")).hexdigest() + + +def detect_application_language(*texts: str) -> str: + """Return 'de' or 'en'. Defaults to English when unsure.""" + combined = " ".join(t for t in texts if t).strip() + if not combined: + return "en" + + lowered = combined.lower() + german_markers = [ + "ä", "ö", "ü", "ß", + "lebenslauf", "anschreiben", "bewerbung", "kündigung", "kuendigung", + "verfügbar", "verfuegbar", "jahre erfahrung", "wie viele jahre", + "deutsch", "schweiz", "wohnort", "gehalt", "vorname", "nachname", + "strasse", "plz", "ort", "haben sie", "ihre erfahrung", "notiz", + "arbeitserlaubnis", "aufenthalts", "bewilligung", + ] + german_hits = sum(1 for m in german_markers if m in lowered) + # Strong label cues + if "lebenslauf" in lowered or "anschreiben" in lowered: + return "de" + if german_hits >= 2: + return "de" + + try: + from langdetect import detect + + lang = detect(combined) + if lang == "de": + return "de" + except Exception: + pass + return "en" + + +def resolve_resume_path(context_data: Dict[str, Any], language: Optional[str] = None) -> Optional[str]: + """Pick EN/DE resume path from config. Defaults to English.""" + resumes = context_data.get("resumes") or {} + if not resumes: + return None + lang = (language or resumes.get("default") or "en").lower() + if lang.startswith("de"): + path = resumes.get("de") or resumes.get("en") + else: + path = resumes.get("en") or resumes.get("de") + if path and Path(path).exists(): + return str(Path(path).resolve()) + # Try relative to cwd + if path: + candidate = Path(path) + if candidate.exists(): + return str(candidate.resolve()) + return None + + +class AnswerEngine: + def __init__(self, context_data: Dict[str, Any], ai_settings: Optional[Dict[str, Any]] = None): + self.context_data = context_data + self.ai_settings = self._merge_settings(ai_settings or context_data.get("aiSettings") or {}) + self.ai_context = context_data.get("aiContext") or {} + self.user_inputs = context_data.get("user_inputs") or {} + self.pairs = flatten_user_inputs(self.user_inputs) + + self._embedder = None + self._embeddings = None # numpy array or list + self._pair_hash = _hash_pairs(self.pairs) + self._transformers_pipeline = None + self._index_built = False + + if self.ai_settings.get("enabled", True): + try: + self._ensure_embedding_index() + except Exception as exc: + logger.warning("Could not build embedding index at init: %s", exc) + + @staticmethod + def _merge_settings(overrides: Dict[str, Any]) -> Dict[str, Any]: + import copy + + merged = copy.deepcopy(DEFAULT_AI_SETTINGS) + for key, value in (overrides or {}).items(): + if isinstance(value, dict) and isinstance(merged.get(key), dict): + merged[key].update(value) + else: + merged[key] = value + return merged + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def suggest( + self, + question: str, + field_type: str = "text", + options: Optional[Sequence[str]] = None, + location: Optional[str] = None, + ) -> Suggestion: + """Return a suggested answer with confidence and source.""" + if field_type == "file": + return Suggestion(answer=None, confidence="low", source="none") + + norm_q = normalize_label(question) + opts = list(options) if options else None + + # Layer 1: exact match (location then global) + exact = self._exact_match(question, location) + if exact is not None: + return Suggestion(answer=exact, confidence="high", source="exact") + + # Layer 2: semantic retrieval + retrieved = self._semantic_retrieve(norm_q) + if retrieved is not None: + answer, score = retrieved + answer = self._adapt_answer(answer, field_type, opts) + confidence = "high" if score >= 0.92 else "medium" + return Suggestion(answer=answer, confidence=confidence, source="retrieval") + + # Layer 3: rules + rule_answer = self._rule_based_answer(norm_q, field_type, opts) + if rule_answer is not None: + answer = self._adapt_answer(rule_answer, field_type, opts) + return Suggestion(answer=answer, confidence="high", source="rules") + + # Layer 4/5: LLM generation + few_shot = self._top_similar_pairs(norm_q, k=5) + prompt = self._build_prompt(norm_q, field_type, opts, few_shot) + + raw = None + source = "none" + if self.ai_settings.get("primary", "ollama") == "ollama": + raw = self._generate_ollama(prompt) + if raw is not None: + source = "ollama" + if raw is None: + raw = self._generate_transformers(prompt) + if raw is not None: + source = "transformers" + + if raw is None: + return Suggestion(answer=None, confidence="low", source="none") + + style = self.ai_settings.get("style") or {} + cleaned = sanitize_answer( + raw, + field_type=field_type, + options=opts, + max_words=int(style.get("maxWords", 40)), + forbidden_patterns=style.get("forbiddenPatterns"), + ) + answer = self._adapt_answer(cleaned, field_type, opts) + + if field_type in ("radio", "select") and opts: + if fuzzy_match_option(str(answer), opts) is None: + return Suggestion(answer=answer, confidence="low", source=source) + + return Suggestion(answer=answer, confidence="medium", source=source) + + def rebuild_index(self) -> None: + """Rebuild embedding index from current user_inputs (e.g. after save).""" + self.user_inputs = self.context_data.get("user_inputs") or {} + self.pairs = flatten_user_inputs(self.user_inputs) + self._pair_hash = _hash_pairs(self.pairs) + self._index_built = False + self._embeddings = None + self._ensure_embedding_index(force=True) + + # ------------------------------------------------------------------ + # Exact / retrieval + # ------------------------------------------------------------------ + + def _exact_match(self, question: str, location: Optional[str]) -> Any: + norm = normalize_label(question) + locations_to_check: List[str] = [] + if location: + locations_to_check.append(location) + locations_to_check.extend(self.user_inputs.keys()) + + for loc in locations_to_check: + answers = self.user_inputs.get(loc) or {} + if question in answers: + return answers[question] + for key, value in answers.items(): + if normalize_label(key).lower() == norm.lower(): + return value + return None + + def _ensure_embedding_index(self, force: bool = False) -> None: + if self._index_built and not force: + return + if not self.pairs: + self._embeddings = None + self._index_built = True + return + + CACHE_DIR.mkdir(parents=True, exist_ok=True) + model_name = (self.ai_settings.get("retrieval") or {}).get( + "embeddingModel", "sentence-transformers/all-MiniLM-L6-v2" + ) + + if not force and EMBEDDINGS_CACHE_PATH.exists(): + try: + with EMBEDDINGS_CACHE_PATH.open("rb") as f: + cached = pickle.load(f) + if ( + cached.get("pair_hash") == self._pair_hash + and cached.get("model") == model_name + and cached.get("questions") == [q for q, _ in self.pairs] + ): + self._embeddings = cached["embeddings"] + self._index_built = True + logger.info("Loaded embedding index from cache (%d pairs)", len(self.pairs)) + return + except Exception as exc: + logger.warning("Failed to load embedding cache: %s", exc) + + embedder = self._get_embedder(model_name) + questions = [q for q, _ in self.pairs] + embeddings = embedder.encode(questions, show_progress_bar=False, convert_to_numpy=True) + self._embeddings = embeddings + self._index_built = True + + try: + with EMBEDDINGS_CACHE_PATH.open("wb") as f: + pickle.dump( + { + "pair_hash": self._pair_hash, + "model": model_name, + "questions": questions, + "embeddings": embeddings, + }, + f, + ) + logger.info("Cached embedding index (%d pairs)", len(self.pairs)) + except Exception as exc: + logger.warning("Failed to write embedding cache: %s", exc) + + def _get_embedder(self, model_name: str): + if self._embedder is None: + from sentence_transformers import SentenceTransformer + + self._embedder = SentenceTransformer(model_name) + return self._embedder + + def _cosine_scores(self, query_vec, matrix): + import numpy as np + + q = np.asarray(query_vec, dtype=float) + m = np.asarray(matrix, dtype=float) + q_norm = q / (np.linalg.norm(q) + 1e-9) + m_norm = m / (np.linalg.norm(m, axis=1, keepdims=True) + 1e-9) + return m_norm @ q_norm + + def _semantic_retrieve(self, question: str) -> Optional[Tuple[Any, float]]: + if not self.pairs: + return None + try: + self._ensure_embedding_index() + except Exception as exc: + logger.warning("Embedding index unavailable: %s", exc) + return None + if self._embeddings is None: + return None + + threshold = float( + (self.ai_settings.get("retrieval") or {}).get("similarityThreshold", 0.85) + ) + model_name = (self.ai_settings.get("retrieval") or {}).get( + "embeddingModel", "sentence-transformers/all-MiniLM-L6-v2" + ) + try: + embedder = self._get_embedder(model_name) + query_vec = embedder.encode([question], convert_to_numpy=True)[0] + scores = self._cosine_scores(query_vec, self._embeddings) + best_idx = int(scores.argmax()) + best_score = float(scores[best_idx]) + if best_score >= threshold: + return self.pairs[best_idx][1], best_score + except Exception as exc: + logger.warning("Semantic retrieval failed: %s", exc) + return None + + def _top_similar_pairs(self, question: str, k: int = 5) -> List[Tuple[str, Any]]: + if not self.pairs: + return [] + try: + self._ensure_embedding_index() + if self._embeddings is None: + return self.pairs[:k] + model_name = (self.ai_settings.get("retrieval") or {}).get( + "embeddingModel", "sentence-transformers/all-MiniLM-L6-v2" + ) + embedder = self._get_embedder(model_name) + query_vec = embedder.encode([question], convert_to_numpy=True)[0] + scores = self._cosine_scores(query_vec, self._embeddings) + import numpy as np + + top_idx = np.argsort(scores)[::-1][:k] + return [self.pairs[i] for i in top_idx] + except Exception: + return self.pairs[:k] + + # ------------------------------------------------------------------ + # Rules + # ------------------------------------------------------------------ + + def _rule_based_answer( + self, + question: str, + field_type: str, + options: Optional[Sequence[str]], + ) -> Any: + q = question.lower() + defaults = self.ai_settings.get("defaults") or {} + user_data = self.ai_context.get("user_data") or {} + languages = self.ai_context.get("languagesSpokenByUser") or {} + preferences = self.ai_context.get("preferences") or {} + + # Contact / profile fields + if "country code" in q and "phone" in q: + return user_data.get("phoneCountryCode") or "Switzerland (+41)" + + if re.search(r"\b(phone|mobile|telephone)\b", q) and "country code" not in q: + if user_data.get("phoneNationalNumber") and ( + "mobile phone number" in q or "phone number" in q or q.strip() in ("phone", "mobile") + ): + return user_data["phoneNationalNumber"] + phone = user_data.get("phone") + if phone: + return phone + + if "email" in q and "how did you" not in q: + if user_data.get("email"): + return user_data["email"] + + if re.search(r"\b(postal|zip)\b", q): + return user_data.get("postalCode") + + if re.search(r"\bstreet\b", q) or "address line 1" in q: + return user_data.get("street") or user_data.get("address") + + if re.search(r"\b(city|current location|where (are|do) you (live|reside)|residing|wohnort)\b", q): + return user_data.get("currentLocation") or user_data.get("city") or user_data.get("address") + + if ("address" in q or "adresse" in q) and "email" not in q: + return user_data.get("address") + + if "country" in q and "phone" not in q and "code" not in q: + return user_data.get("country") or "Switzerland" + + if "linkedin" in q and ("url" in q or "profile" in q or "share" in q or "link" in q): + return user_data.get("linkedin_url") + + if "github" in q: + if user_data.get("github_url"): + return user_data["github_url"] + for past_q, past_a in self.pairs: + if "github" in past_q.lower(): + return past_a + + if "portfolio" in q or "website" in q or "personal site" in q: + return user_data.get("portfolio_url") + + # Notice period (CV: 1 month) + if "notice period" in q or "kündigungsfrist" in q or "kuendigungsfrist" in q: + days = user_data.get("noticePeriodDays") or defaults.get("noticePeriodDays") or 30 + if "month" in q or "monat" in q: + return "1" + return str(days) + + # Work permit / authorization (Switzerland-aware) + if re.search(r"authorized to work|legally authorized|work permit|work authorization|arbeitserlaubnis|aufenthalts", q): + auth = self._work_auth_for_question(q) + answer = "Yes" if auth.get("authorized") else "No" + if options: + return fuzzy_match_option(answer, options) or answer + return answer + + # Language proficiency + if re.search( + r"(?:level of )?proficiency in \w+|(?:speak|language).*\b(english|spanish|dutch|portuguese|catalan|french|german|deutsch)\b", + q, + re.IGNORECASE, + ) or ("language" in q and "proficiency" in q) or "sprachniveau" in q or "deutschkenntnisse" in q: + for lang, level in languages.items(): + if lang.lower() in q or (lang.lower() == "german" and "deutsch" in q): + mapped = self._map_language_level(level, options) + return mapped if mapped is not None else level + + # Years of experience with a skill + years_match = re.search( + r"how many years.*?(?:with|using|in|of|working with|experience (?:with|in|using))\s+(.+?)(?:\?|$)", + q, + re.IGNORECASE, + ) + if years_match or re.search(r"years of (?:work |professional )?experience", q): + skill = None + if years_match: + skill = years_match.group(1).strip(" .?") + skill = re.sub(r"\s*\(.*?\)\s*", " ", skill).strip() + years = self._estimate_skill_years(skill) if skill else self._total_experience_years() + if years is not None: + if options: + return self._years_to_option(years, options) + return str(years) + + # Visa / sponsorship + if re.search(r"sponsor|sponsorship|visa|h-?1b|immigration|visum", q): + auth = self._work_auth_for_question(q) + requires = auth.get("requiresSponsorship", defaults.get("requiresSponsorship", True)) + if "require sponsorship" in q or "need visa" in q or "require.*visa" in q or "visum" in q: + answer = "Yes" if requires else "No" + elif "authorized to work" in q or "legally authorized" in q: + answer = "Yes" if auth.get("authorized") else "No" + else: + answer = "Yes" if requires else "No" + if options: + matched = fuzzy_match_option(answer, options) + return matched if matched is not None else answer + return answer + + # Salary / rate + if re.search(r"salary|compensation|ctc|expected (rate|pay)|hourly|gehalt", q): + if "hour" in q or "hourly" in q or "rate" in q: + return defaults.get("hourlyRateRange", "40-60") + return defaults.get("salaryExpectationUsd", "90000") + + # Office / hybrid / commute — remote anywhere; onsite only Zurich/Zug + if re.search(r"onsite|on-site|in.?office|commut|hybrid|office", q): + office_ok = self._office_location_allowed(q) + if "remote" in q and "only" not in q: + answer = "Yes" + elif office_ok is True: + answer = "Yes" + elif office_ok is False: + answer = "No" + else: + # Ambiguous location — prefer remote yes; otherwise yes if willing to relocate to Zug + if "remote" in q: + answer = "Yes" + else: + answer = "Yes" if preferences.get("willingToRelocate") else "No" + if options: + return fuzzy_match_option(answer, options) or answer + return answer + + # Relocate + if "relocate" in q or "umzug" in q or "umziehen" in q: + willing = preferences.get("willingToRelocate", defaults.get("willingToRelocate", True)) + target = preferences.get("relocateTarget") or defaults.get("relocateTarget") + if field_type == "text" and target and ("where" in q or "which" in q): + return target + answer = "Yes" if willing else "No" + if options: + matched = fuzzy_match_option(answer, options) + return matched if matched is not None else answer + return answer + + # Yes/No radio with clear skill possession when skill is in experience + if field_type in ("radio", "select") and options: + yes_no = {o.strip().lower() for o in options} + if yes_no <= {"yes", "no"} or yes_no == {"yes", "no"}: + # "Do you have experience with X?" + exp_match = re.search( + r"(?:experience (?:with|in|using)|familiar with|worked with|proficient in)\s+(.+?)(?:\?|$)", + q, + ) + if exp_match: + skill = exp_match.group(1).strip() + years = self._estimate_skill_years(skill) + answer = "Yes" if years and years > 0 else "No" + return fuzzy_match_option(answer, options) or answer + + # Checkbox defaults for LinkedIn / agree terms + if field_type == "checkbox": + if "linkedin" in q: + return True + if "agree" in q and "terms" in q: + return True + + return None + + def _work_auth_for_question(self, question_lower: str) -> Dict[str, Any]: + auth_cfg = self.ai_context.get("workAuthorization") or {} + defaults = self.ai_settings.get("defaults") or {} + if re.search(r"switzerland|schweiz|swiss|zurich|zürich|zug", question_lower): + return auth_cfg.get("switzerland") or { + "authorized": True, + "requiresSponsorship": False, + } + if re.search( + r"\b(germany|deutschland|spain|france|netherlands|belgium|portugal|ireland|eu|eea|european)\b", + question_lower, + ): + return auth_cfg.get("eu") or {"authorized": True, "requiresSponsorship": False} + # Search location context + try: + # Prefer Switzerland defaults when searching CH + return { + "authorized": not defaults.get("requiresSponsorship", False), + "requiresSponsorship": defaults.get("requiresSponsorship", False), + } + except Exception: + return auth_cfg.get("default") or { + "authorized": False, + "requiresSponsorship": True, + } + + def _office_location_allowed(self, question_lower: str) -> Optional[bool]: + preferences = self.ai_context.get("preferences") or {} + allowed = [x.lower() for x in (preferences.get("onsiteOnlyIn") or preferences.get("officeLocationsOnly") or [])] + if not allowed: + return None + # If question mentions a city/region + mentioned = None + for city in allowed + ["geneva", "genf", "bern", "basel", "lausanne", "london", "remote"]: + if city.lower() in question_lower: + mentioned = city.lower() + break + if mentioned == "remote": + return True + if mentioned is None: + return None + return mentioned in [a.lower() for a in allowed] + + def _map_language_level(self, level: str, options: Optional[Sequence[str]]) -> Any: + level_l = (level or "").lower() + mapping = { + "native": "Native or bilingual", + "c2": "Native or bilingual", + "c1": "Full professional", + "full professional": "Full professional", + "fluent": "Full professional", + "professional": "Professional working", + "intermediate": "Limited working", + "basic": "Elementary", + "beginner": "Elementary", + "a1": "Elementary", + "a2": "Elementary", + "b1": "Limited working", + "b2": "Professional working", + } + preferred = None + for key, value in mapping.items(): + if key in level_l: + preferred = value + break + preferred = preferred or level + if options: + matched = fuzzy_match_option(preferred, options) + if matched: + return matched + matched = fuzzy_match_option(level, options) + return matched if matched is not None else preferred + return preferred + + def _total_experience_years(self) -> int: + experience = self.ai_context.get("experience") or [] + # Rough: use longest contiguous span from earliest start to latest end/"Present" + years = 0 + for job in experience: + date = str(job.get("date") or "") + y = self._parse_years_from_date_range(date) + if y is not None: + years = max(years, y) + # Also sum unique roughly - prefer max of individual roles if overlapping + if years == 0 and experience: + years = 4 # sensible default from profile + return years + + def _estimate_skill_years(self, skill: Optional[str]) -> Optional[int]: + if not skill: + return self._total_experience_years() + skill_l = skill.lower().strip() + # Normalize common aliases + aliases = { + "node": "node.js", + "nodejs": "node.js", + "react.js": "react", + "reactjs": "react", + "js": "javascript", + "ts": "typescript", + "postgres": "postgresql", + } + skill_l = aliases.get(skill_l, skill_l) + + # Prefer past user_inputs for this skill + for past_q, past_a in self.pairs: + pq = past_q.lower() + if "years" in pq and skill_l in pq: + try: + return int(re.search(r"\d+", str(past_a)).group()) + except Exception: + pass + + experience = self.ai_context.get("experience") or [] + skills_list = [s.lower() for s in (self.ai_context.get("skills") or [])] + best = 0 + found = False + for job in experience: + job_skills = [s.lower() for s in (job.get("skills") or [])] + if any(skill_l in s or s in skill_l for s in job_skills): + found = True + y = self._parse_years_from_date_range(str(job.get("date") or "")) + if y: + best = max(best, y) + if found: + return best or 1 + if any(skill_l in s or s in skill_l for s in skills_list): + return 1 + # Unknown skill -> 0 years is honest + if re.search(r"[a-z]", skill_l): + return 0 + return None + + @staticmethod + def _parse_years_from_date_range(date_str: str) -> Optional[int]: + # e.g. "Nov 2022 - Apr 2024" or "Jan 2021 - Present" + months = { + "jan": 1, "feb": 2, "mar": 3, "apr": 4, "may": 5, "jun": 6, + "jul": 7, "aug": 8, "sep": 9, "oct": 10, "nov": 11, "dec": 12, + } + matches = re.findall(r"(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\s+(\d{4})", date_str, re.I) + if not matches: + years = re.findall(r"(20\d{2})", date_str) + if len(years) >= 1: + start = int(years[0]) + end = 2026 if "present" in date_str.lower() else (int(years[-1]) if len(years) > 1 else start) + return max(0, end - start) + return None + start_m, start_y = months[matches[0][0][:3].lower()], int(matches[0][1]) + if len(matches) >= 2: + end_m, end_y = months[matches[1][0][:3].lower()], int(matches[1][1]) + elif "present" in date_str.lower(): + end_m, end_y = 8, 2026 # approximate "today" + else: + end_m, end_y = start_m, start_y + total_months = (end_y - start_y) * 12 + (end_m - start_m) + return max(0, round(total_months / 12)) + + @staticmethod + def _years_to_option(years: int, options: Sequence[str]) -> str: + # Try numeric exact + for opt in options: + if re.fullmatch(r"\d+", opt.strip()) and int(opt.strip()) == years: + return opt + # Ranges like "2-5 years", "0-2 years", "3+" + for opt in options: + m = re.search(r"(\d+)\s*[-–to]+\s*(\d+)", opt) + if m: + low, high = int(m.group(1)), int(m.group(2)) + if low <= years <= high: + return opt + m = re.search(r"(\d+)\s*\+", opt) + if m and years >= int(m.group(1)): + return opt + m = re.search(r"less than\s*(\d+)", opt, re.I) + if m and years < int(m.group(1)): + return opt + return fuzzy_match_option(str(years), options) or str(years) + + # ------------------------------------------------------------------ + # LLM + # ------------------------------------------------------------------ + + def _build_context_summary(self) -> str: + user_data = self.ai_context.get("user_data") or {} + preferences = self.ai_context.get("preferences") or {} + skills = self.ai_context.get("skills") or [] + languages = self.ai_context.get("languagesSpokenByUser") or {} + experience = self.ai_context.get("experience") or [] + titles = [e.get("title") for e in experience[:3] if e.get("title")] + defaults = self.ai_settings.get("defaults") or {} + + lines = [ + f"Location: {user_data.get('currentLocation', 'N/A')}", + f"Address: {user_data.get('address', 'N/A')}", + f"Email: {user_data.get('email', 'N/A')}", + f"Phone: {user_data.get('phone', 'N/A')}", + f"LinkedIn: {user_data.get('linkedin_url', 'N/A')}", + f"GitHub: {user_data.get('github_url', 'N/A')}", + f"Work permit: {user_data.get('workPermit', 'N/A')}", + f"Notice period days: {user_data.get('noticePeriodDays', defaults.get('noticePeriodDays', 30))}", + f"Top skills: {', '.join(skills[:12])}", + f"Recent roles: {', '.join(titles)}", + f"Languages: {', '.join(f'{k} ({v})' for k, v in languages.items())}", + f"Preferred workplace: {preferences.get('workplaceType', 'Remote')}", + f"Remote anywhere: {preferences.get('remoteAnywhere', True)}", + f"Office only in: {', '.join(preferences.get('onsiteOnlyIn') or [])}", + f"Relocate target: {preferences.get('relocateTarget', defaults.get('relocateTarget'))}", + f"Preferred job type: {preferences.get('jobType', 'Contract')}", + f"Willing to relocate: {preferences.get('willingToRelocate', defaults.get('willingToRelocate'))}", + f"Requires sponsorship (default): {defaults.get('requiresSponsorship')}", + f"Salary expectation USD: {defaults.get('salaryExpectationUsd')}", + f"Hourly rate: {defaults.get('hourlyRateRange')}", + f"Total experience years (approx): {self._total_experience_years()}", + ] + return "\n".join(lines) + + def _build_prompt( + self, + question: str, + field_type: str, + options: Optional[Sequence[str]], + few_shot: Sequence[Tuple[str, Any]], + ) -> str: + examples = "\n".join( + f"Q: {q}\nA: {a}" for q, a in few_shot if a is not None + ) + option_block = "" + if options: + option_block = ( + "\nValid options (reply with ONLY one of these exactly):\n" + + "\n".join(f"- {o}" for o in options) + ) + elif field_type == "checkbox": + option_block = "\nReply with ONLY true or false." + + instruction = ( + "You are filling a job application form as the candidate. " + "Answer in 1-2 short plain sentences max. " + "No em dashes, no bullet lists, no markdown, no phrases like " + "'I am excited' or 'I am passionate'. Match the tone of the examples. " + "Reply with ONLY the answer text." + ) + if field_type in ("radio", "select") and options: + instruction = ( + "You are filling a job application form as the candidate. " + "Reply with ONLY the exact option text from the list. " + "No explanation." + ) + elif field_type == "checkbox": + instruction = ( + "You are filling a job application form as the candidate. " + "Reply with ONLY true or false." + ) + + return ( + f"{instruction}\n\n" + f"Candidate profile:\n{self._build_context_summary()}\n\n" + f"Examples of how this candidate answers:\n{examples}\n\n" + f"Question: {question}" + f"{option_block}\n\n" + f"Answer:" + ) + + def _generate_ollama(self, prompt: str) -> Optional[str]: + cfg = self.ai_settings.get("ollama") or {} + base_url = cfg.get("baseUrl", "http://localhost:11434").rstrip("/") + model = cfg.get("model", "qwen2.5:3b-instruct") + timeout = int(cfg.get("timeoutSeconds", 30)) + try: + import requests + + resp = requests.post( + f"{base_url}/api/generate", + json={ + "model": model, + "prompt": prompt, + "stream": False, + "options": {"temperature": 0.2, "num_predict": 120}, + }, + timeout=timeout, + ) + if resp.status_code != 200: + logger.warning("Ollama returned status %s", resp.status_code) + return None + data = resp.json() + return (data.get("response") or "").strip() or None + except Exception as exc: + logger.info("Ollama unavailable, will try fallback: %s", exc) + return None + + def _generate_transformers(self, prompt: str) -> Optional[str]: + cfg = self.ai_settings.get("fallback") or {} + model_name = cfg.get("model", "Qwen/Qwen2.5-1.5B-Instruct") + device = cfg.get("device", "auto") + try: + pipe = self._get_transformers_pipeline(model_name, device) + result = pipe( + prompt, + max_new_tokens=80, + do_sample=False, + return_full_text=False, + ) + if isinstance(result, list) and result: + text = result[0].get("generated_text", "") + return text.strip() or None + return None + except Exception as exc: + logger.warning("Transformers fallback failed: %s", exc) + return None + + def _get_transformers_pipeline(self, model_name: str, device: str): + if self._transformers_pipeline is None: + from transformers import pipeline + + kwargs: Dict[str, Any] = { + "task": "text-generation", + "model": model_name, + } + if device != "auto": + kwargs["device"] = device + self._transformers_pipeline = pipeline(**kwargs) + return self._transformers_pipeline + + def _adapt_answer(self, answer: Any, field_type: str, options: Optional[Sequence[str]]) -> Any: + if field_type == "checkbox": + return coerce_checkbox_answer(answer) + if options and field_type in ("radio", "select"): + matched = fuzzy_match_option(str(answer), options) + return matched if matched is not None else answer + return answer diff --git a/base_easy_apply.py b/base_easy_apply.py new file mode 100644 index 0000000..d86f73a --- /dev/null +++ b/base_easy_apply.py @@ -0,0 +1,327 @@ +"""Shared helpers for LinkedIn / Indeed Easy Apply bots.""" + +from __future__ import annotations + +import json +import logging +from datetime import datetime, timedelta +from pathlib import Path + +from selenium.common.exceptions import ( + NoSuchElementException, + StaleElementReferenceException, +) +from selenium.webdriver.firefox.service import Service as FirefoxService +from selenium import webdriver + +from answer_engine import AnswerEngine, detect_application_language, resolve_resume_path + + +class BaseEasyApply: + ERROR_LOG_PATH = Path("error_log.json") + APPLIED_COMPANIES_LOG_PATH = Path("applied_companies_log.json") + FAILED_APPLICATIONS_LOG_PATH = Path("failed_applications_log.json") + + def __init__(self, data, start_driver=True): + self.email = data["email"] + self.password = data["password"] + self.keywords_list = data.get("keywords") or [] + self.keywords_to_avoid_list = data.get("keywordsToAvoid") or [] + self.keywords = " OR ".join(self.keywords_list) + self.keywords_to_avoid = " NOT ".join(self.keywords_to_avoid_list) + self.locations = data.get("locations") or [] + self.filters = data.get("filters") or {} + self.context_data = data + self.current_location_index = 0 + if "user_inputs" not in self.context_data: + self.context_data["user_inputs"] = {} + self.answer_engine = None + self._ai_settings = data.get("aiSettings") or {} + self._ai_enabled = self._ai_settings.get("enabled", True) + self.driver = None + if start_driver: + firefox_service = FirefoxService(executable_path=data["driver_path"]) + self.driver = webdriver.Firefox(service=firefox_service) + self.init_logging() + + # ------------------------------------------------------------------ + # Answer engine + # ------------------------------------------------------------------ + + def _get_answer_engine(self): + if not self._ai_enabled: + return None + if self.answer_engine is None: + try: + self.answer_engine = AnswerEngine(self.context_data, self._ai_settings) + self.log_info("Local AI answer engine enabled.") + except Exception as e: + self.log_info(f"Could not initialize AI answer engine: {e}") + self._ai_enabled = False + return None + return self.answer_engine + + def _current_answer_bucket(self): + """Location key used for user_inputs cache.""" + if not self.locations: + return "default" + if self.current_location_index >= len(self.locations): + return self.locations[-1] + return self.locations[self.current_location_index] + + def _get_cached_answer(self, label_text): + bucket = self._current_answer_bucket() + location_inputs = self.context_data["user_inputs"].get(bucket) or {} + if label_text in location_inputs: + return location_inputs[label_text] + return None + + def _save_answer(self, label_text, answer): + bucket = self._current_answer_bucket() + if bucket not in self.context_data["user_inputs"]: + self.context_data["user_inputs"][bucket] = {} + self.context_data["user_inputs"][bucket][label_text] = answer + self.update_config_file() + engine = self.answer_engine + if engine is not None: + try: + from answer_engine import normalize_label + + engine.context_data = self.context_data + engine.user_inputs = self.context_data.get("user_inputs") or {} + norm = normalize_label(label_text) + updated = False + for i, (q, _a) in enumerate(engine.pairs): + if q.lower() == norm.lower(): + engine.pairs[i] = (q, answer) + updated = True + break + if not updated and norm: + engine.pairs.append((norm, answer)) + except Exception as e: + self.log_info(f"Could not update AI memory after save: {e}") + + def resolve_answer(self, label_text, field_type, options=None): + """Resolve an answer via cache, AI suggestion + confirm, or manual input.""" + cached = self._get_cached_answer(label_text) + if cached is not None: + return cached + + if field_type == "file": + return self._resolve_file_answer(label_text) + + engine = self._get_answer_engine() + if engine is None: + return self._manual_prompt(label_text, field_type, options) + + try: + suggestion = engine.suggest( + label_text, + field_type=field_type, + options=options, + location=self._current_answer_bucket(), + ) + except Exception as e: + self.log_info(f"AI suggestion failed: {e}") + return self._manual_prompt(label_text, field_type, options) + + if suggestion.answer is None or suggestion.source == "none": + return self._manual_prompt(label_text, field_type, options) + + print(f"\nQuestion: {label_text}") + if options: + print(f"Options: {options}") + print(f"Suggested ({suggestion.source}, {suggestion.confidence}): {suggestion.answer}") + choice = input("[Y]es accept / [e]dit / [m]anual: ").strip().lower() + + if choice in ("", "y", "yes"): + answer = suggestion.answer + self._save_answer(label_text, answer) + return answer + + if choice in ("e", "edit"): + edited = input(f"Edit answer [{suggestion.answer}]: ").strip() + answer = edited if edited else suggestion.answer + if field_type == "checkbox": + answer = str(answer).strip().lower() in ("yes", "y", "true", "1") + self._save_answer(label_text, answer) + return answer + + return self._manual_prompt(label_text, field_type, options) + + def _gather_application_text(self, label_text=""): + """Override in subclasses to include platform-specific page text.""" + return label_text or "" + + def _resolve_file_answer(self, label_text): + """Attach EN/DE resume based on application language; default English.""" + label_l = (label_text or "").lower() + is_cover = any(k in label_l for k in ("cover letter", "anschreiben", "motivation")) + is_resume = any( + key in label_l for key in ("resume", "cv", "curriculum", "lebenslauf") + ) or ( + not is_cover + and any(k in label_l for k in ("upload", "attach", "document")) + ) + + if is_resume and not is_cover: + app_text = self._gather_application_text(label_text) + lang = detect_application_language(app_text, label_text) + resume_path = resolve_resume_path(self.context_data, lang) + if resume_path: + self.log_info(f"Attaching {lang.upper()} resume: {resume_path}") + print(f"\nFile field: {label_text}") + print(f"Detected application language: {lang} -> {resume_path}") + choice = input("[Y]es use this CV / [e]dit path / [m]anual: ").strip().lower() + if choice in ("", "y", "yes"): + self._save_answer(label_text, resume_path) + return resume_path + if choice in ("e", "edit"): + edited = input(f"File path [{resume_path}]: ").strip() or resume_path + self._save_answer(label_text, edited) + return edited + return self._manual_prompt(label_text, "file") + + def _manual_prompt(self, label_text, field_type, options=None): + if field_type == "checkbox": + while True: + user_input = input( + f"Do you want to check the box for '{label_text}'? (yes/no): " + ).strip().lower() + if user_input in ("yes", "no"): + response = user_input == "yes" + self._save_answer(label_text, response) + return response + elif field_type in ("radio", "select") and options: + while True: + print(f"Please select an option for '{label_text}':") + for i, option in enumerate(options): + print(f"{i + 1}. {option}") + user_input = input("Enter the number of your choice: ").strip() + if user_input.isdigit() and 1 <= int(user_input) <= len(options): + response = options[int(user_input) - 1] + self._save_answer(label_text, response) + return response + print("Invalid input, please try again.") + elif field_type == "file": + user_input = input(f"Please provide the file location for '{label_text}': ") + self._save_answer(label_text, user_input) + return user_input + else: + user_input = input(f"Please provide the answer for '{label_text}': ") + self._save_answer(label_text, user_input) + return user_input + + def get_response_for_label(self, label_text): + return self.resolve_answer(label_text, field_type="text") + + def get_radio_response_for_label(self, label_text, options): + return self.resolve_answer(label_text, field_type="radio", options=options) + + def get_file_response_for_label(self, label_text): + return self.resolve_answer(label_text, field_type="file") + + def get_checkbox_response_for_label(self, label_text): + return self.resolve_answer(label_text, field_type="checkbox") + + def update_config_file(self): + with open("config.json", "w") as config_file: + json.dump(self.context_data, config_file, indent=4) + + # ------------------------------------------------------------------ + # Logging + # ------------------------------------------------------------------ + + def init_logging(self): + logging.basicConfig(level=logging.INFO) + self.error_logger = logging.getLogger("ErrorLogger") + self.applied_companies = self.load_json(self.APPLIED_COMPANIES_LOG_PATH) + self.failed_applications = self.load_json(self.FAILED_APPLICATIONS_LOG_PATH) + + def load_json(self, path): + if path.exists(): + try: + with path.open("r") as file: + return json.load(file) + except json.JSONDecodeError: + self.log_error(f"Error decoding JSON from {path}") + return {} + return {} + + def save_json(self, path, data): + with path.open("w") as file: + json.dump(data, file, indent=4) + + def log_error(self, error_msg): + self.error_logger.error(error_msg) + errors = self.load_json(self.ERROR_LOG_PATH) + errors[str(datetime.now())] = error_msg + self.save_json(self.ERROR_LOG_PATH, errors) + self.cleanup_error_log() + + def log_info(self, message): + logging.info(message) + + def cleanup_error_log(self): + errors = self.load_json(self.ERROR_LOG_PATH) + cutoff = datetime.now() - timedelta(days=1) + errors = {k: v for k, v in errors.items() if datetime.fromisoformat(k) > cutoff} + self.save_json(self.ERROR_LOG_PATH, errors) + + def log_applied_company(self, company): + if company: + self.applied_companies[company] = str(datetime.now()) + self.save_json(self.APPLIED_COMPANIES_LOG_PATH, self.applied_companies) + self.cleanup_applied_companies_log() + + def cleanup_applied_companies_log(self): + cutoff = datetime.now() - timedelta(weeks=2) + self.applied_companies = { + k: v + for k, v in self.applied_companies.items() + if k and v and datetime.fromisoformat(v) > cutoff + } + self.save_json(self.APPLIED_COMPANIES_LOG_PATH, self.applied_companies) + + def log_failed_application(self, company): + self.failed_applications[company] = str(datetime.now()) + self.save_json(self.FAILED_APPLICATIONS_LOG_PATH, self.failed_applications) + self.cleanup_failed_applications_log() + + def cleanup_failed_applications_log(self): + cutoff = datetime.now() - timedelta(weeks=2) + self.failed_applications = { + k: v + for k, v in self.failed_applications.items() + if datetime.fromisoformat(v) > cutoff + } + self.save_json(self.FAILED_APPLICATIONS_LOG_PATH, self.failed_applications) + + # ------------------------------------------------------------------ + # Selenium helpers + # ------------------------------------------------------------------ + + def find_element_with_retry(self, by, value, retries=3, delay=2): + import time + + for _ in range(retries): + try: + return self.driver.find_element(by, value) + except (NoSuchElementException, StaleElementReferenceException): + time.sleep(delay) + raise NoSuchElementException(f"Element not found: {by}, {value}") + + def handle_captcha(self): + input("CAPTCHA detected. Please solve the CAPTCHA manually and then press Enter to continue...") + + def close_session(self): + self.log_info("End of the session") + if self.driver: + try: + self.driver.close() + except Exception: + pass + try: + self.driver.quit() + except Exception: + pass diff --git a/configExample.json b/configExample.json index 603289f..8090c6a 100644 --- a/configExample.json +++ b/configExample.json @@ -70,10 +70,35 @@ }, "collection": "", "aiContext": { + "user_data": { + "linkedin_url": "https://www.linkedin.com/in/example", + "github_url": "https://github.com/example", + "portfolio_url": "https://example.dev/", + "phone": "+41 76 000 00 00", + "phoneCountryCode": "Switzerland (+41)", + "phoneNationalNumber": "760000000", + "email": "example@domain.com", + "address": "Nordstrasse 70, 8006 Zurich, Switzerland", + "street": "Nordstrasse 70", + "city": "Zurich", + "postalCode": "8006", + "country": "Switzerland", + "currentLocation": "Zurich, Switzerland", + "nationality": "Spain", + "workPermit": "Swiss B Permit (EU/EFTA)", + "noticePeriodDays": 30, + "availability": "1 Month Notice Period" + }, + "languagesSpokenByUser": { + "English": "Full Professional / Native (C1/C2)", + "Spanish": "Native (C2)", + "German": "Basic (A1/A2)" + }, "preferences": { "workplaceType": "Remote", "workplaceTypeAlternative": [ - "Hybrid" + "Hybrid", + "On-site" ], "jobType": "Contract", "jobTypeAlternative": [ @@ -83,10 +108,28 @@ "prereferredEndAlternative": [ "Full-Stack", "Frontend" - ] + ], + "willingToRelocate": true, + "relocateTarget": "Zug, Switzerland", + "remoteAnywhere": true, + "officeLocationsOnly": ["Zurich", "Zug", "Zürich"], + "onsiteOnlyIn": ["Zurich", "Zug", "Zürich"] + }, + "workAuthorization": { + "switzerland": { + "authorized": true, + "requiresSponsorship": false, + "permit": "Swiss B Permit (EU/EFTA)" + }, + "eu": { + "authorized": true, + "requiresSponsorship": false + }, + "default": { + "authorized": false, + "requiresSponsorship": true + } }, - "currentLocation": "Fake City, Country", - "willingToRelocate": true, "experience": [ { "title": "Full-Stack Developer", @@ -155,6 +198,59 @@ "CSS" ] }, + "aiSettings": { + "enabled": true, + "primary": "ollama", + "ollama": { + "baseUrl": "http://localhost:11434", + "model": "qwen2.5:3b-instruct", + "timeoutSeconds": 30 + }, + "fallback": { + "model": "Qwen/Qwen2.5-1.5B-Instruct", + "device": "auto" + }, + "retrieval": { + "embeddingModel": "sentence-transformers/all-MiniLM-L6-v2", + "similarityThreshold": 0.85 + }, + "defaults": { + "salaryExpectationUsd": "90000", + "hourlyRateRange": "40-60", + "requiresSponsorship": false, + "willingToRelocate": true, + "noticePeriodDays": 30, + "relocateTarget": "Zug, Switzerland" + }, + "style": { + "maxWords": 40, + "forbiddenPatterns": [ + "—", + "Furthermore", + "I am excited", + "I am passionate" + ] + } + }, + "resumes": { + "en": "resumes/Resume_Gabriel_Clemente.pdf", + "de": "resumes/Lebenslauf_Gabriel_Clemente.pdf", + "default": "en" + }, + "indeed": { + "enabled": true, + "baseUrl": "https://ch.indeed.com", + "locations": [ + "Zurich", + "Zug", + "Remote" + ], + "filters": { + "easyApplyOnly": true, + "fromage": 7, + "remotejob": true + } + }, "user_inputs": { "United States": { "City\nCity": "Fake City, USA", diff --git a/discover_indeed_selectors.py b/discover_indeed_selectors.py new file mode 100644 index 0000000..c498414 --- /dev/null +++ b/discover_indeed_selectors.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +"""Live DOM discovery for ch.indeed.com Indeed Apply anchors. + +Usage: + python discover_indeed_selectors.py + +Opens Firefox, loads an Easily Apply search, pauses for manual login if needed, +then dumps interactive elements into indeed_selectors.json and .cache/indeed/. +""" + +from __future__ import annotations + +import json +import time +from datetime import datetime +from pathlib import Path + +from selenium import webdriver +from selenium.webdriver.common.by import By +from selenium.webdriver.firefox.service import Service as FirefoxService + +CACHE_DIR = Path(".cache/indeed") +SELECTORS_PATH = Path("indeed_selectors.json") +CONFIG_PATH = Path("config.json") + + +def load_config(): + if CONFIG_PATH.exists(): + with CONFIG_PATH.open() as f: + return json.load(f) + return {} + + +def describe_element(el): + try: + return { + "tag": el.tag_name, + "id": el.get_attribute("id") or "", + "class": el.get_attribute("class") or "", + "name": el.get_attribute("name") or "", + "type": el.get_attribute("type") or "", + "data_testid": el.get_attribute("data-testid") or "", + "data_jk": el.get_attribute("data-jk") or "", + "aria_label": el.get_attribute("aria-label") or "", + "href": el.get_attribute("href") or "", + "text": (el.text or "")[:200].strip(), + } + except Exception as exc: + return {"error": str(exc)} + + +def dump_step(driver, step_name: str, snapshot: dict): + CACHE_DIR.mkdir(parents=True, exist_ok=True) + stamp = datetime.now().strftime("%Y%m%d_%H%M%S") + html_path = CACHE_DIR / f"{step_name}_{stamp}.html" + json_path = CACHE_DIR / f"{step_name}_{stamp}.json" + try: + html_path.write_text(driver.page_source, encoding="utf-8") + except Exception: + pass + json_path.write_text(json.dumps(snapshot, indent=2, ensure_ascii=False), encoding="utf-8") + print(f"Wrote {json_path}") + return snapshot + + +def collect_interactive(driver, root=None): + scope = root or driver + selectors = ["button", "input", "textarea", "select", "a"] + found = [] + for sel in selectors: + try: + for el in scope.find_elements(By.CSS_SELECTOR, sel): + found.append(describe_element(el)) + except Exception: + continue + return found + + +def merge_selectors(existing: dict, discovered: dict) -> dict: + merged = dict(existing or {}) + merged["discoveredAt"] = datetime.now().isoformat() + merged["liveSnapshots"] = discovered + # Prefer keeping curated seed selectors; append unique live class hints + hints = discovered.get("classHints") or [] + if hints: + merged.setdefault("liveClassHints", []) + for h in hints: + if h not in merged["liveClassHints"]: + merged["liveClassHints"].append(h) + return merged + + +def main(): + config = load_config() + indeed_cfg = config.get("indeed") or {} + base_url = indeed_cfg.get("baseUrl", "https://ch.indeed.com") + location = (indeed_cfg.get("locations") or ["Zurich"])[0] + keywords = " OR ".join(config.get("keywords") or ["typescript", "react"]) + driver_path = config.get("driver_path", "/usr/local/bin/geckodriver") + + search_url = ( + f"{base_url}/jobs?q={keywords.replace(' ', '+')}" + f"&l={location.replace(' ', '+')}&iafilter=1" + ) + + existing = {} + if SELECTORS_PATH.exists(): + existing = json.loads(SELECTORS_PATH.read_text(encoding="utf-8")) + + firefox_service = FirefoxService(executable_path=driver_path) + driver = webdriver.Firefox(service=firefox_service) + discovered = {"steps": [], "classHints": []} + + try: + print(f"Opening {search_url}") + driver.get(search_url) + time.sleep(3) + input( + "Log in to Indeed in the browser if needed, then press Enter to capture " + "search-page selectors..." + ) + + search_snapshot = { + "url": driver.current_url, + "title": driver.title, + "elements": collect_interactive(driver), + } + dump_step(driver, "search", search_snapshot) + discovered["steps"].append({"name": "search", **search_snapshot}) + + # Collect class hints containing job_ / ia- + for el in search_snapshot["elements"]: + cls = el.get("class") or "" + for token in cls.split(): + if token.startswith(("ia-", "job_", "css-")) and token not in discovered["classHints"]: + discovered["classHints"].append(token) + + print( + "Click a job with 'Easily apply' / 'Einfach bewerben', open the apply flow, " + "then press Enter to capture the apply wizard DOM." + ) + input("Press Enter when the apply form/wizard is visible...") + + apply_snapshot = { + "url": driver.current_url, + "title": driver.title, + "window_handles": len(driver.window_handles), + "elements": collect_interactive(driver), + } + # If a new tab opened, switch and capture there too + if len(driver.window_handles) > 1: + driver.switch_to.window(driver.window_handles[-1]) + time.sleep(1) + apply_snapshot["apply_tab"] = { + "url": driver.current_url, + "elements": collect_interactive(driver), + } + dump_step(driver, "apply", apply_snapshot) + discovered["steps"].append({"name": "apply", **apply_snapshot}) + + for el in apply_snapshot.get("elements", []) + ( + apply_snapshot.get("apply_tab", {}).get("elements") or [] + ): + cls = el.get("class") or "" + for token in cls.split(): + if "ia-" in token and token not in discovered["classHints"]: + discovered["classHints"].append(token) + + merged = merge_selectors(existing, discovered) + SELECTORS_PATH.write_text(json.dumps(merged, indent=2, ensure_ascii=False) + "\n") + print(f"Updated {SELECTORS_PATH}") + print(f"Class hints: {discovered['classHints'][:40]}") + finally: + input("Press Enter to close the browser...") + driver.quit() + + +if __name__ == "__main__": + main() diff --git a/indeed_bot.py b/indeed_bot.py new file mode 100644 index 0000000..a6074bb --- /dev/null +++ b/indeed_bot.py @@ -0,0 +1,841 @@ +"""Indeed Apply (Easily apply) automation for ch.indeed.com.""" + +from __future__ import annotations + +import json +import random +import time +import urllib.parse +from pathlib import Path + +from selenium.common.exceptions import ( + ElementClickInterceptedException, + NoSuchElementException, + StaleElementReferenceException, + TimeoutException, +) +from selenium.webdriver.common.by import By +from selenium.webdriver.common.keys import Keys +from selenium.webdriver.support import expected_conditions as EC +from selenium.webdriver.support.ui import Select, WebDriverWait + +from answer_engine import detect_application_language, resolve_resume_path +from base_easy_apply import BaseEasyApply + +SELECTORS_PATH = Path("indeed_selectors.json") + + +def _human_delay(a=1.2, b=2.8): + time.sleep(random.uniform(a, b)) + + +class EasyApplyIndeed(BaseEasyApply): + def __init__(self, data): + indeed_cfg = data.get("indeed") or {} + # Prefer Indeed-specific locations/credentials when present + merged = dict(data) + if indeed_cfg.get("email"): + merged["email"] = indeed_cfg["email"] + if indeed_cfg.get("password"): + merged["password"] = indeed_cfg["password"] + + super().__init__(merged, start_driver=True) + + self.indeed_cfg = indeed_cfg + self.base_url = (indeed_cfg.get("baseUrl") or "https://ch.indeed.com").rstrip("/") + self.locations = indeed_cfg.get("locations") or ["Zurich"] + self.indeed_filters = indeed_cfg.get("filters") or {} + self.current_location_index = 0 + self.search_window = None + self.selectors = self._load_selectors() + + # Ensure Indeed answer bucket exists + if "Indeed" not in self.context_data["user_inputs"]: + self.context_data["user_inputs"]["Indeed"] = {} + + def _load_selectors(self): + if SELECTORS_PATH.exists(): + try: + return json.loads(SELECTORS_PATH.read_text(encoding="utf-8")) + except Exception as e: + self.log_info(f"Could not load indeed_selectors.json: {e}") + return {} + + def _current_answer_bucket(self): + loc = self.locations[self.current_location_index] if self.locations else "Zurich" + return f"Indeed:{loc}" + + # ------------------------------------------------------------------ + # Login / URL + # ------------------------------------------------------------------ + + def login_indeed(self): + try: + self.driver.get(f"{self.base_url}/") + _human_delay(2, 3) + # Try account login page + try: + self.driver.get(f"{self.base_url}/account/login") + _human_delay(1, 2) + except Exception: + pass + + email_sels = (self.selectors.get("login") or {}).get("email") or [ + "input[type='email']", + "input#login-email-input", + ] + password_sels = (self.selectors.get("login") or {}).get("password") or [ + "input[type='password']", + ] + + email_el = self._find_first(email_sels, timeout=8) + if email_el: + email_el.clear() + email_el.send_keys(self.email) + _human_delay(0.5, 1) + # Indeed sometimes has two-step email then password + submit = self._find_first( + (self.selectors.get("login") or {}).get("submit") or ["button[type='submit']"], + timeout=3, + ) + if submit: + try: + submit.click() + _human_delay(1, 2) + except Exception: + pass + + pass_el = self._find_first(password_sels, timeout=8) + if pass_el: + pass_el.clear() + pass_el.send_keys(self.password) + pass_el.send_keys(Keys.RETURN) + _human_delay(2, 4) + + if self._looks_like_captcha(): + self.handle_captcha() + + self.log_info("Indeed login step finished (verify manually if prompted).") + input("If login/CAPTCHA is complete, press Enter to continue job search...") + self.search_window = self.driver.current_window_handle + except Exception as e: + self.log_error(f"Indeed login error: {e}") + input("Resolve login manually in the browser, then press Enter...") + self.search_window = self.driver.current_window_handle + + def construct_url(self, start=0): + location = self.locations[self.current_location_index] + # Indeed uses spaces / OR differently; join keywords with OR for boolean + q_parts = list(self.keywords_list) + if self.keywords_to_avoid_list: + # Indeed supports -term exclusion + q_parts += [f"-{k}" for k in self.keywords_to_avoid_list] + query = " OR ".join(q_parts) if len(self.keywords_list) > 1 else (self.keywords_list[0] if self.keywords_list else "") + + params = { + "q": query, + "l": location, + } + if self.indeed_filters.get("easyApplyOnly", True): + params["iafilter"] = "1" + fromage = self.indeed_filters.get("fromage") + if fromage: + params["fromage"] = str(fromage) + if self.indeed_filters.get("remotejob"): + params["remotejob"] = "1" + if start: + params["start"] = str(start) + + return f"{self.base_url}/jobs?{urllib.parse.urlencode(params)}" + + # ------------------------------------------------------------------ + # Search / apply loop + # ------------------------------------------------------------------ + + def find_offers(self): + self.apply_filtered_jobs() + + def apply_filtered_jobs(self): + while self.current_location_index < len(self.locations): + start = 0 + empty_pages = 0 + while empty_pages < 2: + url = self.construct_url(start=start) + self.log_info(f"Opening Indeed search: {url}") + self._focus_search_window() + self.driver.get(url) + _human_delay(3, 5) + + if self._looks_like_captcha(): + self.handle_captcha() + + if self.check_no_results(): + self.log_info( + f"No matching Indeed jobs in {self.locations[self.current_location_index]}." + ) + empty_pages += 1 + break + + cards = self._get_job_cards() + if not cards: + self.log_info("No job cards found on page.") + empty_pages += 1 + start += 10 + continue + + empty_pages = 0 + for index in range(len(cards)): + try: + self._focus_search_window() + cards = self._get_job_cards() + if index >= len(cards): + break + card = cards[index] + self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", card) + _human_delay(0.8, 1.5) + + if not self._card_has_easily_apply(card): + self.log_info("Skipping job without Easily apply badge.") + continue + + company = self.get_company_name(card) + job_key = self._card_job_key(card) + applied_id = company or (f"indeed:{job_key}" if job_key else None) + if applied_id and applied_id in self.applied_companies: + self.log_info(f"Already applied at {applied_id}, skipping...") + continue + + try: + link = self._find_in(card, (self.selectors.get("search") or {}).get("jobLink") or []) + if link: + self.driver.execute_script("arguments[0].click();", link) + else: + self.driver.execute_script("arguments[0].click();", card) + except ElementClickInterceptedException: + continue + _human_delay(1.5, 2.5) + + if not self.is_indeed_apply(): + self.log_info("Not an Indeed Apply listing (external ATS), skipping...") + self.close_application(try_exit=False) + continue + + try: + self.handle_indeed_apply() + self.log_applied_company( + applied_id or f"indeed-job-{self.current_location_index}-{start}-{index}" + ) + except Exception as e: + self.log_info(f"Failed Indeed apply at {company}: {e}") + self.log_failed_application(applied_id or company or "unknown") + self.close_application() + except (StaleElementReferenceException, NoSuchElementException) as e: + self.log_info(f"Card interaction error: {e}") + continue + except Exception as e: + self.log_error(f"Indeed apply loop error: {e}") + self.close_application() + continue + + start += 10 + if not self._has_next_page(): + break + + self.current_location_index += 1 + + def check_no_results(self): + for sel in (self.selectors.get("search") or {}).get("noResults") or []: + try: + el = self.driver.find_element(By.CSS_SELECTOR, sel) + if el.is_displayed(): + return True + except NoSuchElementException: + continue + # Heuristic: zero job cards + return len(self._get_job_cards()) == 0 and "jobs" in self.driver.current_url + + def _get_job_cards(self): + for sel in (self.selectors.get("search") or {}).get("jobCard") or [ + "div.job_seen_beacon", + "div[data-jk]", + ]: + try: + cards = self.driver.find_elements(By.CSS_SELECTOR, sel) + if cards: + return cards + except Exception: + continue + return [] + + def _card_has_easily_apply(self, card): + texts = (self.selectors.get("search") or {}).get("easilyApplyBadgeTexts") or [ + "Easily apply", + "Einfach bewerben", + ] + try: + body = (card.text or "").lower() + return any(t.lower() in body for t in texts) + except Exception: + return False + + def _card_job_key(self, card): + attr = (self.selectors.get("search") or {}).get("jobKeyAttribute") or "data-jk" + try: + key = (card.get_attribute(attr) or "").strip() + if key: + return key + except Exception: + pass + try: + el = card.find_element(By.CSS_SELECTOR, f"[{attr}]") + return (el.get_attribute(attr) or "").strip() or None + except Exception: + return None + + def get_company_name(self, job_item): + for sel in (self.selectors.get("search") or {}).get("companyName") or [ + "[data-testid='company-name']", + "span.companyName", + ]: + try: + el = job_item.find_element(By.CSS_SELECTOR, sel) + name = (el.text or "").strip() + if name: + return name + except NoSuchElementException: + continue + return None + + def is_indeed_apply(self): + """Return True when Apply with Indeed widget/button is present (not external ATS).""" + apply_cfg = self.selectors.get("apply") or {} + for sel in apply_cfg.get("applyWidget") or [ + "span[data-testid='indeed-apply-widget']", + "#indeedApplyButton", + ".jobsearch-IndeedApplyButton", + ]: + try: + el = self.driver.find_element(By.CSS_SELECTOR, sel) + if el.is_displayed() or sel.startswith("span"): + return True + except Exception: + continue + page = (self.driver.page_source or "")[:50000].lower() + if "indeedapplybutton" in page or "indeed-apply-widget" in page or "ia-basepage" in page: + return True + if "indeed.com" in (self.driver.current_url or ""): + btn = self._find_button_by_texts( + apply_cfg.get("applyButtonTexts") or ["Apply with Indeed", "Apply", "Bewerben"] + ) + return btn is not None + return False + + # ------------------------------------------------------------------ + # Apply wizard + # ------------------------------------------------------------------ + + def handle_indeed_apply(self): + original_handles = list(self.driver.window_handles) + apply_cfg = self.selectors.get("apply") or {} + # Prefer stable IDs from live CH DOM + apply_btn = self._find_first( + apply_cfg.get("applyButton") or [ + "#indeedApplyButton", + "button[data-testid='indeedApplyButton-test']", + ], + timeout=5, + ) + if apply_btn is None: + apply_btn = self._find_button_by_texts( + apply_cfg.get("applyButtonTexts") or [ + "Apply with Indeed", + "Apply now", + "Apply", + "Bewerben", + ] + ) + if apply_btn is None: + raise NoSuchElementException("Indeed Apply button not found") + + try: + self.driver.execute_script("arguments[0].click();", apply_btn) + except Exception: + apply_btn.click() + _human_delay(2, 3.5) + + # Switch to new tab if opened ("opens in a new tab") + WebDriverWait(self.driver, 8).until( + lambda d: len(d.window_handles) >= len(original_handles) + ) + if len(self.driver.window_handles) > len(original_handles): + self.driver.switch_to.window(self.driver.window_handles[-1]) + _human_delay(1, 2) + + if "indeed.com" not in (self.driver.current_url or ""): + raise RuntimeError(f"Redirected off Indeed: {self.driver.current_url}") + + # Wait for apply wizard shell (resume step or continue) before filling + wizard_ready = self._find_first_any( + (apply_cfg.get("modalOrContainer") or []) + + (apply_cfg.get("resume") or {}).get("form", []) + + (apply_cfg.get("continueButton") or []) + + [ + ".ia-BasePage", + "form[data-testid='resume-selection-form']", + "button[data-testid='continue-button']", + ], + require_displayed=False, + timeout=12, + ) + if wizard_ready is None: + self.log_info("Apply wizard shell not detected quickly; continuing anyway.") + + steps = 0 + max_steps = 20 + while steps < max_steps: + steps += 1 + _human_delay(1, 2) + self.fill_indeed_form() + + if self._click_submit_if_present(): + self.log_info("Indeed application submitted.") + _human_delay(1.5, 2.5) + self.close_application(try_exit=False) + return + + if not self._click_continue(): + if self._application_complete(): + self.log_info("Indeed application appears complete.") + self.close_application(try_exit=False) + return + raise RuntimeError("Could not find Continue/Submit on Indeed apply wizard") + + raise RuntimeError("Indeed apply wizard exceeded max steps") + + def fill_indeed_form(self): + # Resume selection step (mosaic module) — handle before generic questions + if self._handle_resume_selection_step(): + return + + question_sels = (self.selectors.get("apply") or {}).get("questionItem") or [ + ".ia-Questions-item", + "[class*='ia-Questions-item']", + ] + questions = [] + for sel in question_sels: + try: + questions = self.driver.find_elements(By.CSS_SELECTOR, sel) + if questions: + break + except Exception: + continue + + if not questions: + self._fill_loose_fields() + return + + for item in questions: + try: + label_text = self._question_label(item) + if not label_text: + continue + + file_inputs = item.find_elements(By.CSS_SELECTOR, "input[type='file']") + if file_inputs: + path = self.get_file_response_for_label(label_text) + file_inputs[0].send_keys(path) + _human_delay(0.5, 1) + continue + + selects = item.find_elements(By.CSS_SELECTOR, "select") + if selects: + options = [ + o.text.strip() + for o in selects[0].find_elements(By.TAG_NAME, "option") + if o.text.strip() and o.get_attribute("value") not in ("", "-1", "Select") + ] + if options: + answer = self.get_radio_response_for_label(label_text, options) + Select(selects[0]).select_by_visible_text(answer) + continue + + radios = item.find_elements(By.CSS_SELECTOR, "input[type='radio']") + if radios: + options = [] + for radio in radios: + opt = self._radio_label(radio) + if opt: + options.append(opt) + if options: + answer = self.get_radio_response_for_label(label_text, options) + for radio in radios: + if self._radio_label(radio).lower() == str(answer).lower(): + self.driver.execute_script("arguments[0].click();", radio) + break + continue + + checkboxes = item.find_elements(By.CSS_SELECTOR, "input[type='checkbox']") + if checkboxes: + for cb in checkboxes: + cb_label = self._radio_label(cb) or label_text + response = self.get_checkbox_response_for_label(cb_label) + if response and not cb.is_selected(): + self.driver.execute_script("arguments[0].click();", cb) + elif not response and cb.is_selected(): + self.driver.execute_script("arguments[0].click();", cb) + continue + + text_fields = item.find_elements( + By.CSS_SELECTOR, "textarea, input[type='text'], input:not([type])" + ) + if text_fields: + field = text_fields[0] + if (field.get_attribute("value") or "").strip(): + continue + answer = self.get_response_for_label(label_text) + field.clear() + field.send_keys(str(answer)) + continue + except StaleElementReferenceException: + continue + except Exception as e: + self.log_info(f"Indeed form field error: {e}") + continue + + def _desired_resume_path(self): + app_text = self._gather_application_text("resume") + lang = detect_application_language(app_text) + return resolve_resume_path(self.context_data, lang) + + def _configured_resume_names(self): + names = [] + resumes = self.context_data.get("resumes") or {} + for key in ("en", "de"): + path = resumes.get(key) + if path: + names.append(Path(path).name) + return names + + def _handle_resume_selection_step(self): + """Handle mosaic resume-selection form from live CH DOM. Returns True if handled.""" + resume_cfg = ((self.selectors.get("apply") or {}).get("resume") or {}) + form_sels = resume_cfg.get("form") or ["form[data-testid='resume-selection-form']"] + form = None + for sel in form_sels: + try: + form = self.driver.find_element(By.CSS_SELECTOR, sel) + if form: + break + except Exception: + continue + if form is None: + return False + + desired = self._desired_resume_path() + desired_name = Path(desired).name if desired else None + configured_names = {n.lower() for n in self._configured_resume_names()} + + # Prefer already-uploaded resume only when filename matches the desired CV + label_sels = resume_cfg.get("existingLabel") or [ + "label[data-testid='resume-selection-file-resume-radio-card-label']" + ] + radio_sels = resume_cfg.get("existingRadio") or [ + "input[data-testid='resume-selection-file-resume-radio-card-input']" + ] + matched_existing = False + for sel in label_sels: + try: + label = self.driver.find_element(By.CSS_SELECTOR, sel) + label_l = (label.text or "").strip().lower() + if desired_name and desired_name.lower() in label_l: + matched_existing = True + break + # Other profile CV (wrong language / stale file) → upload desired below + if any(name in label_l for name in configured_names): + break + except Exception: + continue + + if matched_existing: + for sel in radio_sels: + try: + radio = self.driver.find_element(By.CSS_SELECTOR, sel) + if not radio.is_selected(): + self.driver.execute_script("arguments[0].click();", radio) + self.log_info(f"Using existing Indeed resume selection ({desired_name}).") + return True + except Exception: + continue + return True + + # Upload configured local CV via hidden file input + if not desired: + self.log_info("No configured resume path; leaving Indeed resume selection as-is.") + return True + + file_sels = resume_cfg.get("fileInput") or [ + "input[data-testid='resume-selection-file-resume-radio-card-file-input']" + ] + file_input = self._find_first_any(file_sels, require_displayed=False, timeout=3) + + if file_input is None: + # Open Resume options -> Upload a different file + for sel in resume_cfg.get("optionsMenu") or ["button[data-testid='ResumeOptionsMenu']"]: + try: + self.driver.find_element(By.CSS_SELECTOR, sel).click() + _human_delay(0.4, 0.8) + break + except Exception: + continue + for sel in resume_cfg.get("uploadDifferent") or [ + "button[data-testid='ResumeOptionsMenu-upload']" + ]: + try: + self.driver.find_element(By.CSS_SELECTOR, sel).click() + _human_delay(0.4, 0.8) + break + except Exception: + continue + file_input = self._find_first_any(file_sels, require_displayed=False, timeout=3) + + if file_input is None: + # Last resort: any file input in form + try: + file_input = form.find_element(By.CSS_SELECTOR, "input[type='file']") + except Exception: + file_input = None + + if file_input is not None: + self.log_info(f"Uploading resume to Indeed: {desired}") + file_input.send_keys(desired) + _human_delay(1, 2) + return True + + self.log_info("Resume selection form found but no file input; continuing with profile default.") + return True + + def _fill_loose_fields(self): + """Fallback when question items are not found — fill empty inputs / file.""" + if self._handle_resume_selection_step(): + return + try: + for file_input in self.driver.find_elements(By.CSS_SELECTOR, "input[type='file']"): + path = self.get_file_response_for_label("Resume / Lebenslauf") + file_input.send_keys(path) + _human_delay(0.5, 1) + break + except Exception: + pass + + def _question_label(self, item): + for sel in ["label", "legend", "[class*='Question']", "span", "div"]: + try: + els = item.find_elements(By.CSS_SELECTOR, sel) + for el in els: + text = (el.text or "").strip() + if text and len(text) < 500: + return text.split("\n")[0].strip() + except Exception: + continue + return (item.text or "").split("\n")[0].strip() + + def _radio_label(self, radio): + try: + rid = radio.get_attribute("id") + if rid: + lab = self.driver.find_element(By.CSS_SELECTOR, f"label[for='{rid}']") + return (lab.text or "").strip() + except Exception: + pass + try: + return radio.find_element(By.XPATH, "./following-sibling::label").text.strip() + except Exception: + return (radio.get_attribute("value") or "").strip() + + def _click_continue(self): + # Prefer stable data-testid; ignore duplicate hp-continue-button-* stubs + btn = self._find_first( + (self.selectors.get("apply") or {}).get("continueButton") or [ + "button[data-testid='continue-button']", + "button.ia-continueButton", + ], + timeout=3, + ) + if btn is None: + texts = (self.selectors.get("apply") or {}).get("continueButtonTexts") or [ + "Continue", + "Weiter", + ] + btn = self._find_button_by_texts(texts) + # Avoid clicking hp-continue-button-* duplicates when possible + if btn is not None: + testid = (btn.get_attribute("data-testid") or "") + if testid.startswith("hp-continue-button"): + prefer = self._find_first( + ["button[data-testid='continue-button']"], timeout=1 + ) + if prefer is not None: + btn = prefer + if btn is None: + return False + try: + self.driver.execute_script("arguments[0].click();", btn) + except Exception: + try: + btn.click() + except Exception: + return False + _human_delay(1.5, 2.5) + return True + + def _click_submit_if_present(self): + texts = (self.selectors.get("apply") or {}).get("submitButtonTexts") or [ + "Submit", + "Submit your application", + "Absenden", + "Bewerbung absenden", + ] + btn = self._find_button_by_texts(texts) + if btn is None: + return False + label = ( + (btn.text or "") + + " " + + (btn.get_attribute("aria-label") or "") + + " " + + (btn.get_attribute("value") or "") + ).strip().lower() + # Avoid treating generic "Continue" as submit unless text matches submit list + submit_l = [t.lower() for t in texts] + if not any(t in label for t in submit_l): + return False + # Never submit via continue stubs + testid = (btn.get_attribute("data-testid") or "") + if testid in ("continue-button",) or testid.startswith("hp-continue-button"): + return False + try: + self.driver.execute_script("arguments[0].click();", btn) + except Exception: + btn.click() + return True + + def _application_complete(self): + body = (self.driver.page_source or "").lower() + markers = [ + "application submitted", + "bewerbung gesendet", + "your application has been submitted", + "danke für ihre bewerbung", + "thank you for applying", + ] + return any(m in body for m in markers) + + def close_application(self, try_exit=True): + try: + # Prefer Save and close when discarding an in-progress wizard + if try_exit: + exit_sels = (self.selectors.get("apply") or {}).get("exitButton") or ( + self.selectors.get("apply") or {} + ).get("closeButton") or [] + for sel in exit_sels: + try: + el = self.driver.find_element(By.CSS_SELECTOR, sel) + if el.is_displayed(): + self.driver.execute_script("arguments[0].click();", el) + _human_delay(0.8, 1.5) + break + except Exception: + continue + + if len(self.driver.window_handles) > 1: + self.driver.close() + remaining = self.driver.window_handles + target = self.search_window if self.search_window in remaining else remaining[0] + self.driver.switch_to.window(target) + except Exception as e: + self.log_info(f"close_application: {e}") + finally: + self._focus_search_window() + + def _focus_search_window(self): + try: + if self.search_window and self.search_window in self.driver.window_handles: + self.driver.switch_to.window(self.search_window) + elif self.driver.window_handles: + self.driver.switch_to.window(self.driver.window_handles[0]) + self.search_window = self.driver.current_window_handle + except Exception: + pass + + def _has_next_page(self): + for sel in (self.selectors.get("search") or {}).get("paginationNext") or []: + try: + el = self.driver.find_element(By.CSS_SELECTOR, sel) + if el.is_displayed() and el.get_attribute("aria-disabled") != "true": + return True + except NoSuchElementException: + continue + return False + + def _gather_application_text(self, label_text=""): + chunks = [label_text or ""] + try: + chunks.append((self.driver.title or "")) + chunks.append((self.driver.page_source or "")[:3000]) + except Exception: + pass + return "\n".join(chunks) + + # ------------------------------------------------------------------ + # Element helpers + # ------------------------------------------------------------------ + + def _find_first(self, selectors, timeout=5): + return self._find_first_any(selectors, require_displayed=True, timeout=timeout) + + def _find_first_any(self, selectors, require_displayed=True, timeout=5): + end = time.time() + timeout + while time.time() < end: + for sel in selectors: + try: + el = self.driver.find_element(By.CSS_SELECTOR, sel) + if not require_displayed or el.is_displayed(): + return el + except Exception: + continue + time.sleep(0.3) + return None + + def _find_in(self, root, selectors): + for sel in selectors: + try: + return root.find_element(By.CSS_SELECTOR, sel) + except Exception: + continue + return None + + def _find_button_by_texts(self, texts): + lowered = [t.lower() for t in texts] + try: + buttons = self.driver.find_elements(By.CSS_SELECTOR, "button, a[role='button'], input[type='submit']") + except Exception: + return None + for btn in buttons: + try: + if not btn.is_displayed(): + continue + label = ((btn.text or "") + " " + (btn.get_attribute("aria-label") or "")).strip().lower() + value = (btn.get_attribute("value") or "").lower() + combined = f"{label} {value}" + if any(t in combined for t in lowered): + return btn + except StaleElementReferenceException: + continue + return None + + def _looks_like_captcha(self): + src = (self.driver.page_source or "").lower() + return any( + token in src + for token in ("captcha", "cf-challenge", "challenge-platform", "verify you are human") + ) diff --git a/indeed_selectors.json b/indeed_selectors.json new file mode 100644 index 0000000..31758a3 --- /dev/null +++ b/indeed_selectors.json @@ -0,0 +1,2138 @@ +{ + "version": 2, + "domain": "ch.indeed.com", + "notes": "Updated from live ch.indeed.com DOM (Apply with Indeed, data-testid continue/resume). Prefer IDs/data-testid over hashed css-* classes.", + "search": { + "baseUrl": "https://ch.indeed.com/jobs", + "easyApplyParam": "iafilter", + "easyApplyValue": "1", + "jobCard": [ + "div.job_seen_beacon", + "li[data-jk]", + "div[data-jk]" + ], + "jobLink": [ + "a[href*='/viewjob?jk=']", + "a[data-jk]", + "h2.jobTitle a", + "a.jcs-JobTitle" + ], + "jobKeyAttribute": "data-jk", + "companyName": [ + "[data-testid='company-name']", + "span.companyName", + "span[data-testid='company-name']", + ".companyName" + ], + "easilyApplyBadgeTexts": [ + "Easily apply", + "Einfach bewerben", + "Postuler simplement", + "Candidatura semplice" + ], + "searchForm": { + "whatInput": [ + "#text-input-what", + "input[name='q']" + ], + "whereInput": [ + "#text-input-where", + "input[name='l']" + ], + "form": [ + "#jobsearch", + "form.yosegi-InlineWhatWhere-form" + ], + "submit": [ + "button.yosegi-InlineWhatWhere-primaryButton", + "#jobsearch button[type='submit']" + ] + }, + "paginationNext": [ + "a[data-testid='pagination-page-next']", + "a[aria-label='Next Page']", + "a[aria-label='Nächste']", + "nav[role='navigation'] a[aria-label*='Next']" + ], + "noResults": [ + ".jobsearch-NoResult-messageContainer", + "[data-testid='no-results-message']" + ] + }, + "apply": { + "applyButtonTexts": [ + "Apply with Indeed", + "Apply now", + "Apply", + "Bewerben", + "Jetzt bewerben", + "Einfach bewerben", + "Postuler", + "Candidati" + ], + "applyButton": [ + "#indeedApplyButton", + "button[data-testid='indeedApplyButton-test']", + "span[data-testid='indeed-apply-widget'] button", + ".jobsearch-IndeedApplyButton button", + ".ia-IndeedApplyButton button", + "button[aria-label*='Apply with Indeed']" + ], + "applyWidget": [ + "span[data-testid='indeed-apply-widget']", + ".indeed-apply-widget", + ".ia-IndeedApplyButton", + ".jobsearch-IndeedApplyButton" + ], + "jobKeyAttribute": "data-indeed-apply-jk", + "continueButton": [ + "button[data-testid='continue-button']", + "button.ia-continueButton", + "[class*='ia-continueButton']" + ], + "continueButtonTexts": [ + "Continue", + "Weiter", + "Continuer", + "Continua", + "Next" + ], + "submitButtonTexts": [ + "Submit", + "Submit your application", + "Absenden", + "Bewerbung absenden", + "Envoyer", + "Invia" + ], + "questionItem": [ + ".ia-Questions-item", + "[class*='ia-Questions-item']", + "[data-testid='ia-Questions-item']" + ], + "textInput": [ + ".ia-Answer-input input[type='text']", + ".ia-Answer-input textarea", + "input[type='text']", + "textarea" + ], + "fileInput": [ + "input[data-testid='resume-selection-file-resume-radio-card-file-input']", + "input[type='file']" + ], + "resume": { + "form": [ + "form[data-testid='resume-selection-form']" + ], + "heading": [ + "#resume-selection-resume-selection-heading", + "h1" + ], + "existingRadio": [ + "input[data-testid='resume-selection-file-resume-radio-card-input']" + ], + "existingLabel": [ + "label[data-testid='resume-selection-file-resume-radio-card-label']" + ], + "fileInput": [ + "input[data-testid='resume-selection-file-resume-radio-card-file-input']" + ], + "uploadDifferent": [ + "button[data-testid='ResumeOptionsMenu-upload']" + ], + "selectFile": [ + "button[data-testid='resume-selection-file-resume-radio-card-button']" + ], + "optionsMenu": [ + "button[data-testid='ResumeOptionsMenu']" + ] + }, + "modalOrContainer": [ + ".ia-BasePage", + ".ia-JobHeader", + "#ia-JobHeader-title", + "[class*='ia-']" + ], + "exitButton": [ + "button[data-testid='ExitLinkWithModalComponent-exitButton']" + ], + "closeButton": [ + "button[data-testid='ExitLinkWithModalComponent-exitButton']", + "button[aria-label='Close']", + "button[aria-label='Schliessen']", + "button[aria-label='Fermer']" + ] + }, + "login": { + "email": [ + "input[type='email']", + "input#login-email-input", + "input[name='__email']" + ], + "password": [ + "input[type='password']", + "input#login-password-input", + "input[name='__password']" + ], + "submit": [ + "button[type='submit']", + "button[data-testid='login-submit']" + ] + }, + "discoveredAt": "2026-08-11T17:52:23.875165", + "liveSnapshots": { + "steps": [ + { + "name": "search", + "url": "https://ch.indeed.com/", + "title": "Job Search | Indeed", + "elements": [ + { + "tag": "button", + "id": "", + "class": "gnav-header-1ue33bt e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "", + "data_jk": "", + "aria_label": "", + "href": "", + "text": "Skip to main content" + }, + { + "tag": "button", + "id": "AccountMenu", + "class": "gnav-header-1baprrs e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "", + "data_jk": "", + "aria_label": "Account", + "href": "", + "text": "" + }, + { + "tag": "button", + "id": "", + "class": "gnav-header-16y3sf1 e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "", + "data_jk": "", + "aria_label": "Primary navigation", + "href": "", + "text": "" + }, + { + "tag": "button", + "id": "", + "class": "gnav-header-1j2vlm6 e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "", + "data_jk": "", + "aria_label": "Close", + "href": "", + "text": "" + }, + { + "tag": "button", + "id": "", + "class": "css-1wsv9po e8ju0x50", + "name": "", + "type": "reset", + "data_testid": "", + "data_jk": "", + "aria_label": "Clear location input", + "href": "", + "text": "" + }, + { + "tag": "button", + "id": "", + "class": "yosegi-InlineWhatWhere-primaryButton css-17qy6hn e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "", + "data_jk": "", + "aria_label": "", + "href": "", + "text": "Find jobs" + }, + { + "tag": "button", + "id": "", + "class": "css-e1dhv1 e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "Pay-preference-button", + "data_jk": "", + "aria_label": "add pay preference", + "href": "", + "text": "Add pay" + }, + { + "tag": "button", + "id": "", + "class": "bookmark bookmark-tap-target mosaic-provider-jobcards-ykqx5t e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "", + "data_jk": "", + "aria_label": "Save job Toggle", + "href": "", + "text": "" + }, + { + "tag": "button", + "id": "", + "class": "dislike-tap-target mosaic-provider-jobcards-1pc3wcg e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "dislikeicon", + "data_jk": "", + "aria_label": "Not interested", + "href": "", + "text": "" + }, + { + "tag": "button", + "id": "", + "class": "bookmark bookmark-tap-target mosaic-provider-jobcards-ykqx5t e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "", + "data_jk": "", + "aria_label": "Save job Toggle", + "href": "", + "text": "" + }, + { + "tag": "button", + "id": "", + "class": "dislike-tap-target mosaic-provider-jobcards-1pc3wcg e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "dislikeicon", + "data_jk": "", + "aria_label": "Not interested", + "href": "", + "text": "" + }, + { + "tag": "button", + "id": "", + "class": "bookmark bookmark-tap-target mosaic-provider-jobcards-ykqx5t e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "", + "data_jk": "", + "aria_label": "Save job Toggle", + "href": "", + "text": "" + }, + { + "tag": "button", + "id": "", + "class": "dislike-tap-target mosaic-provider-jobcards-1pc3wcg e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "dislikeicon", + "data_jk": "", + "aria_label": "Not interested", + "href": "", + "text": "" + }, + { + "tag": "button", + "id": "", + "class": "bookmark bookmark-tap-target mosaic-provider-jobcards-ykqx5t e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "", + "data_jk": "", + "aria_label": "Save job Toggle", + "href": "", + "text": "" + }, + { + "tag": "button", + "id": "", + "class": "dislike-tap-target mosaic-provider-jobcards-1pc3wcg e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "dislikeicon", + "data_jk": "", + "aria_label": "Not interested", + "href": "", + "text": "" + }, + { + "tag": "button", + "id": "", + "class": "bookmark bookmark-tap-target mosaic-provider-jobcards-ykqx5t e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "", + "data_jk": "", + "aria_label": "Save job Toggle", + "href": "", + "text": "" + }, + { + "tag": "button", + "id": "", + "class": "dislike-tap-target mosaic-provider-jobcards-1pc3wcg e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "dislikeicon", + "data_jk": "", + "aria_label": "Not interested", + "href": "", + "text": "" + }, + { + "tag": "button", + "id": "", + "class": "bookmark bookmark-tap-target mosaic-provider-jobcards-ykqx5t e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "", + "data_jk": "", + "aria_label": "Save job Toggle", + "href": "", + "text": "" + }, + { + "tag": "button", + "id": "", + "class": "dislike-tap-target mosaic-provider-jobcards-1pc3wcg e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "dislikeicon", + "data_jk": "", + "aria_label": "Not interested", + "href": "", + "text": "" + }, + { + "tag": "button", + "id": "", + "class": "bookmark bookmark-tap-target mosaic-provider-jobcards-ykqx5t e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "", + "data_jk": "", + "aria_label": "Save job Toggle", + "href": "", + "text": "" + }, + { + "tag": "button", + "id": "", + "class": "dislike-tap-target mosaic-provider-jobcards-1pc3wcg e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "dislikeicon", + "data_jk": "", + "aria_label": "Not interested", + "href": "", + "text": "" + }, + { + "tag": "button", + "id": "", + "class": "bookmark bookmark-tap-target mosaic-provider-jobcards-ykqx5t e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "", + "data_jk": "", + "aria_label": "Save job Toggle", + "href": "", + "text": "" + }, + { + "tag": "button", + "id": "", + "class": "dislike-tap-target mosaic-provider-jobcards-1pc3wcg e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "dislikeicon", + "data_jk": "", + "aria_label": "Not interested", + "href": "", + "text": "" + }, + { + "tag": "button", + "id": "", + "class": "bookmark bookmark-tap-target mosaic-provider-jobcards-ykqx5t e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "", + "data_jk": "", + "aria_label": "Save job Toggle", + "href": "", + "text": "" + }, + { + "tag": "button", + "id": "", + "class": "dislike-tap-target mosaic-provider-jobcards-1pc3wcg e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "dislikeicon", + "data_jk": "", + "aria_label": "Not interested", + "href": "", + "text": "" + }, + { + "tag": "button", + "id": "", + "class": "bookmark bookmark-tap-target mosaic-provider-jobcards-ykqx5t e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "", + "data_jk": "", + "aria_label": "Save job Toggle", + "href": "", + "text": "" + }, + { + "tag": "button", + "id": "", + "class": "dislike-tap-target mosaic-provider-jobcards-1pc3wcg e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "dislikeicon", + "data_jk": "", + "aria_label": "Not interested", + "href": "", + "text": "" + }, + { + "tag": "button", + "id": "", + "class": "bookmark bookmark-tap-target mosaic-provider-jobcards-ykqx5t e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "", + "data_jk": "", + "aria_label": "Save job Toggle", + "href": "", + "text": "" + }, + { + "tag": "button", + "id": "", + "class": "dislike-tap-target mosaic-provider-jobcards-1pc3wcg e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "dislikeicon", + "data_jk": "", + "aria_label": "Not interested", + "href": "", + "text": "" + }, + { + "tag": "button", + "id": "", + "class": "bookmark bookmark-tap-target mosaic-provider-jobcards-ykqx5t e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "", + "data_jk": "", + "aria_label": "Save job Toggle", + "href": "", + "text": "" + }, + { + "tag": "button", + "id": "", + "class": "dislike-tap-target mosaic-provider-jobcards-1pc3wcg e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "dislikeicon", + "data_jk": "", + "aria_label": "Not interested", + "href": "", + "text": "" + }, + { + "tag": "button", + "id": "", + "class": "css-ynm87x e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "", + "data_jk": "", + "aria_label": "", + "href": "", + "text": "Show more jobs" + }, + { + "tag": "button", + "id": "", + "class": "css-1aceri0 e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "", + "data_jk": "", + "aria_label": "Apply on company site (opens in a new tab)", + "href": "https://ch.indeed.com/applystart?jk=fa5f517d6ff8d9ff&from=vj&pos=bottom&mvj=0&jobsearchTk=1jvoobi9fhn3t800&spon=0&xkcb=SoBU67M3g44hkSTBEZ0GbzkdCdPP&vjfrom=mobhp_jobfeed_auto&asub=mob&astse=0aca7b0fc83a8541&assa=4969", + "text": "Apply on company site" + }, + { + "tag": "button", + "id": "", + "class": "css-1jujo9d e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "", + "data_jk": "", + "aria_label": "Save job", + "href": "", + "text": "" + }, + { + "tag": "button", + "id": "", + "class": "css-145xnqb e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "", + "data_jk": "", + "aria_label": "Not interested", + "href": "", + "text": "" + }, + { + "tag": "button", + "id": "", + "class": "css-kr38n4 e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "", + "data_jk": "", + "aria_label": "Share Job", + "href": "", + "text": "" + }, + { + "tag": "button", + "id": "", + "class": "js-match-insights-provider-qb6ap9 e1xnxm2i0", + "name": "", + "type": "submit", + "data_testid": "90-100%-tile", + "data_jk": "", + "aria_label": "Job type 90-100% matching preference", + "href": "", + "text": "90-100%" + }, + { + "tag": "button", + "id": "", + "class": "js-match-insights-provider-qb6ap9 e1xnxm2i0", + "name": "", + "type": "submit", + "data_testid": "100%-tile", + "data_jk": "", + "aria_label": "Job type 100% matching preference", + "href": "", + "text": "100%" + }, + { + "tag": "button", + "id": "", + "class": "mosaic-reportcontent-button desktop mosaic-provider-reportcontent-1hfwffs e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "", + "data_jk": "", + "aria_label": "", + "href": "", + "text": "Report job" + }, + { + "tag": "input", + "id": "text-input-what", + "class": "css-1lonhz0 e1jgz0i3", + "name": "q", + "type": "text", + "data_testid": "", + "data_jk": "", + "aria_label": "search: Job title, keywords, or company", + "href": "", + "text": "" + }, + { + "tag": "input", + "id": "text-input-where", + "class": "css-1rtikjh e1jgz0i3", + "name": "l", + "type": "text", + "data_testid": "", + "data_jk": "", + "aria_label": "Edit location", + "href": "", + "text": "" + }, + { + "tag": "a", + "id": "indeed-globalnav-logo", + "class": "gnav-Logo gnav-header-1nnfo36 e71d0lh0", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "", + "aria_label": "Indeed Home", + "href": "https://ch.indeed.com/?from=gnav-homepage", + "text": "" + }, + { + "tag": "a", + "id": "FindJobs", + "class": "gnav-header-bleyba e71d0lh0", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "", + "aria_label": "Home", + "href": "https://ch.indeed.com/", + "text": "Home" + }, + { + "tag": "a", + "id": "CompanyReviews", + "class": "gnav-header-bleyba e71d0lh0", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "", + "aria_label": "Company reviews", + "href": "https://ch.indeed.com/companies", + "text": "Company reviews" + }, + { + "tag": "a", + "id": "FindSalaries", + "class": "gnav-header-bleyba e71d0lh0", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "", + "aria_label": "Find salaries", + "href": "https://ch.indeed.com/career/salaries", + "text": "Find salaries" + }, + { + "tag": "a", + "id": "", + "class": "gnav-header-d3wicl e71d0lh0", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "", + "aria_label": "My jobs", + "href": "https://myjobs.indeed.com/?from=gnav-util-homepage&gnavTK=1jvoobhv0hc6v802&tk=1jvoobhuj26sv000&co=CH&hl=en", + "text": "" + }, + { + "tag": "a", + "id": "", + "class": "gnav-header-d3wicl e71d0lh0", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "", + "aria_label": "Messages Unread count 0", + "href": "https://messages.indeed.com/?from=gnav-util-homepage&gnavTK=1jvoobhv0hc6v802&tk=1jvoobhuj26sv000&co=CH&hl=en", + "text": "Messages Unread count 0" + }, + { + "tag": "a", + "id": "", + "class": "gnav-header-d3wicl e71d0lh0", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "", + "aria_label": "Notifications Unread count 0", + "href": "https://ch.indeed.com/notifications?from=gnav-util-homepage&gnavTK=1jvoobhv0hc6v802&tk=1jvoobhuj26sv000&hl=en", + "text": "" + }, + { + "tag": "a", + "id": "", + "class": "gnav-header-1pmrdpz e1wnkr790", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "", + "aria_label": "Home", + "href": "https://ch.indeed.com/m/", + "text": "" + }, + { + "tag": "a", + "id": "", + "class": "gnav-header-1pmrdpz e1wnkr790", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "", + "aria_label": "Company reviews", + "href": "https://ch.indeed.com/companies", + "text": "" + }, + { + "tag": "a", + "id": "", + "class": "gnav-header-1pmrdpz e1wnkr790", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "", + "aria_label": "Find salaries", + "href": "https://ch.indeed.com/career/salaries", + "text": "" + }, + { + "tag": "a", + "id": "", + "class": "gnav-header-1pmrdpz e1wnkr790", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "", + "aria_label": "Employers", + "href": "https://ch.indeed.com/hire", + "text": "" + }, + { + "tag": "a", + "id": "", + "class": "gnav-header-1pmrdpz e1wnkr790", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "", + "aria_label": "Profile", + "href": "https://profile.indeed.com/?hl=en_CH&co=CH", + "text": "" + }, + { + "tag": "a", + "id": "", + "class": "gnav-header-1pmrdpz e1wnkr790", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "", + "aria_label": "Reviews", + "href": "https://ch.indeed.com/contributions", + "text": "" + }, + { + "tag": "a", + "id": "", + "class": "gnav-header-1pmrdpz e1wnkr790", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "", + "aria_label": "Settings", + "href": "https://secure.indeed.com/settings?hl=en_CH&co=CH&continue=https%3A%2F%2Fch.indeed.com%2F&tmpl=desktop&service=my&from=gnav-util-homepage", + "text": "" + }, + { + "tag": "a", + "id": "", + "class": "gnav-header-1pmrdpz e1wnkr790", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "", + "aria_label": "Change country: Switzerland", + "href": "https://ch.indeed.com/countries", + "text": "" + }, + { + "tag": "a", + "id": "", + "class": "gnav-header-1pmrdpz e1wnkr790", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "", + "aria_label": "Help", + "href": "https://ch.indeed.com/help/job-seekers?hl=en&co=ch", + "text": "" + }, + { + "tag": "a", + "id": "", + "class": "gnav-header-1pmrdpz e1wnkr790", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "", + "aria_label": "Privacy Center", + "href": "https://hrtechprivacy.com/", + "text": "" + }, + { + "tag": "a", + "id": "", + "class": "gnav-header-1pmrdpz e1wnkr790", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "", + "aria_label": "Terms", + "href": "https://ch.indeed.com/legal?hl=en", + "text": "" + }, + { + "tag": "a", + "id": "", + "class": "gnav-header-1pmrdpz e1wnkr790", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "", + "aria_label": "Accessibility at Indeed", + "href": "https://ch.indeed.com/accessibility?hl=en", + "text": "" + }, + { + "tag": "a", + "id": "", + "class": "gnav-header-1pmrdpz e1wnkr790", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "", + "aria_label": "Online Safety Page", + "href": "https://ch.indeed.com/legal/online-safety-page", + "text": "" + }, + { + "tag": "a", + "id": "", + "class": "gnav-header-1pmrdpz e1wnkr790", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "", + "aria_label": "DSA Reporting", + "href": "https://dsa-reporting-and-appeals.indeed.com/", + "text": "" + }, + { + "tag": "a", + "id": "", + "class": "gnav-header-1pmrdpz e1wnkr790", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "", + "aria_label": "Cookies", + "href": "https://ch.indeed.com/legal/cookies?hl=en&showOneTrustModal=true", + "text": "" + }, + { + "tag": "a", + "id": "", + "class": "gnav-header-1pmrdpz e1wnkr790", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "", + "aria_label": "Sign out: sendmessage@gabo.email", + "href": "https://secure.indeed.com/account/logout?hl=en_CH&co=CH&continue=https%3A%2F%2Fch.indeed.com%2F&tmpl=desktop&from=gnav-util-homepage", + "text": "" + }, + { + "tag": "a", + "id": "EmployersPostJob", + "class": "gnav-header-bleyba e71d0lh0", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "", + "aria_label": "Employers / Post Job", + "href": "https://ch.indeed.com/hire?co=CH&hl=en", + "text": "Employers / Post Job" + }, + { + "tag": "a", + "id": "job_fa5f517d6ff8d9ff", + "class": "jcs-JobTitle css-1baag51 eu4oa1w0", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "fa5f517d6ff8d9ff", + "aria_label": "full details of Software Engineer - Frontend focus", + "href": "https://ch.indeed.com/rc/clk?jk=fa5f517d6ff8d9ff&from=hp.jobsForYou&tk=1jvoobi9fhn3t800&bb=ulWFy1sX1sLVlFd13aU2mVMoRQlqsQLhXnLnQ5AsKm9tIIhXHuaygC1qTJo1g5qkpBzmggUdfMVKj8oZ_sC0wtYwvnSyqgwIzNBK90wWZbj0_QIFGl1dHpS-TA5rjzHFCG61fMAukdaB83XGfhBW0ENLTh1r0Eu7e6oG0VXEptI1z_RozSiFCdHa_4mknMDgLKmv7C51VZ1IVW5A3gAtxQ%3D%3D&xkcb=SoBU67M3g44hkSTBEZ0GbzkdCdPP&mtk=1jvoobhuj26sv000", + "text": "Software Engineer - Frontend focus" + }, + { + "tag": "a", + "id": "sj_ab539e569ae30906", + "class": "jcs-JobTitle css-1baag51 eu4oa1w0", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "ab539e569ae30906", + "aria_label": "full details of Software Architect - Mobile Apps (w/m/d)", + "href": "https://ch.indeed.com/pagead/clk?mo=r&ad=-6NYlbfkN0D4OHe0ibpzH4Ce9jmueuNcI7vAuFqActfaHK4HIFT4kfnm8q5uukDhxx0L04ju9xVrqPeFUD44pd5Jow_1PA4UCfh8RbAJEhmbxgO-wQOuMJPQ8-Q1G5W33fSSMU-WGCKz--KNUTAOVXGb6SnDh4qn0hDDJx06Fb0hZee5Pm615Y_O0jMD_ToqCvK4o-GVZJqG1HUxbQAngYZTdZUoBkaVHjzrvIyInmm5WunIn5X3etCRUEPiSGbVgf77HQFR5jjcCvh9SsHPLY38wSjnFepbWvSdEzEKljn0cgCDPir7geClsBq52kGgopAqDwZOOWulJP1GKqE5jjYxc0dPhQMtaY6NXNFSeMahiKt1Laxvdy3bjIpnH6RgB24CBYYAW3ki-TmoK91YFpdgGQ1yvMjfzcqt3paPMFFz_k9M56aGvYwdaQKBXk2XcI-v4GhuIQzs77Sbt3Pe1pP8sI5dCCxCrKh2Ilu-fz6VJpAFL1vrHQ4B2eFuREQiQ3QNZS5IUgmIgjmhAH2YZ3NwUxnVBsYUVeMtAr_IRk_vxHBjnrpvdtfrbhz-LGpgEjXwDBO9shOkEUplO3TP_wGj73yruhqLEuYHIRsYZvY3VIYIVGzm7lev_k2e6T1zUtBJqVQ3aih9P_nXmSXnzIRRqXI_IX3mqvgwFRMWjLl-I2DNszZp00WV5YiZBR65SlgTHdCyeA3tmk7PIKteRCvUZEdEjGA7L-zFgRT-JE6SnqSy5gin0GtHxObFfox9ludy_6Iaq2JgLZUHP9WS8SKe0jXS91orOYKvz_vD0_MppNcPzZU17xyMcqrKXpBF3i_5VCfF_M2tOQ51ShhSFYQDBDJ2SWrriC0izV-zadiNWPiY2qzm_peAL5APTJONe-7TUuCTgxBahmU8Eu0NruJfgs-Aa3aKb6-fhf6ZhzsY0aHm7CSmkS_OtO-QkGBHZoXMzdQGHDEBTCg9ZYJP2a6_MPDg4zXE9o50seyj4W-7YiQd5L8Q4V1SzDCoy4dlo_Vr9HdqrjM%3D&xkcb=SoAl6_M3g44hkSzBEZ0LbzkdCdPP&jsa=4354&camk=C3EPSzFlQw9kMUZsPKmIKQ%3D%3D&from=hp.jobsForYou&tk=1jvoobi9fhn3t800&vjs=3&mtk=1jvoobhuj26sv000", + "text": "Software Architect - Mobile Apps (w/m/d)" + }, + { + "tag": "a", + "id": "sj_432f99aac2879fe7", + "class": "jcs-JobTitle css-1baag51 eu4oa1w0", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "432f99aac2879fe7", + "aria_label": "full details of Cloud & DevOps Engineer (a) 100% – Ref. 923018", + "href": "https://ch.indeed.com/pagead/clk?mo=r&ad=-6NYlbfkN0ARgjwA5PNIP0mEVZC9C8z286QYPvLwiZJTCwJSzVt9UiJHQlPnNF_ygPoswOtDaorYW2m4is8UPW99gZEo9hcjlmNhlg7szusBNSJ6ifL5pB6K6fqzxplOl5-fKig9prLhPQzbOxgRH7S3_R2mkxl4D-GmKU6AzrcK02Vf3ntRI5WxkjBfPWX91vWtKb2M1jP9nTtt1DpzkJOFSaVKL2elMLFlHxD8UQnDB8pHSZINKLn8SyNpePbaTY7n31s2dSPXokCruuWajvt1FIOt5az2_XuF9Tszafo37RR6_o7YAu50HFnryvIROdoqrE1oZOmIj3O-i8mD1uRU6I5UxRYKMX2gTbHUBhquOMANnCXm_xCOjjA6NEMLoZognnnRMQb-hvDt_SJbjqWAuGxZgp17-VPWU4ZB486Tnf23xqgfgqjPUS_u1OVJyXkSFp97ifEbdmu30hNL0BdXte75lsuKjbWdc7bS8vLDgsHC4po16TGdYUJz4hhlNb1Xs-mnxh6K0ICILF99mkfQw9CYpi6SJYXa0uGBFF-OvkwmYnvAtb3GoUbxE9Y2AiBkWzs0n_fjOAjlBeLIY54f1QiN8PVCRTeDdnkNNwxe-KwIhMWtykRHgH-auZnIBOP5bt_5k1KkIbUq3TFNeX4UKYt4QmDG930gK63VXaNh4PVgUUKADQRyZILs71xBYEe-cuUXtRgeRWQTaAjzCZ2cbOKNXSMmxcqcKPauFc2If5ZrV17TOTw5Yo7sum8U5JPpokrXgSfkrebBuwmCiDegXd93vX13YmNgXPkQDsFA3xa7nU8W-gJelshUCBy7ZyTnw9g3wvR3QpF9TzglJGV_7w95iFbYiWXB5dlM-PIoS1_IopAe6HxQDG19vIyfNrBd4vM1uuI%3D&xkcb=SoCR6_M3g44hkSzBEZ0KbzkdCdPP&jsa=4354&camk=C3EPSzFlQw9BPjnxLJHmDQ%3D%3D&from=hp.jobsForYou&tk=1jvoobi9fhn3t800&vjs=3&mtk=1jvoobhuj26sv000", + "text": "Cloud & DevOps Engineer (a) 100% – Ref. 923018" + }, + { + "tag": "a", + "id": "sj_0ebd727574bf6a7b", + "class": "jcs-JobTitle css-1baag51 eu4oa1w0", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "0ebd727574bf6a7b", + "aria_label": "full details of Cloud & DevOps Engineer (a) 100% – Ref. 923018", + "href": "https://ch.indeed.com/pagead/clk?mo=r&ad=-6NYlbfkN0ARgjwA5PNIP0mEVZC9C8z286QYPvLwiZJTCwJSzVt9UiJHQlPnNF_ygPoswOtDaorYW2m4is8UPVEvLsaFOBH2_hjp4Imb1vSoox4I8YyLEnxXsKptE49ajkrtVXflC1mScopryHJtZr0NAPdQvODweNBQKPgElFGT4qVRui5LoiB0OorLa1tBh8oYaPmBKbw3lqSiCgE1NeV7GTYA1W3haQ2_qq4X4zn8lPjCPEXx2HrwFlSftouN4bdD7JS2bTNq1ibGpe3b8alNZZJpY3QfLfE9MKBYEMl2zlgrBDj0YUnAEhopAKFPYVlo8oSd5WSHIc6vfF6hEXmp7VOvNu7pI7RHn5NiuY_Ya0RAWk9fjzpz6BR8B5Vx8Iyok5iImihHZpWwueI6DOJrORGLi4mqERNEJhyMnyq1-dsiRzxcMQTbOtxwjqoyMx7BoPaRmBuA4v5wAzxhTaBtpD2LJ45uYHmSY_zy5rcFfTivkOchouM3u8d3xptWkmFTCP_e9sF-OQNfLov82qGcJWJLnqIgly7s4XaiLmyAVEhdZNno9QMiBPXRAxG3Ljzg2SdhURhSAenSqoWKhTH9quUoE3EU7My_terVgvOV7qDtNNYWd6Pm9b3mElbghhDW4a6XgExO-Ke7CNCHLaLcWBCTkpxoutvMd2vxwLBe53uYU1bJDtzkqhfmibXLkJWGwrIwlaKl45YWCDXOIFRQV2-ZRydqcdPg8KCyo3ogKnpiAtXwDuqgF1nh-HkxU8besz-U13IFiHYr3v1fXr0o3uZOkiT0FrJc2AxLlUG7rwBrnKqRIXQ8tUsV2F0DdR-vNYn6rv7VPfYe7yRBJwNnJDxJzJC6nubPOqHijTv9mk_kGuDpDFmGufIij7ChzqaNI4DhNXU%3D&xkcb=SoAM6_M3g44hkSzBEZ0JbzkdCdPP&jsa=4354&camk=C3EPSzFlQw9BPjnxLJHmDQ%3D%3D&from=hp.jobsForYou&tk=1jvoobi9fhn3t800&vjs=3&mtk=1jvoobhuj26sv000", + "text": "Cloud & DevOps Engineer (a) 100% – Ref. 923018" + }, + { + "tag": "a", + "id": "sj_02922a2e8a31c93f", + "class": "jcs-JobTitle css-1baag51 eu4oa1w0", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "02922a2e8a31c93f", + "aria_label": "full details of Software Engineer Backend (w/m/d)", + "href": "https://ch.indeed.com/pagead/clk?mo=r&ad=-6NYlbfkN0D4OHe0ibpzH4Ce9jmueuNcI7vAuFqActfaHK4HIFT4kfnm8q5uukDhxx0L04ju9xVrqPeFUD44pUUkwTi9l5aBTqY4x24rC7sydGVaUGVcNA4sbiBeBNEeCM8AIqXU92B5brNfbvZaIWMJvnPB2-NcfFPTjS3LES-a5oyrBAahEwPA0GmdIgxbiFprdolEcGtntWrRLWfU-7LB_VyZbjVVBKZew5ICLysTSrN6ZcuGwlmiz8mTbPAHTepWRglBxrpw0c6lgktO-a_4gieEVwjMXZZwc6nothNEWQYJp_MRwrMzBv80yWkay9WwAPzbz4xRJ_Wc3EkGUL5wQBpvCD7U6rxM03qAGcRv_YAXZ68ZalPwi5_7xo1AS8pkSzDF3WMBCKICpWx4bKLUSfzRaF6MxwdNN-0DeiYm-kL9AsuAVEL_9gyxqXvgLU4shOAfJVISDBGHSEMHtS9RvaIQQKUmg8xj33pYZhlCCSfU12ntI7ecHXpkKCSfkgGj02glu2d-MxCMsSg-i3f5QiHEnZDd2RaZtdAzXXS2vImCGIKGigfZCP8EnIyLTEBCcQ19KuE-5pJoLHf-FMmZ38HcAsRvJgd4EhlxY0eMtF7TyHyzK9fNrtCcMwVQTadkVvnjOdpNAy5P33WxKBDTDV_tJ5UIm43uuYaDUyrbdHr3sLWnl4b2J4_b5tOcVNJT9EwLp_8oSGQdFknzv8Qc_komog51CbVCfSMi8JNMxwcfZ5T_sInSpCAItj0UnxNnJkof4pBdsfMvZp4U5wpkBV0jCShV8psziZDaIVdb8e6OQTbGQnPw_0ZVhfbFO9PtOlfWqOdEKQFxD1vwRETO93mDm7DPaYPyku932OYG9s4bW2DdZX-luqXedp1dAxTb04MBPcdr2ePfIMSxO8xW0o_TwPs9-2Fkv2ZFnE9PPdgsicBLlcs_HUc6I6gm6FP5vbXy1kKXhpehFvuwcQS_Olx1_DxKOTzulFDO0bxzIQars89uCe-N4V3R4f2S&xkcb=SoAM6_M3g44hkdTBEZ0LbzkdCdPP&jsa=4354&camk=C3EPSzFlQw9kMUZsPKmIKQ%3D%3D&from=hp.jobsForYou&tk=1jvoobi9fhn3t800&vjs=3&mtk=1jvoobhuj26sv000", + "text": "Software Engineer Backend (w/m/d)" + }, + { + "tag": "a", + "id": "sj_a9b14ebaf3308946", + "class": "jcs-JobTitle css-1baag51 eu4oa1w0", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "a9b14ebaf3308946", + "aria_label": "full details of Junior Software Engineer (w/m/d)", + "href": "https://ch.indeed.com/pagead/clk?mo=r&ad=-6NYlbfkN0D4OHe0ibpzH4Ce9jmueuNcI7vAuFqActfaHK4HIFT4kfnm8q5uukDhxx0L04ju9xVrqPeFUD44pS26JnvbvZoitS3FT8tFyAhi1lMmDYjqW10_XrCS6Ym70IbxCxpRpAugv_Xa69P2OiGnWmp6NZJ4Ck2v7giq-G8QSJaqA4zz9_5qqlFVAIslwFuaZUge5urJTFgOZDTWTeVtzfCBNXawfwnAPyT5-fHUEyMZDajRG8QDKIbSDqpSHas8ORRVC3XQArLORAkZx6CEU7-d_M9EuMA5QK493RUBbRuwyM_uvTrOLFNrv30007BHx2Y3CBizyxVdnGHDhKwzDnOTupzPfFCalqqGBx_HH1joI_Ml4js_GU2MSKV71BFPs786FjdtzmbIaP-3IT2APqvyJcg6dsmLs305CIxRf9IdR24dE4w-H3SrrsZEcCSEl0VUKOYu0vkS1dMB1kF9wLZdvewcxY28GRutz7R6JxQMsfvtRYLOy-H6o2w2EwvuLBKQDirRIGBg7bK_elkIdarlfNLpltnXFE8E1_P4p7JHWThEdmn2iL-M8lADzzyf_SemDSpjhgINFnkGmhtN7AUwE5ku0H5jq4n9lUIYpse9lCsELvn6x6DtXEtcv_igzPUICaRpZd_SYbwLsWM_mhYBiJ9RQZe_Lb6h1AY3tF5yyljgLcVbOtcvECHwfuQoV_E4n2JUMlB85NXKU5xjen7FpTPgBgl5tSoHUf6LLIZpQ8o8UoDlerlW31Oap47F0niyhbd9ZCdmg4Hbaa-zxQn3VgqGo2PmEv7Gk48HO5OFJD4p0zk8dro2XcdqadEHMFX5kduI5dR1YjB-KFo60CqB79Ey909ekOSxSQ2Jvt1KqJAMYv71AAPsXN2VVTbGXbPZrwESThfJj8HE43nbOJeviutD8QPeVmud0kmicXQuwn9NopvhLTXBDNqlvu7mBOHcTl9YUmSE3hquLQgnpvWqVzVM4IPYYuCZbObhds04x5q1AQ%3D%3D&xkcb=SoC46_M3g44hkdTBEZ0KbzkdCdPP&jsa=4354&camk=C3EPSzFlQw9kMUZsPKmIKQ%3D%3D&from=hp.jobsForYou&tk=1jvoobi9fhn3t800&vjs=3&mtk=1jvoobhuj26sv000", + "text": "Junior Software Engineer (w/m/d)" + }, + { + "tag": "a", + "id": "sj_f45893f18ce77119", + "class": "jcs-JobTitle css-1baag51 eu4oa1w0", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "f45893f18ce77119", + "aria_label": "full details of Full Stack Software Engineers (m/w/d)", + "href": "https://ch.indeed.com/pagead/clk?mo=r&ad=-6NYlbfkN0Bj-xBWVmzs7UIwFa-9Zm6MwNuQFkwLehssDYDaWnvIcd5HmIuxn5ic6eqALSc1aXOM7aZ4x4n83A4QsLB5RCmfx-XCh3a1-XNr57P2bd4c0b8M2I8k_6Hm2M4wHJ_2P0jDgp9IUCjDDjI5srasjobL48Bb8piHHjCaiR6aBZveY0d7YpAde5V4Xnm4-0rqeGW4g7UivKR1AeJt0wYXeV6w3m6gifOJcNPbYv-F3TEHDC8Cu5y0KQ8JXcrv8mZPudedfQZOL5gwvJECgtMF7yMMYZ2-484V2J0fCVvUURp8a2rMyCZf6Sa6nCFljY6L9Z02cMd-GIAgPkIbtsDtk7EAYiRCQyRPNHxcjyRoO0Uz1R46jdf3Nk5wvcAQ12f_Hcq638IXsmhvL7oTb4ZsxDcp_agtRTjxsOLiXWhAMyIuI5-pzZMkugJpAMecPgTiAbPam7Mnue1Z3XMtxZS2cktZStI2bXmgSC4AdAbEmGysYPGwASgZY5AQ-jDaok3OrJk2h7d_sqx01803shZvcSSX21uFL_a3Kx1Qjk-HwjCdUcw6GR8egMVUQEo36QY8YvsOaJeNEeilrw2XtUvX3Do3dty0qH9xq3_btPZZyTcoDGT9T0D18k3u9CPG0BvC9Nwos-76CaP4z44U0kTcKuo1ci0HMGxNsQ-dKFKZQToJ2inzp_iazkJByhvvMkU7kIcYMSQr4yX4kb1z-XnLwi0fZ96UfiMzm-2pr_NtB9dDHDH8NuWtWFYTQbi1MTBR2IqDP46KhSmZW9VzPzfXCATyqD3u5zJotTH98ps2L36AQC0IhYJgznMuAtVhKq5leEg30dcuprON6ZKO0CZ7dEdU-xJV4VZFo47e6vPFGlRG0bLdtkaDdy2sRMpSu4lkco86JQljlkFf00cGnZ_vJnVNE4I59yf-6wCZsNmnBK3eow5Y6EsNKrDCV4M3-uYVWXoZrMm_-hksEYnDb1iegg7-dDVERJlIpYHAc5cd6Yj8IwoX1Fh9s-tlas6OIa4hfdP1zqzsc6ciU1cElJFPooujJPyVcO7JfWsOPHLjdF920R5rdpp6hmbVRMt5mZ5rYIkP8lBlvaVxRQ%3D%3D&xkcb=SoAl6_M3g44hkdTBEZ0JbzkdCdPP&jsa=4354&camk=C3EPSzFlQw_ip5KgLfCEQg%3D%3D&from=hp.jobsForYou&tk=1jvoobi9fhn3t800&vjs=3&mtk=1jvoobhuj26sv000", + "text": "Full Stack Software Engineers (m/w/d)" + }, + { + "tag": "a", + "id": "job_c062420d2591b390", + "class": "jcs-JobTitle css-1baag51 eu4oa1w0", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "c062420d2591b390", + "aria_label": "full details of Software Engineer (Compilers & Virtual Machines)", + "href": "https://ch.indeed.com/rc/clk?jk=c062420d2591b390&from=hp.jobsForYou&tk=1jvoobi9fhn3t800&bb=ulWFy1sX1sLVlFd13aU2mYNXdE9qjUeym4W3l10B6a38oK9QVUvCJQvzTTpkt0UGAiYv31u7Cy0mnvZcS4Dka3N8mZ_QoHTvEEVorpM1ESpU18QZsz6j3DQ7tbpLSi8bf5yCYyxrEYZMm4mnISUM24vqryIgMa5dPJYBKN7KfMBPqte_IuPjw2N7aHUxWIczmh8whxZhE_0TtRwYB2pKSg%3D%3D&xkcb=SoBl67M3g44hkdTBEZ0IbzkdCdPP&mtk=1jvoobhuj26sv000", + "text": "Software Engineer (Compilers & Virtual Machines)" + }, + { + "tag": "a", + "id": "job_502a43b1ce23f2fb", + "class": "jcs-JobTitle css-1baag51 eu4oa1w0", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "502a43b1ce23f2fb", + "aria_label": "full details of Software Engineer (full-stack)", + "href": "https://ch.indeed.com/rc/clk?jk=502a43b1ce23f2fb&from=hp.jobsForYou&tk=1jvoobi9fhn3t800&bb=ulWFy1sX1sLVlFd13aU2mbtBVF3QUqh2Tvv4TO8UddbwnvUtORZlyLq97P0pGy6KSdMEstn7kqNiG1VtrRSCd-1UAhJ0FvAD8Zjk5-FasEz4PFt7Fo1bacxzChDGh7DSpj-g6Ues74iawh5hGMfunaJ7DBwTYBdHde-U2XsNRvCrxNcKi_pwmTc7V6XqRefAGvQ839e3ITotsgbE_hLPgw%3D%3D&xkcb=SoDr67M3g44hkdTBEZ0PbzkdCdPP&mtk=1jvoobhuj26sv000", + "text": "Software Engineer (full-stack)" + }, + { + "tag": "a", + "id": "job_f1e2d3c4b5a67890", + "class": "jcs-JobTitle css-1baag51 eu4oa1w0", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "f1e2d3c4b5a67890", + "aria_label": "full details of Software Engineer (full-stack)", + "href": "https://ch.indeed.com/viewjob?jk=f1e2d3c4b5a67890", + "text": "" + }, + { + "tag": "a", + "id": "job_6e653d30dc348b1d", + "class": "jcs-JobTitle css-1baag51 eu4oa1w0", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "6e653d30dc348b1d", + "aria_label": "full details of Full Stack Webentwickler/in (80-100%)", + "href": "https://ch.indeed.com/rc/clk?jk=6e653d30dc348b1d&from=hp.jobsForYou&tk=1jvoobi9fhn3t800&bb=ulWFy1sX1sLVlFd13aU2mTtMx12XM8pZlQBMW7yD7XDGRxIqqvCra2pVQ9jtFv36boOMV8P-ljraaTlnxY_nKdFwXlW4Bkb5-es2z52tpSGNdh0ex4ztwMvLLKoozmuWu5V6UGIlAEYB2CSAql6aVyzSZNNUhPuI8JiAuWn_VZ88aGSAryxGbopOXyhJGzV-Lw_pe0LdSqtM7fvpgO1Vww%3D%3D&xkcb=SoC967M3g44hkdzBEZ0LbzkdCdPP&mtk=1jvoobhuj26sv000", + "text": "Full Stack Webentwickler/in (80-100%)" + }, + { + "tag": "a", + "id": "job_eac3547790906e6a", + "class": "jcs-JobTitle css-1baag51 eu4oa1w0", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "eac3547790906e6a", + "aria_label": "full details of Mobile & Web Software Engineer", + "href": "https://ch.indeed.com/rc/clk?jk=eac3547790906e6a&from=hp.jobsForYou&tk=1jvoobi9fhn3t800&bb=ulWFy1sX1sLVlFd13aU2mcXi4oyLqIeBKAq3LlcdgknxvRPjbFiB6ADShMiMkteKCZHQZq80HU1IPqXjKDdtj7Hdd9IoSUR3YumMA-I1TeLwqilbWqDzHgoLbeEeRe-U8cDLt89STBVqiogIdaaLQHrlrM5efXWMun_Jw6jEH1EW4MqQwoq5XVGuCWceU2cainprpja5qDDjgUXClRPfog%3D%3D&xkcb=SoAJ67M3g44hkdzBEZ0KbzkdCdPP&mtk=1jvoobhuj26sv000", + "text": "Mobile & Web Software Engineer" + }, + { + "tag": "a", + "id": "", + "class": "css-1h4l2d7 e19afand0", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "", + "aria_label": "Adobe (opens in a new tab)", + "href": "https://ch.indeed.com/cmp/Adobe?campaignid=mobvjcmp&from=mobviewjob&tk=1jvoobiephc2s800&fromjk=fa5f517d6ff8d9ff", + "text": "Adobe" + }, + { + "tag": "a", + "id": "", + "class": "js-match-insights-provider-1i4bbao e19afand0", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "", + "aria_label": "job preferences (opens in a new window)", + "href": "https://profile.indeed.com/", + "text": "profile" + } + ] + }, + { + "name": "apply", + "url": "https://ch.indeed.com/?vjk=432f99aac2879fe7", + "title": "Job Search | Indeed", + "window_handles": 1, + "elements": [ + { + "tag": "button", + "id": "", + "class": "gnav-header-1ue33bt e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "", + "data_jk": "", + "aria_label": "", + "href": "", + "text": "Skip to main content" + }, + { + "tag": "button", + "id": "AccountMenu", + "class": "gnav-header-1baprrs e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "", + "data_jk": "", + "aria_label": "Account", + "href": "", + "text": "" + }, + { + "tag": "button", + "id": "", + "class": "gnav-header-16y3sf1 e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "", + "data_jk": "", + "aria_label": "Primary navigation", + "href": "", + "text": "" + }, + { + "tag": "button", + "id": "", + "class": "gnav-header-1j2vlm6 e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "", + "data_jk": "", + "aria_label": "Close", + "href": "", + "text": "" + }, + { + "tag": "button", + "id": "", + "class": "css-1wsv9po e8ju0x50", + "name": "", + "type": "reset", + "data_testid": "", + "data_jk": "", + "aria_label": "Clear location input", + "href": "", + "text": "" + }, + { + "tag": "button", + "id": "", + "class": "yosegi-InlineWhatWhere-primaryButton css-17qy6hn e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "", + "data_jk": "", + "aria_label": "", + "href": "", + "text": "Find jobs" + }, + { + "tag": "button", + "id": "", + "class": "css-e1dhv1 e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "Pay-preference-button", + "data_jk": "", + "aria_label": "add pay preference", + "href": "", + "text": "Add pay" + }, + { + "tag": "button", + "id": "", + "class": "bookmark bookmark-tap-target mosaic-provider-jobcards-ykqx5t e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "", + "data_jk": "", + "aria_label": "Save job Toggle", + "href": "", + "text": "" + }, + { + "tag": "button", + "id": "", + "class": "dislike-tap-target mosaic-provider-jobcards-1pc3wcg e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "dislikeicon", + "data_jk": "", + "aria_label": "Not interested", + "href": "", + "text": "" + }, + { + "tag": "button", + "id": "", + "class": "bookmark bookmark-tap-target mosaic-provider-jobcards-ykqx5t e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "", + "data_jk": "", + "aria_label": "Save job Toggle", + "href": "", + "text": "" + }, + { + "tag": "button", + "id": "", + "class": "dislike-tap-target mosaic-provider-jobcards-1pc3wcg e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "dislikeicon", + "data_jk": "", + "aria_label": "Not interested", + "href": "", + "text": "" + }, + { + "tag": "button", + "id": "", + "class": "bookmark bookmark-tap-target mosaic-provider-jobcards-ykqx5t e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "", + "data_jk": "", + "aria_label": "Save job Toggle", + "href": "", + "text": "" + }, + { + "tag": "button", + "id": "", + "class": "dislike-tap-target mosaic-provider-jobcards-1pc3wcg e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "dislikeicon", + "data_jk": "", + "aria_label": "Not interested", + "href": "", + "text": "" + }, + { + "tag": "button", + "id": "", + "class": "bookmark bookmark-tap-target mosaic-provider-jobcards-ykqx5t e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "", + "data_jk": "", + "aria_label": "Save job Toggle", + "href": "", + "text": "" + }, + { + "tag": "button", + "id": "", + "class": "dislike-tap-target mosaic-provider-jobcards-1pc3wcg e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "dislikeicon", + "data_jk": "", + "aria_label": "Not interested", + "href": "", + "text": "" + }, + { + "tag": "button", + "id": "", + "class": "bookmark bookmark-tap-target mosaic-provider-jobcards-ykqx5t e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "", + "data_jk": "", + "aria_label": "Save job Toggle", + "href": "", + "text": "" + }, + { + "tag": "button", + "id": "", + "class": "dislike-tap-target mosaic-provider-jobcards-1pc3wcg e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "dislikeicon", + "data_jk": "", + "aria_label": "Not interested", + "href": "", + "text": "" + }, + { + "tag": "button", + "id": "", + "class": "bookmark bookmark-tap-target mosaic-provider-jobcards-ykqx5t e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "", + "data_jk": "", + "aria_label": "Save job Toggle", + "href": "", + "text": "" + }, + { + "tag": "button", + "id": "", + "class": "dislike-tap-target mosaic-provider-jobcards-1pc3wcg e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "dislikeicon", + "data_jk": "", + "aria_label": "Not interested", + "href": "", + "text": "" + }, + { + "tag": "button", + "id": "", + "class": "bookmark bookmark-tap-target mosaic-provider-jobcards-ykqx5t e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "", + "data_jk": "", + "aria_label": "Save job Toggle", + "href": "", + "text": "" + }, + { + "tag": "button", + "id": "", + "class": "dislike-tap-target mosaic-provider-jobcards-1pc3wcg e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "dislikeicon", + "data_jk": "", + "aria_label": "Not interested", + "href": "", + "text": "" + }, + { + "tag": "button", + "id": "", + "class": "bookmark bookmark-tap-target mosaic-provider-jobcards-ykqx5t e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "", + "data_jk": "", + "aria_label": "Save job Toggle", + "href": "", + "text": "" + }, + { + "tag": "button", + "id": "", + "class": "dislike-tap-target mosaic-provider-jobcards-1pc3wcg e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "dislikeicon", + "data_jk": "", + "aria_label": "Not interested", + "href": "", + "text": "" + }, + { + "tag": "button", + "id": "", + "class": "bookmark bookmark-tap-target mosaic-provider-jobcards-ykqx5t e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "", + "data_jk": "", + "aria_label": "Save job Toggle", + "href": "", + "text": "" + }, + { + "tag": "button", + "id": "", + "class": "dislike-tap-target mosaic-provider-jobcards-1pc3wcg e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "dislikeicon", + "data_jk": "", + "aria_label": "Not interested", + "href": "", + "text": "" + }, + { + "tag": "button", + "id": "", + "class": "bookmark bookmark-tap-target mosaic-provider-jobcards-ykqx5t e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "", + "data_jk": "", + "aria_label": "Save job Toggle", + "href": "", + "text": "" + }, + { + "tag": "button", + "id": "", + "class": "dislike-tap-target mosaic-provider-jobcards-1pc3wcg e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "dislikeicon", + "data_jk": "", + "aria_label": "Not interested", + "href": "", + "text": "" + }, + { + "tag": "button", + "id": "", + "class": "bookmark bookmark-tap-target mosaic-provider-jobcards-ykqx5t e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "", + "data_jk": "", + "aria_label": "Save job Toggle", + "href": "", + "text": "" + }, + { + "tag": "button", + "id": "", + "class": "dislike-tap-target mosaic-provider-jobcards-1pc3wcg e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "dislikeicon", + "data_jk": "", + "aria_label": "Not interested", + "href": "", + "text": "" + }, + { + "tag": "button", + "id": "", + "class": "bookmark bookmark-tap-target mosaic-provider-jobcards-ykqx5t e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "", + "data_jk": "", + "aria_label": "Save job Toggle", + "href": "", + "text": "" + }, + { + "tag": "button", + "id": "", + "class": "dislike-tap-target mosaic-provider-jobcards-1pc3wcg e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "dislikeicon", + "data_jk": "", + "aria_label": "Not interested", + "href": "", + "text": "" + }, + { + "tag": "button", + "id": "", + "class": "css-ynm87x e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "", + "data_jk": "", + "aria_label": "", + "href": "", + "text": "Show more jobs" + }, + { + "tag": "button", + "id": "indeedApplyButton", + "class": "css-1neivp9 e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "indeedApplyButton-test", + "data_jk": "", + "aria_label": "Apply with Indeed opens in a new tab", + "href": "", + "text": "Apply with Indeed" + }, + { + "tag": "button", + "id": "", + "class": "css-1jujo9d e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "", + "data_jk": "", + "aria_label": "Save job", + "href": "", + "text": "" + }, + { + "tag": "button", + "id": "", + "class": "css-145xnqb e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "", + "data_jk": "", + "aria_label": "Not interested", + "href": "", + "text": "" + }, + { + "tag": "button", + "id": "", + "class": "css-kr38n4 e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "", + "data_jk": "", + "aria_label": "Share Job", + "href": "", + "text": "" + }, + { + "tag": "button", + "id": "", + "class": "js-match-insights-provider-10rul8 e1xnxm2i0", + "name": "", + "type": "submit", + "data_testid": "CHF 81’816.95 - CHF 125’869.21 a year-tile", + "data_jk": "", + "aria_label": "Pay CHF 81’816.95 - CHF 125’869.21 a year missing preference", + "href": "", + "text": "CHF 81’816.95 - CHF 125’869.21 a year" + }, + { + "tag": "button", + "id": "", + "class": "js-match-insights-provider-qb6ap9 e1xnxm2i0", + "name": "", + "type": "submit", + "data_testid": "Permanent-tile", + "data_jk": "", + "aria_label": "Job type Permanent matching preference", + "href": "", + "text": "Permanent" + }, + { + "tag": "button", + "id": "", + "class": "js-match-insights-provider-qb6ap9 e1xnxm2i0", + "name": "", + "type": "submit", + "data_testid": "100%-tile", + "data_jk": "", + "aria_label": "Job type 100% matching preference", + "href": "", + "text": "100%" + }, + { + "tag": "button", + "id": "", + "class": "mosaic-reportcontent-button desktop mosaic-provider-reportcontent-1hfwffs e8ju0x50", + "name": "", + "type": "submit", + "data_testid": "", + "data_jk": "", + "aria_label": "", + "href": "", + "text": "Report job" + }, + { + "tag": "input", + "id": "text-input-what", + "class": "css-1lonhz0 e1jgz0i3", + "name": "q", + "type": "text", + "data_testid": "", + "data_jk": "", + "aria_label": "search: Job title, keywords, or company", + "href": "", + "text": "" + }, + { + "tag": "input", + "id": "text-input-where", + "class": "css-1rtikjh e1jgz0i3", + "name": "l", + "type": "text", + "data_testid": "", + "data_jk": "", + "aria_label": "Edit location", + "href": "", + "text": "" + }, + { + "tag": "a", + "id": "indeed-globalnav-logo", + "class": "gnav-Logo gnav-header-1nnfo36 e71d0lh0", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "", + "aria_label": "Indeed Home", + "href": "https://ch.indeed.com/?from=gnav-homepage", + "text": "" + }, + { + "tag": "a", + "id": "FindJobs", + "class": "gnav-header-bleyba e71d0lh0", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "", + "aria_label": "Home", + "href": "https://ch.indeed.com/", + "text": "Home" + }, + { + "tag": "a", + "id": "CompanyReviews", + "class": "gnav-header-bleyba e71d0lh0", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "", + "aria_label": "Company reviews", + "href": "https://ch.indeed.com/companies", + "text": "Company reviews" + }, + { + "tag": "a", + "id": "FindSalaries", + "class": "gnav-header-bleyba e71d0lh0", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "", + "aria_label": "Find salaries", + "href": "https://ch.indeed.com/career/salaries", + "text": "Find salaries" + }, + { + "tag": "a", + "id": "", + "class": "gnav-header-d3wicl e71d0lh0", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "", + "aria_label": "My jobs", + "href": "https://myjobs.indeed.com/?from=gnav-util-homepage&gnavTK=1jvoobhv0hc6v802&tk=1jvoobhuj26sv000&co=CH&hl=en", + "text": "" + }, + { + "tag": "a", + "id": "", + "class": "gnav-header-d3wicl e71d0lh0", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "", + "aria_label": "Messages Unread count 0", + "href": "https://messages.indeed.com/?from=gnav-util-homepage&gnavTK=1jvoobhv0hc6v802&tk=1jvoobhuj26sv000&co=CH&hl=en", + "text": "Messages Unread count 0" + }, + { + "tag": "a", + "id": "", + "class": "gnav-header-d3wicl e71d0lh0", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "", + "aria_label": "Notifications Unread count 0", + "href": "https://ch.indeed.com/notifications?from=gnav-util-homepage&gnavTK=1jvoobhv0hc6v802&tk=1jvoobhuj26sv000&hl=en", + "text": "" + }, + { + "tag": "a", + "id": "", + "class": "gnav-header-1pmrdpz e1wnkr790", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "", + "aria_label": "Home", + "href": "https://ch.indeed.com/m/", + "text": "" + }, + { + "tag": "a", + "id": "", + "class": "gnav-header-1pmrdpz e1wnkr790", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "", + "aria_label": "Company reviews", + "href": "https://ch.indeed.com/companies", + "text": "" + }, + { + "tag": "a", + "id": "", + "class": "gnav-header-1pmrdpz e1wnkr790", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "", + "aria_label": "Find salaries", + "href": "https://ch.indeed.com/career/salaries", + "text": "" + }, + { + "tag": "a", + "id": "", + "class": "gnav-header-1pmrdpz e1wnkr790", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "", + "aria_label": "Employers", + "href": "https://ch.indeed.com/hire", + "text": "" + }, + { + "tag": "a", + "id": "", + "class": "gnav-header-1pmrdpz e1wnkr790", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "", + "aria_label": "Profile", + "href": "https://profile.indeed.com/?hl=en_CH&co=CH", + "text": "" + }, + { + "tag": "a", + "id": "", + "class": "gnav-header-1pmrdpz e1wnkr790", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "", + "aria_label": "Reviews", + "href": "https://ch.indeed.com/contributions", + "text": "" + }, + { + "tag": "a", + "id": "", + "class": "gnav-header-1pmrdpz e1wnkr790", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "", + "aria_label": "Settings", + "href": "https://secure.indeed.com/settings?hl=en_CH&co=CH&continue=https%3A%2F%2Fch.indeed.com%2F&tmpl=desktop&service=my&from=gnav-util-homepage", + "text": "" + }, + { + "tag": "a", + "id": "", + "class": "gnav-header-1pmrdpz e1wnkr790", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "", + "aria_label": "Change country: Switzerland", + "href": "https://ch.indeed.com/countries", + "text": "" + }, + { + "tag": "a", + "id": "", + "class": "gnav-header-1pmrdpz e1wnkr790", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "", + "aria_label": "Help", + "href": "https://ch.indeed.com/help/job-seekers?hl=en&co=ch", + "text": "" + }, + { + "tag": "a", + "id": "", + "class": "gnav-header-1pmrdpz e1wnkr790", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "", + "aria_label": "Privacy Center", + "href": "https://hrtechprivacy.com/", + "text": "" + }, + { + "tag": "a", + "id": "", + "class": "gnav-header-1pmrdpz e1wnkr790", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "", + "aria_label": "Terms", + "href": "https://ch.indeed.com/legal?hl=en", + "text": "" + }, + { + "tag": "a", + "id": "", + "class": "gnav-header-1pmrdpz e1wnkr790", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "", + "aria_label": "Accessibility at Indeed", + "href": "https://ch.indeed.com/accessibility?hl=en", + "text": "" + }, + { + "tag": "a", + "id": "", + "class": "gnav-header-1pmrdpz e1wnkr790", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "", + "aria_label": "Online Safety Page", + "href": "https://ch.indeed.com/legal/online-safety-page", + "text": "" + }, + { + "tag": "a", + "id": "", + "class": "gnav-header-1pmrdpz e1wnkr790", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "", + "aria_label": "DSA Reporting", + "href": "https://dsa-reporting-and-appeals.indeed.com/", + "text": "" + }, + { + "tag": "a", + "id": "", + "class": "gnav-header-1pmrdpz e1wnkr790", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "", + "aria_label": "Cookies", + "href": "https://ch.indeed.com/legal/cookies?hl=en&showOneTrustModal=true", + "text": "" + }, + { + "tag": "a", + "id": "", + "class": "gnav-header-1pmrdpz e1wnkr790", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "", + "aria_label": "Sign out: sendmessage@gabo.email", + "href": "https://secure.indeed.com/account/logout?hl=en_CH&co=CH&continue=https%3A%2F%2Fch.indeed.com%2F&tmpl=desktop&from=gnav-util-homepage", + "text": "" + }, + { + "tag": "a", + "id": "EmployersPostJob", + "class": "gnav-header-bleyba e71d0lh0", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "", + "aria_label": "Employers / Post Job", + "href": "https://ch.indeed.com/hire?co=CH&hl=en", + "text": "Employers / Post Job" + }, + { + "tag": "a", + "id": "job_fa5f517d6ff8d9ff", + "class": "jcs-JobTitle css-1baag51 eu4oa1w0", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "fa5f517d6ff8d9ff", + "aria_label": "full details of Software Engineer - Frontend focus", + "href": "https://ch.indeed.com/rc/clk?jk=fa5f517d6ff8d9ff&from=hp.jobsForYou&tk=1jvoobi9fhn3t800&bb=ulWFy1sX1sLVlFd13aU2mVMoRQlqsQLhXnLnQ5AsKm9tIIhXHuaygC1qTJo1g5qkpBzmggUdfMVKj8oZ_sC0wtYwvnSyqgwIzNBK90wWZbj0_QIFGl1dHpS-TA5rjzHFCG61fMAukdaB83XGfhBW0ENLTh1r0Eu7e6oG0VXEptI1z_RozSiFCdHa_4mknMDgLKmv7C51VZ1IVW5A3gAtxQ%3D%3D&xkcb=SoBU67M3g44hkSTBEZ0GbzkdCdPP&mtk=1jvoobhuj26sv000", + "text": "Software Engineer - Frontend focus" + }, + { + "tag": "a", + "id": "sj_ab539e569ae30906", + "class": "jcs-JobTitle css-1baag51 eu4oa1w0", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "ab539e569ae30906", + "aria_label": "full details of Software Architect - Mobile Apps (w/m/d)", + "href": "https://ch.indeed.com/pagead/clk?mo=r&ad=-6NYlbfkN0D4OHe0ibpzH4Ce9jmueuNcI7vAuFqActfaHK4HIFT4kfnm8q5uukDhxx0L04ju9xVrqPeFUD44pd5Jow_1PA4UCfh8RbAJEhmbxgO-wQOuMJPQ8-Q1G5W33fSSMU-WGCKz--KNUTAOVXGb6SnDh4qn0hDDJx06Fb0hZee5Pm615Y_O0jMD_ToqCvK4o-GVZJqG1HUxbQAngYZTdZUoBkaVHjzrvIyInmm5WunIn5X3etCRUEPiSGbVgf77HQFR5jjcCvh9SsHPLY38wSjnFepbWvSdEzEKljn0cgCDPir7geClsBq52kGgopAqDwZOOWulJP1GKqE5jjYxc0dPhQMtaY6NXNFSeMahiKt1Laxvdy3bjIpnH6RgB24CBYYAW3ki-TmoK91YFpdgGQ1yvMjfzcqt3paPMFFz_k9M56aGvYwdaQKBXk2XcI-v4GhuIQzs77Sbt3Pe1pP8sI5dCCxCrKh2Ilu-fz6VJpAFL1vrHQ4B2eFuREQiQ3QNZS5IUgmIgjmhAH2YZ3NwUxnVBsYUVeMtAr_IRk_vxHBjnrpvdtfrbhz-LGpgEjXwDBO9shOkEUplO3TP_wGj73yruhqLEuYHIRsYZvY3VIYIVGzm7lev_k2e6T1zUtBJqVQ3aih9P_nXmSXnzIRRqXI_IX3mqvgwFRMWjLl-I2DNszZp00WV5YiZBR65SlgTHdCyeA3tmk7PIKteRCvUZEdEjGA7L-zFgRT-JE6SnqSy5gin0GtHxObFfox9ludy_6Iaq2JgLZUHP9WS8SKe0jXS91orOYKvz_vD0_MppNcPzZU17xyMcqrKXpBF3i_5VCfF_M2tOQ51ShhSFYQDBDJ2SWrriC0izV-zadiNWPiY2qzm_peAL5APTJONe-7TUuCTgxBahmU8Eu0NruJfgs-Aa3aKb6-fhf6ZhzsY0aHm7CSmkS_OtO-QkGBHZoXMzdQGHDEBTCg9ZYJP2a6_MPDg4zXE9o50seyj4W-7YiQd5L8Q4V1SzDCoy4dlo_Vr9HdqrjM%3D&xkcb=SoAl6_M3g44hkSzBEZ0LbzkdCdPP&jsa=4354&camk=C3EPSzFlQw9kMUZsPKmIKQ%3D%3D&from=hp.jobsForYou&tk=1jvoobi9fhn3t800&vjs=3&mtk=1jvoobhuj26sv000", + "text": "Software Architect - Mobile Apps (w/m/d)" + }, + { + "tag": "a", + "id": "sj_432f99aac2879fe7", + "class": "jcs-JobTitle css-1baag51 eu4oa1w0", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "432f99aac2879fe7", + "aria_label": "full details of Cloud & DevOps Engineer (a) 100% – Ref. 923018", + "href": "https://ch.indeed.com/pagead/clk?mo=r&ad=-6NYlbfkN0ARgjwA5PNIP0mEVZC9C8z286QYPvLwiZJTCwJSzVt9UiJHQlPnNF_ygPoswOtDaorYW2m4is8UPW99gZEo9hcjlmNhlg7szusBNSJ6ifL5pB6K6fqzxplOl5-fKig9prLhPQzbOxgRH7S3_R2mkxl4D-GmKU6AzrcK02Vf3ntRI5WxkjBfPWX91vWtKb2M1jP9nTtt1DpzkJOFSaVKL2elMLFlHxD8UQnDB8pHSZINKLn8SyNpePbaTY7n31s2dSPXokCruuWajvt1FIOt5az2_XuF9Tszafo37RR6_o7YAu50HFnryvIROdoqrE1oZOmIj3O-i8mD1uRU6I5UxRYKMX2gTbHUBhquOMANnCXm_xCOjjA6NEMLoZognnnRMQb-hvDt_SJbjqWAuGxZgp17-VPWU4ZB486Tnf23xqgfgqjPUS_u1OVJyXkSFp97ifEbdmu30hNL0BdXte75lsuKjbWdc7bS8vLDgsHC4po16TGdYUJz4hhlNb1Xs-mnxh6K0ICILF99mkfQw9CYpi6SJYXa0uGBFF-OvkwmYnvAtb3GoUbxE9Y2AiBkWzs0n_fjOAjlBeLIY54f1QiN8PVCRTeDdnkNNwxe-KwIhMWtykRHgH-auZnIBOP5bt_5k1KkIbUq3TFNeX4UKYt4QmDG930gK63VXaNh4PVgUUKADQRyZILs71xBYEe-cuUXtRgeRWQTaAjzCZ2cbOKNXSMmxcqcKPauFc2If5ZrV17TOTw5Yo7sum8U5JPpokrXgSfkrebBuwmCiDegXd93vX13YmNgXPkQDsFA3xa7nU8W-gJelshUCBy7ZyTnw9g3wvR3QpF9TzglJGV_7w95iFbYiWXB5dlM-PIoS1_IopAe6HxQDG19vIyfNrBd4vM1uuI%3D&xkcb=SoCR6_M3g44hkSzBEZ0KbzkdCdPP&jsa=4354&camk=C3EPSzFlQw9BPjnxLJHmDQ%3D%3D&from=hp.jobsForYou&tk=1jvoobi9fhn3t800&vjs=3&mtk=1jvoobhuj26sv000", + "text": "Cloud & DevOps Engineer (a) 100% – Ref. 923018" + }, + { + "tag": "a", + "id": "sj_0ebd727574bf6a7b", + "class": "jcs-JobTitle css-1baag51 eu4oa1w0", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "0ebd727574bf6a7b", + "aria_label": "full details of Cloud & DevOps Engineer (a) 100% – Ref. 923018", + "href": "https://ch.indeed.com/pagead/clk?mo=r&ad=-6NYlbfkN0ARgjwA5PNIP0mEVZC9C8z286QYPvLwiZJTCwJSzVt9UiJHQlPnNF_ygPoswOtDaorYW2m4is8UPVEvLsaFOBH2_hjp4Imb1vSoox4I8YyLEnxXsKptE49ajkrtVXflC1mScopryHJtZr0NAPdQvODweNBQKPgElFGT4qVRui5LoiB0OorLa1tBh8oYaPmBKbw3lqSiCgE1NeV7GTYA1W3haQ2_qq4X4zn8lPjCPEXx2HrwFlSftouN4bdD7JS2bTNq1ibGpe3b8alNZZJpY3QfLfE9MKBYEMl2zlgrBDj0YUnAEhopAKFPYVlo8oSd5WSHIc6vfF6hEXmp7VOvNu7pI7RHn5NiuY_Ya0RAWk9fjzpz6BR8B5Vx8Iyok5iImihHZpWwueI6DOJrORGLi4mqERNEJhyMnyq1-dsiRzxcMQTbOtxwjqoyMx7BoPaRmBuA4v5wAzxhTaBtpD2LJ45uYHmSY_zy5rcFfTivkOchouM3u8d3xptWkmFTCP_e9sF-OQNfLov82qGcJWJLnqIgly7s4XaiLmyAVEhdZNno9QMiBPXRAxG3Ljzg2SdhURhSAenSqoWKhTH9quUoE3EU7My_terVgvOV7qDtNNYWd6Pm9b3mElbghhDW4a6XgExO-Ke7CNCHLaLcWBCTkpxoutvMd2vxwLBe53uYU1bJDtzkqhfmibXLkJWGwrIwlaKl45YWCDXOIFRQV2-ZRydqcdPg8KCyo3ogKnpiAtXwDuqgF1nh-HkxU8besz-U13IFiHYr3v1fXr0o3uZOkiT0FrJc2AxLlUG7rwBrnKqRIXQ8tUsV2F0DdR-vNYn6rv7VPfYe7yRBJwNnJDxJzJC6nubPOqHijTv9mk_kGuDpDFmGufIij7ChzqaNI4DhNXU%3D&xkcb=SoAM6_M3g44hkSzBEZ0JbzkdCdPP&jsa=4354&camk=C3EPSzFlQw9BPjnxLJHmDQ%3D%3D&from=hp.jobsForYou&tk=1jvoobi9fhn3t800&vjs=3&mtk=1jvoobhuj26sv000", + "text": "Cloud & DevOps Engineer (a) 100% – Ref. 923018" + }, + { + "tag": "a", + "id": "sj_02922a2e8a31c93f", + "class": "jcs-JobTitle css-1baag51 eu4oa1w0", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "02922a2e8a31c93f", + "aria_label": "full details of Software Engineer Backend (w/m/d)", + "href": "https://ch.indeed.com/pagead/clk?mo=r&ad=-6NYlbfkN0D4OHe0ibpzH4Ce9jmueuNcI7vAuFqActfaHK4HIFT4kfnm8q5uukDhxx0L04ju9xVrqPeFUD44pUUkwTi9l5aBTqY4x24rC7sydGVaUGVcNA4sbiBeBNEeCM8AIqXU92B5brNfbvZaIWMJvnPB2-NcfFPTjS3LES-a5oyrBAahEwPA0GmdIgxbiFprdolEcGtntWrRLWfU-7LB_VyZbjVVBKZew5ICLysTSrN6ZcuGwlmiz8mTbPAHTepWRglBxrpw0c6lgktO-a_4gieEVwjMXZZwc6nothNEWQYJp_MRwrMzBv80yWkay9WwAPzbz4xRJ_Wc3EkGUL5wQBpvCD7U6rxM03qAGcRv_YAXZ68ZalPwi5_7xo1AS8pkSzDF3WMBCKICpWx4bKLUSfzRaF6MxwdNN-0DeiYm-kL9AsuAVEL_9gyxqXvgLU4shOAfJVISDBGHSEMHtS9RvaIQQKUmg8xj33pYZhlCCSfU12ntI7ecHXpkKCSfkgGj02glu2d-MxCMsSg-i3f5QiHEnZDd2RaZtdAzXXS2vImCGIKGigfZCP8EnIyLTEBCcQ19KuE-5pJoLHf-FMmZ38HcAsRvJgd4EhlxY0eMtF7TyHyzK9fNrtCcMwVQTadkVvnjOdpNAy5P33WxKBDTDV_tJ5UIm43uuYaDUyrbdHr3sLWnl4b2J4_b5tOcVNJT9EwLp_8oSGQdFknzv8Qc_komog51CbVCfSMi8JNMxwcfZ5T_sInSpCAItj0UnxNnJkof4pBdsfMvZp4U5wpkBV0jCShV8psziZDaIVdb8e6OQTbGQnPw_0ZVhfbFO9PtOlfWqOdEKQFxD1vwRETO93mDm7DPaYPyku932OYG9s4bW2DdZX-luqXedp1dAxTb04MBPcdr2ePfIMSxO8xW0o_TwPs9-2Fkv2ZFnE9PPdgsicBLlcs_HUc6I6gm6FP5vbXy1kKXhpehFvuwcQS_Olx1_DxKOTzulFDO0bxzIQars89uCe-N4V3R4f2S&xkcb=SoAM6_M3g44hkdTBEZ0LbzkdCdPP&jsa=4354&camk=C3EPSzFlQw9kMUZsPKmIKQ%3D%3D&from=hp.jobsForYou&tk=1jvoobi9fhn3t800&vjs=3&mtk=1jvoobhuj26sv000", + "text": "Software Engineer Backend (w/m/d)" + }, + { + "tag": "a", + "id": "sj_a9b14ebaf3308946", + "class": "jcs-JobTitle css-1baag51 eu4oa1w0", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "a9b14ebaf3308946", + "aria_label": "full details of Junior Software Engineer (w/m/d)", + "href": "https://ch.indeed.com/pagead/clk?mo=r&ad=-6NYlbfkN0D4OHe0ibpzH4Ce9jmueuNcI7vAuFqActfaHK4HIFT4kfnm8q5uukDhxx0L04ju9xVrqPeFUD44pS26JnvbvZoitS3FT8tFyAhi1lMmDYjqW10_XrCS6Ym70IbxCxpRpAugv_Xa69P2OiGnWmp6NZJ4Ck2v7giq-G8QSJaqA4zz9_5qqlFVAIslwFuaZUge5urJTFgOZDTWTeVtzfCBNXawfwnAPyT5-fHUEyMZDajRG8QDKIbSDqpSHas8ORRVC3XQArLORAkZx6CEU7-d_M9EuMA5QK493RUBbRuwyM_uvTrOLFNrv30007BHx2Y3CBizyxVdnGHDhKwzDnOTupzPfFCalqqGBx_HH1joI_Ml4js_GU2MSKV71BFPs786FjdtzmbIaP-3IT2APqvyJcg6dsmLs305CIxRf9IdR24dE4w-H3SrrsZEcCSEl0VUKOYu0vkS1dMB1kF9wLZdvewcxY28GRutz7R6JxQMsfvtRYLOy-H6o2w2EwvuLBKQDirRIGBg7bK_elkIdarlfNLpltnXFE8E1_P4p7JHWThEdmn2iL-M8lADzzyf_SemDSpjhgINFnkGmhtN7AUwE5ku0H5jq4n9lUIYpse9lCsELvn6x6DtXEtcv_igzPUICaRpZd_SYbwLsWM_mhYBiJ9RQZe_Lb6h1AY3tF5yyljgLcVbOtcvECHwfuQoV_E4n2JUMlB85NXKU5xjen7FpTPgBgl5tSoHUf6LLIZpQ8o8UoDlerlW31Oap47F0niyhbd9ZCdmg4Hbaa-zxQn3VgqGo2PmEv7Gk48HO5OFJD4p0zk8dro2XcdqadEHMFX5kduI5dR1YjB-KFo60CqB79Ey909ekOSxSQ2Jvt1KqJAMYv71AAPsXN2VVTbGXbPZrwESThfJj8HE43nbOJeviutD8QPeVmud0kmicXQuwn9NopvhLTXBDNqlvu7mBOHcTl9YUmSE3hquLQgnpvWqVzVM4IPYYuCZbObhds04x5q1AQ%3D%3D&xkcb=SoC46_M3g44hkdTBEZ0KbzkdCdPP&jsa=4354&camk=C3EPSzFlQw9kMUZsPKmIKQ%3D%3D&from=hp.jobsForYou&tk=1jvoobi9fhn3t800&vjs=3&mtk=1jvoobhuj26sv000", + "text": "Junior Software Engineer (w/m/d)" + }, + { + "tag": "a", + "id": "sj_f45893f18ce77119", + "class": "jcs-JobTitle css-1baag51 eu4oa1w0", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "f45893f18ce77119", + "aria_label": "full details of Full Stack Software Engineers (m/w/d)", + "href": "https://ch.indeed.com/pagead/clk?mo=r&ad=-6NYlbfkN0Bj-xBWVmzs7UIwFa-9Zm6MwNuQFkwLehssDYDaWnvIcd5HmIuxn5ic6eqALSc1aXOM7aZ4x4n83A4QsLB5RCmfx-XCh3a1-XNr57P2bd4c0b8M2I8k_6Hm2M4wHJ_2P0jDgp9IUCjDDjI5srasjobL48Bb8piHHjCaiR6aBZveY0d7YpAde5V4Xnm4-0rqeGW4g7UivKR1AeJt0wYXeV6w3m6gifOJcNPbYv-F3TEHDC8Cu5y0KQ8JXcrv8mZPudedfQZOL5gwvJECgtMF7yMMYZ2-484V2J0fCVvUURp8a2rMyCZf6Sa6nCFljY6L9Z02cMd-GIAgPkIbtsDtk7EAYiRCQyRPNHxcjyRoO0Uz1R46jdf3Nk5wvcAQ12f_Hcq638IXsmhvL7oTb4ZsxDcp_agtRTjxsOLiXWhAMyIuI5-pzZMkugJpAMecPgTiAbPam7Mnue1Z3XMtxZS2cktZStI2bXmgSC4AdAbEmGysYPGwASgZY5AQ-jDaok3OrJk2h7d_sqx01803shZvcSSX21uFL_a3Kx1Qjk-HwjCdUcw6GR8egMVUQEo36QY8YvsOaJeNEeilrw2XtUvX3Do3dty0qH9xq3_btPZZyTcoDGT9T0D18k3u9CPG0BvC9Nwos-76CaP4z44U0kTcKuo1ci0HMGxNsQ-dKFKZQToJ2inzp_iazkJByhvvMkU7kIcYMSQr4yX4kb1z-XnLwi0fZ96UfiMzm-2pr_NtB9dDHDH8NuWtWFYTQbi1MTBR2IqDP46KhSmZW9VzPzfXCATyqD3u5zJotTH98ps2L36AQC0IhYJgznMuAtVhKq5leEg30dcuprON6ZKO0CZ7dEdU-xJV4VZFo47e6vPFGlRG0bLdtkaDdy2sRMpSu4lkco86JQljlkFf00cGnZ_vJnVNE4I59yf-6wCZsNmnBK3eow5Y6EsNKrDCV4M3-uYVWXoZrMm_-hksEYnDb1iegg7-dDVERJlIpYHAc5cd6Yj8IwoX1Fh9s-tlas6OIa4hfdP1zqzsc6ciU1cElJFPooujJPyVcO7JfWsOPHLjdF920R5rdpp6hmbVRMt5mZ5rYIkP8lBlvaVxRQ%3D%3D&xkcb=SoAl6_M3g44hkdTBEZ0JbzkdCdPP&jsa=4354&camk=C3EPSzFlQw_ip5KgLfCEQg%3D%3D&from=hp.jobsForYou&tk=1jvoobi9fhn3t800&vjs=3&mtk=1jvoobhuj26sv000", + "text": "Full Stack Software Engineers (m/w/d)" + }, + { + "tag": "a", + "id": "job_c062420d2591b390", + "class": "jcs-JobTitle css-1baag51 eu4oa1w0", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "c062420d2591b390", + "aria_label": "full details of Software Engineer (Compilers & Virtual Machines)", + "href": "https://ch.indeed.com/rc/clk?jk=c062420d2591b390&from=hp.jobsForYou&tk=1jvoobi9fhn3t800&bb=ulWFy1sX1sLVlFd13aU2mYNXdE9qjUeym4W3l10B6a38oK9QVUvCJQvzTTpkt0UGAiYv31u7Cy0mnvZcS4Dka3N8mZ_QoHTvEEVorpM1ESpU18QZsz6j3DQ7tbpLSi8bf5yCYyxrEYZMm4mnISUM24vqryIgMa5dPJYBKN7KfMBPqte_IuPjw2N7aHUxWIczmh8whxZhE_0TtRwYB2pKSg%3D%3D&xkcb=SoBl67M3g44hkdTBEZ0IbzkdCdPP&mtk=1jvoobhuj26sv000", + "text": "Software Engineer (Compilers & Virtual Machines)" + }, + { + "tag": "a", + "id": "job_502a43b1ce23f2fb", + "class": "jcs-JobTitle css-1baag51 eu4oa1w0", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "502a43b1ce23f2fb", + "aria_label": "full details of Software Engineer (full-stack)", + "href": "https://ch.indeed.com/rc/clk?jk=502a43b1ce23f2fb&from=hp.jobsForYou&tk=1jvoobi9fhn3t800&bb=ulWFy1sX1sLVlFd13aU2mbtBVF3QUqh2Tvv4TO8UddbwnvUtORZlyLq97P0pGy6KSdMEstn7kqNiG1VtrRSCd-1UAhJ0FvAD8Zjk5-FasEz4PFt7Fo1bacxzChDGh7DSpj-g6Ues74iawh5hGMfunaJ7DBwTYBdHde-U2XsNRvCrxNcKi_pwmTc7V6XqRefAGvQ839e3ITotsgbE_hLPgw%3D%3D&xkcb=SoDr67M3g44hkdTBEZ0PbzkdCdPP&mtk=1jvoobhuj26sv000", + "text": "Software Engineer (full-stack)" + }, + { + "tag": "a", + "id": "job_f1e2d3c4b5a67890", + "class": "jcs-JobTitle css-1baag51 eu4oa1w0", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "f1e2d3c4b5a67890", + "aria_label": "full details of Software Engineer (full-stack)", + "href": "https://ch.indeed.com/viewjob?jk=f1e2d3c4b5a67890", + "text": "" + }, + { + "tag": "a", + "id": "job_6e653d30dc348b1d", + "class": "jcs-JobTitle css-1baag51 eu4oa1w0", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "6e653d30dc348b1d", + "aria_label": "full details of Full Stack Webentwickler/in (80-100%)", + "href": "https://ch.indeed.com/rc/clk?jk=6e653d30dc348b1d&from=hp.jobsForYou&tk=1jvoobi9fhn3t800&bb=ulWFy1sX1sLVlFd13aU2mTtMx12XM8pZlQBMW7yD7XDGRxIqqvCra2pVQ9jtFv36boOMV8P-ljraaTlnxY_nKdFwXlW4Bkb5-es2z52tpSGNdh0ex4ztwMvLLKoozmuWu5V6UGIlAEYB2CSAql6aVyzSZNNUhPuI8JiAuWn_VZ88aGSAryxGbopOXyhJGzV-Lw_pe0LdSqtM7fvpgO1Vww%3D%3D&xkcb=SoC967M3g44hkdzBEZ0LbzkdCdPP&mtk=1jvoobhuj26sv000", + "text": "Full Stack Webentwickler/in (80-100%)" + }, + { + "tag": "a", + "id": "job_eac3547790906e6a", + "class": "jcs-JobTitle css-1baag51 eu4oa1w0", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "eac3547790906e6a", + "aria_label": "full details of Mobile & Web Software Engineer", + "href": "https://ch.indeed.com/rc/clk?jk=eac3547790906e6a&from=hp.jobsForYou&tk=1jvoobi9fhn3t800&bb=ulWFy1sX1sLVlFd13aU2mcXi4oyLqIeBKAq3LlcdgknxvRPjbFiB6ADShMiMkteKCZHQZq80HU1IPqXjKDdtj7Hdd9IoSUR3YumMA-I1TeLwqilbWqDzHgoLbeEeRe-U8cDLt89STBVqiogIdaaLQHrlrM5efXWMun_Jw6jEH1EW4MqQwoq5XVGuCWceU2cainprpja5qDDjgUXClRPfog%3D%3D&xkcb=SoAJ67M3g44hkdzBEZ0KbzkdCdPP&mtk=1jvoobhuj26sv000", + "text": "Mobile & Web Software Engineer" + }, + { + "tag": "a", + "id": "", + "class": "css-1h4l2d7 e19afand0", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "", + "aria_label": "EMGE Personal GmbH (opens in a new tab)", + "href": "https://ch.indeed.com/cmp/Emge-Personal-Gmbh?campaignid=mobvjcmp&from=mobviewjob&tk=1jvoocgnthc76804&fromjk=432f99aac2879fe7", + "text": "EMGE Personal GmbH" + }, + { + "tag": "a", + "id": "", + "class": "js-match-insights-provider-1i4bbao e19afand0", + "name": "", + "type": "", + "data_testid": "", + "data_jk": "", + "aria_label": "job preferences (opens in a new window)", + "href": "https://profile.indeed.com/", + "text": "profile" + } + ] + } + ], + "classHints": [ + "css-1wsv9po", + "css-17qy6hn", + "css-e1dhv1", + "css-ynm87x", + "css-1aceri0", + "css-1jujo9d", + "css-145xnqb", + "css-kr38n4", + "css-1lonhz0", + "css-1rtikjh", + "css-1baag51", + "css-1h4l2d7" + ] + }, + "liveClassHints": [ + "css-1wsv9po", + "css-17qy6hn", + "css-e1dhv1", + "css-ynm87x", + "css-1aceri0", + "css-1jujo9d", + "css-145xnqb", + "css-kr38n4", + "css-1lonhz0", + "css-1rtikjh", + "css-1baag51", + "css-1h4l2d7" + ] +} diff --git a/main.py b/main.py index fe1a178..7ff5350 100644 --- a/main.py +++ b/main.py @@ -1,10 +1,7 @@ +import argparse import json import time import urllib.parse -import logging -from datetime import datetime, timedelta -from pathlib import Path -from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.common.keys import Keys from selenium.webdriver.support import expected_conditions as EC @@ -16,9 +13,11 @@ TimeoutException, ElementClickInterceptedException, ) -from selenium.webdriver.firefox.service import Service as FirefoxService -class EasyApplyLinkedin: +from base_easy_apply import BaseEasyApply + + +class EasyApplyLinkedin(BaseEasyApply): BASE_URL = "https://www.linkedin.com/jobs/search/" COLLECTION_URLS = { "small_business": "https://www.linkedin.com/jobs/collections/small-business", @@ -26,9 +25,6 @@ class EasyApplyLinkedin: "easy_apply": "https://www.linkedin.com/jobs/collections/easy-apply", "top_applicant": "https://www.linkedin.com/jobs/collections/top-applicant" } - ERROR_LOG_PATH = Path("error_log.json") - APPLIED_COMPANIES_LOG_PATH = Path("applied_companies_log.json") - FAILED_APPLICATIONS_LOG_PATH = Path("failed_applications_log.json") TIME_POSTED_MAPPING = { "Any Time": "", @@ -94,85 +90,27 @@ class EasyApplyLinkedin: } def __init__(self, data): - self.email = data["email"] - self.password = data["password"] - self.keywords = " OR ".join(data["keywords"]) - self.keywords_to_avoid = " NOT ".join(data["keywordsToAvoid"]) - self.locations = data["locations"] - self.filters = data["filters"] + super().__init__(data, start_driver=True) self.collection = data.get("collection", "") self.sort_by = data["sortBy"] - self.context_data = data - self.current_location_index = 0 - if "user_inputs" not in self.context_data: - self.context_data["user_inputs"] = {} - firefox_service = FirefoxService(executable_path=data["driver_path"]) - self.driver = webdriver.Firefox(service=firefox_service) - self.init_logging() - - def init_logging(self): - logging.basicConfig(level=logging.INFO) - self.error_logger = logging.getLogger("ErrorLogger") - self.applied_companies = self.load_json(self.APPLIED_COMPANIES_LOG_PATH) - self.failed_applications = self.load_json(self.FAILED_APPLICATIONS_LOG_PATH) - - def load_json(self, path): - if path.exists(): - try: - with path.open("r") as file: - return json.load(file) - except json.JSONDecodeError: - self.log_error(f"Error decoding JSON from {path}") - return {} - return {} - - def save_json(self, path, data): - with path.open("w") as file: - json.dump(data, file, indent=4) - - def log_error(self, error_msg): - self.error_logger.error(error_msg) - errors = self.load_json(self.ERROR_LOG_PATH) - errors[str(datetime.now())] = error_msg - self.save_json(self.ERROR_LOG_PATH, errors) - self.cleanup_error_log() - - def log_info(self, message): - logging.info(message) - - def cleanup_error_log(self): - errors = self.load_json(self.ERROR_LOG_PATH) - cutoff = datetime.now() - timedelta(days=1) - errors = {k: v for k, v in errors.items() if datetime.fromisoformat(k) > cutoff} - self.save_json(self.ERROR_LOG_PATH, errors) - - def log_applied_company(self, company): - self.applied_companies[company] = str(datetime.now()) - self.save_json(self.APPLIED_COMPANIES_LOG_PATH, self.applied_companies) - self.cleanup_applied_companies_log() - - def cleanup_applied_companies_log(self): - cutoff = datetime.now() - timedelta(weeks=2) - self.applied_companies = { - k: v - for k, v in self.applied_companies.items() - if datetime.fromisoformat(v) > cutoff - } - self.save_json(self.APPLIED_COMPANIES_LOG_PATH, self.applied_companies) - - def log_failed_application(self, company): - self.failed_applications[company] = str(datetime.now()) - self.save_json(self.FAILED_APPLICATIONS_LOG_PATH, self.failed_applications) - self.cleanup_failed_applications_log() - - def cleanup_failed_applications_log(self): - cutoff = datetime.now() - timedelta(weeks=2) - self.failed_applications = { - k: v - for k, v in self.failed_applications.items() - if datetime.fromisoformat(v) > cutoff - } - self.save_json(self.FAILED_APPLICATIONS_LOG_PATH, self.failed_applications) + self.locations = data["locations"] + self.filters = data["filters"] + + def _gather_application_text(self, label_text=""): + chunks = [label_text or ""] + try: + modal = self.driver.find_element( + By.CSS_SELECTOR, "div.artdeco-modal--layer-default.jobs-easy-apply-modal" + ) + chunks.append(modal.text or "") + except Exception: + pass + try: + details = self.driver.find_element(By.CLASS_NAME, "jobs-search__job-details--wrapper") + chunks.append((details.text or "")[:2000]) + except Exception: + pass + return "\n".join(chunks) def login_linkedin(self): try: @@ -316,68 +254,6 @@ def check_no_results(self): except NoSuchElementException: return False - def find_element_with_retry(self, by, value, retries=3, delay=2): - for _ in range(retries): - try: - return self.driver.find_element(by, value) - except (NoSuchElementException, StaleElementReferenceException): - time.sleep(delay) - raise NoSuchElementException(f"Element not found: {by}, {value}") - - def get_response_for_label(self, label_text): - current_location = self.locations[self.current_location_index] - if current_location in self.context_data["user_inputs"]: - location_specific_inputs = self.context_data["user_inputs"][current_location] - if label_text in location_specific_inputs: - return location_specific_inputs[label_text] - - user_input = input(f"Please provide the answer for '{label_text}': ") - if current_location not in self.context_data["user_inputs"]: - self.context_data["user_inputs"][current_location] = {} - self.context_data["user_inputs"][current_location][label_text] = user_input - self.update_config_file() - return user_input - - def get_radio_response_for_label(self, label_text, options): - current_location = self.locations[self.current_location_index] - if current_location in self.context_data["user_inputs"]: - location_specific_inputs = self.context_data["user_inputs"][current_location] - if label_text in location_specific_inputs: - return location_specific_inputs[label_text] - - while True: - print(f"Please select an option for '{label_text}':") - for i, option in enumerate(options): - print(f"{i + 1}. {option}") - user_input = input("Enter the number of your choice: ").strip() - if user_input.isdigit() and 1 <= int(user_input) <= len(options): - response = options[int(user_input) - 1] - if current_location not in self.context_data["user_inputs"]: - self.context_data["user_inputs"][current_location] = {} - self.context_data["user_inputs"][current_location][label_text] = response - self.update_config_file() - return response - else: - print("Invalid input, please try again.") - - def get_file_response_for_label(self, label_text): - current_location = self.locations[self.current_location_index] - if current_location in self.context_data["user_inputs"]: - location_specific_inputs = self.context_data["user_inputs"][current_location] - if label_text in location_specific_inputs: - return location_specific_inputs[label_text] - - user_input = input(f"Please provide the file location for '{label_text}': ") - if current_location not in self.context_data["user_inputs"]: - self.context_data["user_inputs"][current_location] = {} - self.context_data["user_inputs"][current_location][label_text] = user_input - self.update_config_file() - return user_input - - def update_config_file(self): - with open("config.json", "w") as config_file: - json.dump(self.context_data, config_file, indent=4) - def find_offers(self): if self.collection: self.apply_collection() @@ -644,8 +520,27 @@ def fill_form(self, modal_dialog): ) for element in form_elements: try: - label = element.find_element(By.CSS_SELECTOR, "label, legend, span[aria-hidden='true']") - label_text = label.text.strip() + try: + label = element.find_element(By.CSS_SELECTOR, "label, legend, span[aria-hidden='true']") + label_text = label.text.strip() + except NoSuchElementException: + self.log_info("No label found for a form element, skipping...") + continue + + if "notice period" in label_text.lower() or "kündigungsfrist" in label_text.lower(): + input_field = element.find_element(By.CSS_SELECTOR, "input[type='text']") + if input_field.get_attribute("value") == "": + notice = ( + (self.context_data.get("aiContext") or {}) + .get("user_data", {}) + .get("noticePeriodDays", 30) + ) + self.log_info(f"Filling notice period with {notice} for field: {label_text}") + input_field.clear() + input_field.send_keys(str(notice)) + time.sleep(1) + input_field.send_keys(Keys.RETURN) + continue if "data-test-checkbox-form-component" in element.get_attribute("outerHTML"): self.handle_checkboxes(element) @@ -698,7 +593,14 @@ def fill_form(self, modal_dialog): input_field.send_keys(response) time.sleep(1) - except NoSuchElementException: + except NoSuchElementException as e: + self.log_error(f"Element not found for a form field, error: {e}") + continue + except ElementNotInteractableException as e: + self.log_error(f"Element not interactable for a form field, error: {e}") + continue + except Exception as e: + self.log_error(f"Unexpected error while processing form field: {e}") continue try: @@ -745,23 +647,6 @@ def set_checkbox_state(self, checkbox, checkbox_label, response): elif not response and checkbox.is_selected(): self.driver.execute_script("arguments[0].click();", checkbox) - def get_checkbox_response_for_label(self, label_text): - current_location = self.locations[self.current_location_index] - if current_location not in self.context_data["user_inputs"]: - self.context_data["user_inputs"][current_location] = {} - - location_specific_inputs = self.context_data["user_inputs"][current_location] - if label_text in location_specific_inputs: - return location_specific_inputs[label_text] - - while True: - user_input = input(f"Do you want to check the box for '{label_text}'? (yes/no): ").strip().lower() - if user_input in ["yes", "no"]: - response = user_input == "yes" - location_specific_inputs[label_text] = response - self.update_config_file() - return response - def handle_done_button(self): try: done_button = WebDriverWait(self.driver, 10).until( @@ -800,19 +685,34 @@ def handle_discard_dialog(self): except TimeoutException: self.log_info("Discard button not found, skipping to next job.") - def close_session(self): - self.log_info("End of the session") - self.driver.close() - self.driver.quit() - def handle_captcha(self): - input("CAPTCHA detected. Please solve the CAPTCHA manually and then press Enter to continue...") +def main(): + parser = argparse.ArgumentParser(description="Easy Apply automation for LinkedIn / Indeed") + parser.add_argument( + "--platform", + choices=["linkedin", "indeed"], + default="linkedin", + help="Which job board to automate (default: linkedin)", + ) + args = parser.parse_args() -if __name__ == "__main__": with open("config.json") as config_file: data = json.load(config_file) - bot = EasyApplyLinkedin(data) - bot.login_linkedin() - bot.job_search() - bot.find_offers() - bot.close_session() + + if args.platform == "indeed": + from indeed_bot import EasyApplyIndeed + + bot = EasyApplyIndeed(data) + bot.login_indeed() + bot.find_offers() + bot.close_session() + else: + bot = EasyApplyLinkedin(data) + bot.login_linkedin() + bot.job_search() + bot.find_offers() + bot.close_session() + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt index fa05aa0..8364cb9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,3 +25,6 @@ transformers==4.28.1 torch==2.0.1 langdetect==1.0.9 googletrans==4.0.0-rc1 +sentence-transformers>=2.2.0 +requests>=2.28.0 +numpy>=1.21.0 diff --git a/test_answer_engine.py b/test_answer_engine.py new file mode 100644 index 0000000..cef611c --- /dev/null +++ b/test_answer_engine.py @@ -0,0 +1,333 @@ +"""Unit tests for answer_engine — no real LLM or network calls.""" + +import unittest +from unittest.mock import MagicMock, patch + +import numpy as np + +from answer_engine import ( + AnswerEngine, + coerce_checkbox_answer, + flatten_user_inputs, + fuzzy_match_option, + normalize_label, + sanitize_answer, +) + + +SAMPLE_CONTEXT = { + "aiContext": { + "user_data": { + "linkedin_url": "https://www.linkedin.com/in/bugslayer", + "phone": "+34 622480234", + "email": "sendmessage@gabo.email", + "address": "Albal, Valencia, Spain", + "currentLocation": "Valencia, Spain", + }, + "languagesSpokenByUser": { + "English": "Native", + "Spanish": "Native", + }, + "preferences": { + "workplaceType": "Remote", + "jobType": "Contract", + "willingToRelocate": True, + }, + "experience": [ + { + "title": "Full-Stack TypeScript Software Engineer", + "description": "Built apps with Node and Angular", + "date": "Nov 2022 - Apr 2024", + "company": "Beyondbmi", + "skills": ["TypeScript", "Node.js", "Angular", "React"], + }, + { + "title": "Full Stack Engineer", + "description": "Various projects", + "date": "Oct 2021 - Apr 2024", + "company": "GABO", + "skills": ["TypeScript", "Node.js", "React.js", "Express.js"], + }, + ], + "skills": ["TypeScript", "JavaScript", "Angular", "React", "Node.js", "Express.js"], + }, + "user_inputs": { + "United Kingdom": { + "City\nCity": "Valencia, Valencian Community, Spain", + "How many years of work experience do you have with Node.js?": "4", + "How many years of work experience do you have with React?": "3", + "What are your salary expectations?": "90000", + "Will you now or in the future require sponsorship for employment visa status?\nWill you now or in the future require sponsorship for employment visa status?\nRequired": "Yes", + "I Agree Terms & Conditions": True, + "LinkedIn": True, + }, + "United States": { + "How many years of work experience do you have with Express.js?": "4", + "Are you legally authorized to work in the United States?\nAre you legally authorized to work in the United States?\nRequired": "No", + }, + }, + "aiSettings": { + "enabled": True, + "primary": "ollama", + "retrieval": {"similarityThreshold": 0.85}, + "defaults": { + "salaryExpectationUsd": "90000", + "hourlyRateRange": "40-60", + "requiresSponsorship": True, + "willingToRelocate": True, + }, + "style": { + "maxWords": 40, + "forbiddenPatterns": ["—", "Furthermore", "I am excited", "I am passionate"], + }, + }, +} + + +class TestNormalizeAndSanitize(unittest.TestCase): + def test_normalize_duplicate_city_label(self): + self.assertEqual(normalize_label("City\nCity"), "City") + + def test_normalize_strips_required(self): + self.assertEqual( + normalize_label("Are you authorized?\nAre you authorized?\nRequired"), + "Are you authorized?", + ) + + def test_sanitize_strips_em_dash_and_markdown(self): + raw = "I am excited — **furthermore** I built APIs" + cleaned = sanitize_answer( + raw, + field_type="textarea", + forbidden_patterns=["—", "Furthermore", "I am excited", "I am passionate"], + ) + self.assertNotIn("—", cleaned) + self.assertNotIn("**", cleaned) + self.assertNotIn("I am excited", cleaned.lower()) + + def test_sanitize_radio_fuzzy_match(self): + cleaned = sanitize_answer( + "yes please", + field_type="radio", + options=["Yes", "No"], + ) + self.assertEqual(cleaned, "Yes") + + def test_fuzzy_match_option_exact(self): + self.assertEqual(fuzzy_match_option("yes", ["Yes", "No"]), "Yes") + + def test_fuzzy_match_option_substring(self): + self.assertEqual( + fuzzy_match_option("Native", ["Native or bilingual", "Elementary"]), + "Native or bilingual", + ) + + def test_coerce_checkbox(self): + self.assertTrue(coerce_checkbox_answer("yes")) + self.assertTrue(coerce_checkbox_answer(True)) + self.assertFalse(coerce_checkbox_answer("no")) + + +class TestFlatten(unittest.TestCase): + def test_flatten_dedupes_normalized_labels(self): + pairs = flatten_user_inputs(SAMPLE_CONTEXT["user_inputs"]) + questions = [q.lower() for q, _ in pairs] + self.assertEqual(len(questions), len(set(questions))) + self.assertTrue(any("node.js" in q for q in questions)) + + +class TestRules(unittest.TestCase): + def setUp(self): + # Skip embedding model download in unit tests + with patch.object(AnswerEngine, "_ensure_embedding_index", lambda self, force=False: None): + self.engine = AnswerEngine(SAMPLE_CONTEXT, SAMPLE_CONTEXT["aiSettings"]) + self.engine._embeddings = None + self.engine._index_built = True + + def test_rule_years_with_nodejs(self): + suggestion = self.engine.suggest( + "How many years of work experience do you have with Node.js?", + field_type="text", + ) + # Exact match from user_inputs should win first + self.assertEqual(str(suggestion.answer), "4") + self.assertEqual(suggestion.source, "exact") + + def test_rule_years_unknown_skill_via_rules(self): + # Avoid exact/retrieval by using a novel phrasing and empty retrieval + with patch.object(self.engine, "_semantic_retrieve", return_value=None): + with patch.object(self.engine, "_exact_match", return_value=None): + suggestion = self.engine.suggest( + "How many years of work experience do you have with COBOL?", + field_type="text", + ) + self.assertEqual(suggestion.source, "rules") + self.assertEqual(str(suggestion.answer), "0") + + def test_rule_email(self): + with patch.object(self.engine, "_semantic_retrieve", return_value=None): + with patch.object(self.engine, "_exact_match", return_value=None): + suggestion = self.engine.suggest("Email address", field_type="text") + self.assertEqual(suggestion.answer, "sendmessage@gabo.email") + self.assertEqual(suggestion.source, "rules") + + def test_rule_notice_period(self): + with patch.object(self.engine, "_semantic_retrieve", return_value=None): + with patch.object(self.engine, "_exact_match", return_value=None): + suggestion = self.engine.suggest("Notice period in number of days?", field_type="text") + self.assertEqual(str(suggestion.answer), "30") + self.assertEqual(suggestion.source, "rules") + + def test_detect_german_vs_english(self): + from answer_engine import detect_application_language, resolve_resume_path + + self.assertEqual(detect_application_language("Upload your Lebenslauf bitte"), "de") + self.assertEqual(detect_application_language("Please upload your resume"), "en") + self.assertEqual(detect_application_language("asdf qwer"), "en") + + def test_resolve_resume_language(self): + from answer_engine import resolve_resume_path + import tempfile + from pathlib import Path + + with tempfile.TemporaryDirectory() as tmp: + en = Path(tmp) / "en.pdf" + de = Path(tmp) / "de.pdf" + en.write_bytes(b"%PDF") + de.write_bytes(b"%PDF") + ctx = {"resumes": {"en": str(en), "de": str(de), "default": "en"}} + self.assertTrue(resolve_resume_path(ctx, "de").endswith("de.pdf")) + self.assertTrue(resolve_resume_path(ctx, "en").endswith("en.pdf")) + self.assertTrue(resolve_resume_path(ctx, None).endswith("en.pdf")) + + def test_rule_salary(self): + with patch.object(self.engine, "_semantic_retrieve", return_value=None): + with patch.object(self.engine, "_exact_match", return_value=None): + suggestion = self.engine.suggest("What is your salary expectation?", field_type="text") + self.assertEqual(str(suggestion.answer), "90000") + self.assertEqual(suggestion.source, "rules") + + def test_rule_sponsorship(self): + with patch.object(self.engine, "_semantic_retrieve", return_value=None): + with patch.object(self.engine, "_exact_match", return_value=None): + suggestion = self.engine.suggest( + "Will you now or in the future require sponsorship for employment visa status?", + field_type="radio", + options=["Yes", "No"], + ) + self.assertEqual(suggestion.answer, "Yes") + self.assertEqual(suggestion.source, "rules") + + def test_rule_language_proficiency(self): + with patch.object(self.engine, "_semantic_retrieve", return_value=None): + with patch.object(self.engine, "_exact_match", return_value=None): + suggestion = self.engine.suggest( + "What is your level of proficiency in English?", + field_type="select", + options=["Elementary", "Limited working", "Native or bilingual"], + ) + self.assertEqual(suggestion.answer, "Native or bilingual") + self.assertEqual(suggestion.source, "rules") + + +class TestRetrieval(unittest.TestCase): + def test_semantic_retrieve_returns_known_answer(self): + with patch.object(AnswerEngine, "_ensure_embedding_index", lambda self, force=False: None): + engine = AnswerEngine(SAMPLE_CONTEXT, SAMPLE_CONTEXT["aiSettings"]) + + # Fake embeddings: identity-ish vectors for each pair + n = len(engine.pairs) + engine._embeddings = np.eye(n, dtype=float) + engine._index_built = True + + # Mock embedder to return the vector for the Node.js question index + node_idx = next(i for i, (q, _) in enumerate(engine.pairs) if "node.js" in q.lower()) + mock_embedder = MagicMock() + mock_embedder.encode.return_value = np.array([engine._embeddings[node_idx]]) + + with patch.object(engine, "_get_embedder", return_value=mock_embedder): + with patch.object(engine, "_exact_match", return_value=None): + suggestion = engine.suggest( + "Years of professional Node.js experience?", + field_type="text", + ) + + self.assertEqual(suggestion.source, "retrieval") + self.assertEqual(str(suggestion.answer), "4") + + +class TestLLMFallback(unittest.TestCase): + def setUp(self): + with patch.object(AnswerEngine, "_ensure_embedding_index", lambda self, force=False: None): + self.engine = AnswerEngine(SAMPLE_CONTEXT, SAMPLE_CONTEXT["aiSettings"]) + self.engine._embeddings = None + self.engine._index_built = True + + def test_ollama_then_transformers_fallback(self): + with patch.object(self.engine, "_exact_match", return_value=None): + with patch.object(self.engine, "_semantic_retrieve", return_value=None): + with patch.object(self.engine, "_rule_based_answer", return_value=None): + with patch.object(self.engine, "_generate_ollama", return_value=None) as ollama: + with patch.object( + self.engine, + "_generate_transformers", + return_value="I built several React apps.", + ) as transformers: + suggestion = self.engine.suggest( + "Describe a project you are proud of", + field_type="textarea", + ) + + ollama.assert_called_once() + transformers.assert_called_once() + self.assertEqual(suggestion.source, "transformers") + self.assertIsNotNone(suggestion.answer) + self.assertNotIn("—", str(suggestion.answer)) + + def test_ollama_success_skips_transformers(self): + with patch.object(self.engine, "_exact_match", return_value=None): + with patch.object(self.engine, "_semantic_retrieve", return_value=None): + with patch.object(self.engine, "_rule_based_answer", return_value=None): + with patch.object( + self.engine, "_generate_ollama", return_value="4 years with React" + ): + with patch.object(self.engine, "_generate_transformers") as transformers: + suggestion = self.engine.suggest( + "Tell us about your React experience briefly", + field_type="textarea", + ) + + transformers.assert_not_called() + self.assertEqual(suggestion.source, "ollama") + self.assertIn("React", str(suggestion.answer)) + + def test_file_field_skipped(self): + suggestion = self.engine.suggest("Upload resume", field_type="file") + self.assertEqual(suggestion.source, "none") + self.assertIsNone(suggestion.answer) + + def test_no_llm_returns_none_source(self): + with patch.object(self.engine, "_exact_match", return_value=None): + with patch.object(self.engine, "_semantic_retrieve", return_value=None): + with patch.object(self.engine, "_rule_based_answer", return_value=None): + with patch.object(self.engine, "_generate_ollama", return_value=None): + with patch.object(self.engine, "_generate_transformers", return_value=None): + suggestion = self.engine.suggest( + "Invent a brand new obscure question xyzzy", + field_type="text", + ) + self.assertEqual(suggestion.source, "none") + + +class TestYearsParsing(unittest.TestCase): + def test_parse_date_range(self): + years = AnswerEngine._parse_years_from_date_range("Nov 2022 - Apr 2024") + self.assertEqual(years, 1) + + def test_parse_present(self): + years = AnswerEngine._parse_years_from_date_range("Oct 2021 - Present") + self.assertGreaterEqual(years, 4) + + +if __name__ == "__main__": + unittest.main() diff --git a/test_indeed_bot.py b/test_indeed_bot.py new file mode 100644 index 0000000..ffdc617 --- /dev/null +++ b/test_indeed_bot.py @@ -0,0 +1,223 @@ +"""Unit tests for Indeed Easy Apply bot helpers (no live browser).""" + +import json +import unittest +from pathlib import Path +from unittest.mock import MagicMock, patch + +from indeed_bot import EasyApplyIndeed + + +SAMPLE_CONFIG = { + "email": "test@example.com", + "password": "secret", + "keywords": ["TypeScript", "React"], + "keywordsToAvoid": ["Java"], + "locations": ["Switzerland"], + "driver_path": "/usr/local/bin/geckodriver", + "sortBy": "R", + "filters": {}, + "aiSettings": {"enabled": False}, + "user_inputs": {}, + "resumes": { + "en": str(Path("resumes/Resume_Gabriel_Clemente.pdf").resolve()), + "de": str(Path("resumes/Lebenslauf_Gabriel_Clemente.pdf").resolve()), + "default": "en", + }, + "indeed": { + "enabled": True, + "baseUrl": "https://ch.indeed.com", + "locations": ["Zurich", "Zug"], + "filters": { + "easyApplyOnly": True, + "fromage": 7, + "remotejob": True, + }, + }, +} + + +class TestIndeedUrlBuilder(unittest.TestCase): + def setUp(self): + with patch("base_easy_apply.webdriver.Firefox"): + with patch("base_easy_apply.FirefoxService"): + self.bot = EasyApplyIndeed(SAMPLE_CONFIG) + + def test_construct_url_includes_iafilter_and_location(self): + url = self.bot.construct_url(start=0) + self.assertIn("ch.indeed.com/jobs?", url) + self.assertIn("iafilter=1", url) + self.assertIn("l=Zurich", url) + self.assertIn("fromage=7", url) + self.assertIn("remotejob=1", url) + self.assertIn("TypeScript", url) + + def test_construct_url_pagination(self): + url = self.bot.construct_url(start=20) + self.assertIn("start=20", url) + + def test_answer_bucket(self): + self.assertEqual(self.bot._current_answer_bucket(), "Indeed:Zurich") + self.bot.current_location_index = 1 + self.assertEqual(self.bot._current_answer_bucket(), "Indeed:Zug") + + +class TestIndeedSelectorsJson(unittest.TestCase): + def setUp(self): + path = Path("indeed_selectors.json") + self.data = json.loads(path.read_text(encoding="utf-8")) + + def test_version_and_apply_with_indeed(self): + self.assertGreaterEqual(self.data.get("version", 0), 2) + texts = self.data["apply"]["applyButtonTexts"] + self.assertIn("Apply with Indeed", texts) + + def test_continue_prefers_data_testid(self): + cont = self.data["apply"]["continueButton"] + self.assertEqual(cont[0], "button[data-testid='continue-button']") + + def test_apply_button_prefers_indeedApplyButton(self): + btns = self.data["apply"]["applyButton"] + self.assertEqual(btns[0], "#indeedApplyButton") + + def test_resume_selectors_present(self): + resume = self.data["apply"]["resume"] + self.assertIn("form[data-testid='resume-selection-form']", resume["form"]) + self.assertTrue( + any("resume-selection-file-resume-radio-card-file-input" in s for s in resume["fileInput"]) + ) + + def test_search_form_inputs(self): + form = self.data["search"]["searchForm"] + self.assertIn("#text-input-what", form["whatInput"]) + self.assertIn("#text-input-where", form["whereInput"]) + + +class TestIndeedHelpers(unittest.TestCase): + def setUp(self): + with patch("base_easy_apply.webdriver.Firefox"): + with patch("base_easy_apply.FirefoxService"): + self.bot = EasyApplyIndeed(SAMPLE_CONFIG) + self.bot.driver = MagicMock() + + def test_card_has_easily_apply_german(self): + card = MagicMock() + card.text = "Software Engineer\nFirma AG\nEinfach bewerben" + self.assertTrue(self.bot._card_has_easily_apply(card)) + + def test_card_has_easily_apply_english(self): + card = MagicMock() + card.text = "Software Engineer\nFirma AG\nEasily apply" + self.assertTrue(self.bot._card_has_easily_apply(card)) + + def test_card_missing_badge(self): + card = MagicMock() + card.text = "Software Engineer\nFirma AG\nAuf Unternehmenswebsite bewerben" + self.assertFalse(self.bot._card_has_easily_apply(card)) + + def test_is_indeed_apply_from_widget(self): + widget = MagicMock() + widget.is_displayed.return_value = True + self.bot.driver.find_element.return_value = widget + self.bot.driver.page_source = "" + self.bot.driver.current_url = "https://ch.indeed.com/viewjob?jk=abc" + self.assertTrue(self.bot.is_indeed_apply()) + + def test_is_indeed_apply_from_page_source(self): + self.bot.driver.find_element.side_effect = Exception("missing") + self.bot.driver.page_source = "" + self.bot.driver.current_url = "https://ch.indeed.com/viewjob?jk=abc" + self.assertTrue(self.bot.is_indeed_apply()) + + def test_is_indeed_apply_external_false(self): + self.bot.driver.page_source = "greenhouse apply" + self.bot.driver.current_url = "https://boards.greenhouse.io/foo" + self.bot.driver.find_element.side_effect = Exception("missing") + self.bot.driver.find_elements.return_value = [] + self.assertFalse(self.bot.is_indeed_apply()) + + def test_find_button_by_texts(self): + btn = MagicMock() + btn.is_displayed.return_value = True + btn.text = "Weiter" + btn.get_attribute.side_effect = lambda k: "" if k != "aria-label" else "" + self.bot.driver.find_elements.return_value = [btn] + found = self.bot._find_button_by_texts(["Continue", "Weiter"]) + self.assertIs(found, btn) + + def test_close_application_switches_tab(self): + self.bot.search_window = "search" + self.bot.driver.window_handles = ["search", "apply"] + self.bot.driver.current_window_handle = "apply" + self.bot.driver.find_element.side_effect = Exception("no exit") + self.bot.close_application() + self.bot.driver.close.assert_called() + self.bot.driver.switch_to.window.assert_called_with("search") + + def test_resume_selection_uses_existing_matching_pdf(self): + form = MagicMock() + label = MagicMock() + label.text = "Resume_Gabriel_Clemente.pdf" + radio = MagicMock() + radio.is_selected.return_value = True + + def find_element(by, sel): + if "resume-selection-form" in sel: + return form + if "radio-card-label" in sel: + return label + if "radio-card-input" in sel: + return radio + raise Exception(sel) + + self.bot.driver.find_element.side_effect = find_element + with patch.object( + self.bot, + "_desired_resume_path", + return_value="/tmp/Resume_Gabriel_Clemente.pdf", + ): + handled = self.bot._handle_resume_selection_step() + self.assertTrue(handled) + + def test_resume_selection_uploads_when_wrong_language_on_profile(self): + form = MagicMock() + label = MagicMock() + label.text = "Lebenslauf_Gabriel_Clemente.pdf" + file_input = MagicMock() + + def find_element(by, sel): + if "resume-selection-form" in sel: + return form + if "radio-card-label" in sel: + return label + raise Exception(sel) + + self.bot.driver.find_element.side_effect = find_element + with patch.object( + self.bot, + "_desired_resume_path", + return_value="/tmp/Resume_Gabriel_Clemente.pdf", + ), patch.object( + self.bot, + "_configured_resume_names", + return_value=[ + "Resume_Gabriel_Clemente.pdf", + "Lebenslauf_Gabriel_Clemente.pdf", + ], + ), patch.object( + self.bot, + "_find_first_any", + return_value=file_input, + ): + handled = self.bot._handle_resume_selection_step() + self.assertTrue(handled) + file_input.send_keys.assert_called_once_with("/tmp/Resume_Gabriel_Clemente.pdf") + + def test_card_job_key(self): + card = MagicMock() + card.get_attribute.return_value = "abc123" + self.assertEqual(self.bot._card_job_key(card), "abc123") + + +if __name__ == "__main__": + unittest.main() diff --git a/unit_tests.py b/unit_tests.py index 0401d34..bf473e4 100644 --- a/unit_tests.py +++ b/unit_tests.py @@ -1,12 +1,16 @@ import unittest -from unittest.mock import patch, MagicMock -from easy_apply_linkedin import EasyApplyLinkedin +from unittest.mock import MagicMock, patch + +from selenium.common.exceptions import NoSuchElementException + +from main import EasyApplyLinkedin + class TestEasyApplyLinkedin(unittest.TestCase): def setUp(self): self.data = { - "email": "sendmessage@gabo.email", - "password": "bp8v9fvk#?QaKe7", + "email": "test@example.com", + "password": "secret", "keywords": ["TypeScript", "Angular", "React"], "keywordsToAvoid": ["C++", ".NET"], "locations": ["Switzerland", "Belgium"], @@ -18,38 +22,44 @@ def setUp(self): "jobType": ["Full-time", "Contract"], "timePostedRange": [], "workplaceType": ["Remote", "Hybrid"], - "less_than_10_applicants": False - } + "less_than_10_applicants": False, + }, + "aiSettings": {"enabled": False}, + "user_inputs": {}, } - self.bot = EasyApplyLinkedin(self.data) - - @patch('easy_apply_linkedin.webdriver.Firefox') - def test_login_linkedin(self, MockWebDriver): - mock_driver = MockWebDriver.return_value - mock_driver.find_element.return_value = MagicMock() - self.bot.login_linkedin() - mock_driver.get.assert_called_with("https://www.linkedin.com/login") - self.assertTrue(mock_driver.find_element.called) - - @patch('easy_apply_linkedin.webdriver.Firefox') - def test_construct_url(self, MockWebDriver): + with patch("base_easy_apply.webdriver.Firefox"): + with patch("base_easy_apply.FirefoxService"): + self.bot = EasyApplyLinkedin(self.data) + self.bot.driver = MagicMock() + + def test_login_linkedin(self): + self.bot.driver.find_element.return_value = MagicMock() + with patch("main.WebDriverWait") as mock_wait: + mock_wait.return_value.until.return_value = MagicMock() + self.bot.login_linkedin() + self.bot.driver.get.assert_called_with("https://www.linkedin.com/login") + self.assertTrue(self.bot.driver.find_element.called) + + def test_construct_url(self): url = self.bot.construct_url() - self.assertIn("keywords=TypeScript%20OR%20Angular%20OR%20React", url) + self.assertIn("TypeScript", url) + self.assertIn("Angular", url) + self.assertIn("React", url) self.assertIn("geoId=106693272", url) self.assertIn("f_AL=true", url) - @patch('easy_apply_linkedin.webdriver.Firefox') - def test_apply_filters_and_search_no_results(self, MockWebDriver): - mock_driver = MockWebDriver.return_value - mock_driver.find_element.side_effect = NoSuchElementException - self.bot.apply_filters_and_search() - self.assertEqual(self.bot.current_location_index, 1) + def test_apply_filters_and_search_no_results(self): + with patch.object(self.bot, "check_no_results", return_value=True), patch( + "main.time.sleep" + ): + self.bot.apply_filters_and_search() + self.assertEqual(self.bot.current_location_index, 2) - @patch('easy_apply_linkedin.webdriver.Firefox') - def test_log_error(self, MockWebDriver): + def test_log_error(self): self.bot.log_error("Test error") errors = self.bot.load_json(self.bot.ERROR_LOG_PATH) self.assertTrue(any("Test error" in v for v in errors.values())) + if __name__ == "__main__": unittest.main()