├── requirements.txt ├── assets └── Readme.png ├── config.yaml ├── LICENSE ├── README.md ├── .gitignore └── indeed_bot.py /requirements.txt: -------------------------------------------------------------------------------- 1 | camoufox -------------------------------------------------------------------------------- /assets/Readme.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meteor314/indeed_bot/HEAD/assets/Readme.png -------------------------------------------------------------------------------- /config.yaml: -------------------------------------------------------------------------------- 1 | # Indeed Auto-Apply Bot Configuration 2 | # Edit these fields to customize your job search and application process 3 | 4 | 5 | search: 6 | base_url: "https://fr.indeed.com/jobs?q=developer&l=Ile-de-France&sc=0kf%3Aattr%285QWDV%29%3B&radius=25" 7 | 8 | start: 0 9 | end: 100 # default is 100, you can set it to 1000 or more for larger searches (not recommanded though) 10 | 11 | camoufox: 12 | user_data_dir: "user_data_dir" 13 | language: "fr" # uk, us, fr, de, es, it, nl, pt, pl, ro, tr, ru, cn etc... 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License @meteor314 2 | =========== 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Indeed Auto-Apply Bot 2 | 3 | **WARNING:** 4 | This guide explains how to use this bot. Use at your own risk. Indeed may change their website or introduce new protections (such as captchas or anti-bot measures) at any time, which could break this tool or result in your account being restricted. This is for educational purposes only. 5 | 6 | --- 7 | 8 | ## Features 9 | 10 | - Automatically finds and applies to jobs on Indeed with "Indeed Apply" . 11 | - Uses Camoufox for browser automation (bypass Cloudfare, Captch bot) 12 | - Handles multi-step application forms, including resume upload and personal info. 13 | 14 | ## Prerequisites 15 | 16 | - Python 3.8+ 17 | - [Camoufox](https://github.com/meteor314/camoufox) installed and configured 18 | - An Indeed account with: 19 | - Your CV already uploaded 20 | - Your name, address, and phone number filled in your Indeed profile 21 | 22 | --- 23 | 24 | ## Setup 25 | 26 | 1. **Clone this repository** and install dependencies: 27 | ```bash 28 | pip install -r requirements.txt 29 | ``` 30 | 31 | 2. **Edit `config.yaml`:** 32 | 33 | Example: 34 | ```yaml 35 | camoufox: 36 | user_data_dir: "user_data_dir" # by default, no need to change this value 37 | language: "fr" # or "uk", "de", etc. make sure to update this value 38 | 39 | search: 40 | base_url: "https://fr.indeed.com/jobs?q=python+developer&l=Paris" 41 | start: 0 42 | end: 100 43 | ``` 44 | 45 | - `user_data_dir`: Path to your Chrome user data directory (to keep your Indeed session). 46 | - `language`: Your Indeed site language code (e.g., "fr" for France, "uk" for United Kingdom) etc.. 47 | - `base_url`: The Indeed search URL for your job search. 48 | - `start`/`end`: Pagination range (should be multiples of 10). 49 | 50 | 3. **How to get your `base_url`:** 51 | 52 | - Go to [Indeed](https://www.indeed.com/) in your browser. 53 | - Select your search options (job title, location, remote working, type of work, etc.). 54 | - Click on **Find jobs**. 55 | - Copy the URL from your browser's address bar. 56 | - Paste this URL as the value for `base_url` in your `config.yaml`. 57 | 58 | ![How to get your base_url](assets/Readme.png) 59 | 60 | 4. **Upload your CV to Indeed:** 61 | - Go to your Indeed profile and upload your CV. 62 | - Make sure your name, address, and phone number are filled in. 63 | - This bot will use this information to apply for jobs. So make sure they are filled in correctly otherwise the bot will not be able to apply for jobs. 64 | 65 | --- 66 | 67 | ## First Run 68 | 69 | 1. **Login to Indeed manually:** 70 | - Run the bot: 71 | ```bash 72 | python indeed_bot.py 73 | ``` 74 | - If not logged in, the bot will open Indeed and prompt you to log in manually. 75 | - After logging in, close the bot and restart it. 76 | 77 | 2. **Run the bot again:** 78 | - The bot will now use your saved session to search and apply for jobs. 79 | - All your session data (cookies, login info) will be preserved in the `user_data_dir` specified in `config.yaml`. 80 | 81 | 82 | ## Usage 83 | 84 | - The bot will: 85 | - Visit each search results page. 86 | - Collect all jobs with "Indeed Apply". 87 | - For each job: 88 | - Open the job page in a new tab. 89 | - Click "Apply" or "Postuler maintenant". 90 | - Step through the application wizard, selecting your uploaded CV and clicking "Continue"/"Submit". 91 | - Log the result in `indeed_apply.log`. 92 | 93 | 94 | ## Notes & Limitations 95 | 96 | - This bot only works for jobs with "Indeed Apply" (Candidature simplifiée). 97 | - If you encounter captchas or anti-bot protections, this bot should handle them automatically, but you may need to solve them manually. 98 | - Indeed may change their website at any time, which could break this bot. 99 | - Use responsibly and do not spam applications. 100 | - This program is a guide on how to automate job applications, you need to make some modifications to the code to make it work for your needs. 101 | 102 | --- 103 | 104 | ## Troubleshooting 105 | 106 | - If the bot gets stuck or fails to apply: 107 | - Check `indeed_apply.log` for errors. 108 | - Make sure your CV and personal info are uploaded to Indeed. 109 | - Try increasing wait times if your internet is slow. 110 | 111 | --- 112 | 113 | ## Disclaimer 114 | 115 | This project is not affiliated with Indeed. Use at your own risk. 116 | 117 | ## License 118 | This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[codz] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py.cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # UV 98 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | #uv.lock 102 | 103 | # poetry 104 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 105 | # This is especially recommended for binary packages to ensure reproducibility, and is more 106 | # commonly ignored for libraries. 107 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 108 | #poetry.lock 109 | #poetry.toml 110 | 111 | # pdm 112 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 113 | # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. 114 | # https://pdm-project.org/en/latest/usage/project/#working-with-version-control 115 | #pdm.lock 116 | #pdm.toml 117 | .pdm-python 118 | .pdm-build/ 119 | 120 | # pixi 121 | # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. 122 | #pixi.lock 123 | # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one 124 | # in the .venv directory. It is recommended not to include this directory in version control. 125 | .pixi 126 | 127 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 128 | __pypackages__/ 129 | 130 | # Celery stuff 131 | celerybeat-schedule 132 | celerybeat.pid 133 | 134 | # SageMath parsed files 135 | *.sage.py 136 | 137 | # Environments 138 | .env 139 | .envrc 140 | .venv 141 | env/ 142 | venv/ 143 | ENV/ 144 | env.bak/ 145 | venv.bak/ 146 | 147 | # Spyder project settings 148 | .spyderproject 149 | .spyproject 150 | 151 | # Rope project settings 152 | .ropeproject 153 | 154 | # mkdocs documentation 155 | /site 156 | 157 | # mypy 158 | .mypy_cache/ 159 | .dmypy.json 160 | dmypy.json 161 | 162 | # Pyre type checker 163 | .pyre/ 164 | 165 | # pytype static type analyzer 166 | .pytype/ 167 | 168 | # Cython debug symbols 169 | cython_debug/ 170 | 171 | # PyCharm 172 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 173 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 174 | # and can be added to the global gitignore or merged into this file. For a more nuclear 175 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 176 | #.idea/ 177 | 178 | # Abstra 179 | # Abstra is an AI-powered process automation framework. 180 | # Ignore directories containing user credentials, local state, and settings. 181 | # Learn more at https://abstra.io/docs 182 | .abstra/ 183 | 184 | # Visual Studio Code 185 | # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore 186 | # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore 187 | # and can be added to the global gitignore or merged into this file. However, if you prefer, 188 | # you could uncomment the following to ignore the entire vscode folder 189 | # .vscode/ 190 | 191 | # Ruff stuff: 192 | .ruff_cache/ 193 | 194 | # PyPI configuration file 195 | .pypirc 196 | 197 | # Marimo 198 | marimo/_static/ 199 | marimo/_lsp/ 200 | __marimo__/ 201 | 202 | # Streamlit 203 | .streamlit/secrets.toml 204 | 205 | #vim stuff 206 | *.swo 207 | *.swp 208 | 209 | # indeed_bot specific files 210 | logs.txt 211 | user_data_dir/ -------------------------------------------------------------------------------- /indeed_bot.py: -------------------------------------------------------------------------------- 1 | """ 2 | Indeed Auto-Apply Bot 3 | --------------------- 4 | Automates job applications on Indeed using Camoufox. 5 | 6 | Usage: 7 | - Configure your search and Chrome settings in config.yaml 8 | - Run: python indeed_bot.py 9 | 10 | Author: @meteor314 11 | License: MIT 12 | """ 13 | import yaml 14 | import time 15 | from datetime import datetime 16 | from typing import Dict, Any 17 | from camoufox.sync_api import Camoufox 18 | import logging 19 | 20 | 21 | with open("config.yaml", "r") as f: 22 | config = yaml.safe_load(f) 23 | camoufox_config = config.get("camoufox", {}) 24 | user_data_dir = camoufox_config.get("user_data_dir") 25 | language = camoufox_config.get("language") 26 | 27 | 28 | def collect_indeed_apply_links(page, language): 29 | """Collect all 'Indeed Apply' job links from the current search result page.""" 30 | links = [] 31 | job_cards = page.query_selector_all('div[data-testid="slider_item"]') 32 | for card in job_cards: 33 | indeed_apply = card.query_selector('[data-testid="indeedApply"]') 34 | if indeed_apply: 35 | link = card.query_selector('a.jcs-JobTitle') 36 | if link: 37 | job_url = link.get_attribute('href') 38 | if job_url: 39 | if job_url.startswith('/'): 40 | job_url = f"https://{language}.indeed.com{job_url}" 41 | links.append(job_url) 42 | return links 43 | 44 | 45 | def click_and_wait(element, timeout=5): 46 | if element: 47 | element.click() 48 | time.sleep(timeout) 49 | 50 | 51 | def apply_to_job(browser, job_url, language, logger): 52 | """Open a new tab, apply to the job, log the result, and close the tab.""" 53 | page = browser.new_page() 54 | try: 55 | page.goto(job_url) 56 | page.wait_for_load_state("domcontentloaded") 57 | time.sleep(3) 58 | # Try to find the apply button using robust, language-agnostic selectors 59 | apply_btn = None 60 | for _ in range(20): 61 | # 1. Try button with a span with the unique Indeed Apply class (often css-1ebo7dz) 62 | apply_btn = page.query_selector( 63 | 'button:has(span[class*="css-1ebo7dz"])') 64 | # 2. Fallback: first visible button with a span containing "Postuler" or "Apply" 65 | if not apply_btn: 66 | apply_btn = page.query_selector( 67 | 'button:visible:has-text("Postuler")') 68 | if not apply_btn: 69 | apply_btn = page.query_selector( 70 | 'button:visible:has-text("Apply")') 71 | # 3. Fallback: first visible button on the page (avoid close/cancel if possible) 72 | if not apply_btn: 73 | btns = page.query_selector_all('button:visible') 74 | for btn in btns: 75 | label = (btn.get_attribute("aria-label") or "").lower() 76 | text = (btn.inner_text() or "").lower() 77 | if "close" in label or "cancel" in label or "fermer" in label or "annuler" in label: 78 | continue 79 | if "postuler" in text or "apply" in text or btn.is_visible(): 80 | apply_btn = btn 81 | break 82 | if apply_btn: 83 | break 84 | time.sleep(0.5) 85 | if apply_btn: 86 | click_and_wait(apply_btn, 5) 87 | else: 88 | logger.warning( 89 | f"No Indeed Apply button found for {job_url}") 90 | page.close() 91 | return False 92 | 93 | # add timeout for the wizard loop 94 | start_time = time.time() 95 | while True: 96 | if time.time() - start_time > 40: 97 | logger.warning( 98 | f"Timeout applying to {job_url}, closing tab and moving to next.") 99 | break 100 | current_url = page.url 101 | # Resume step: select resume card if present 102 | resume_card = page.query_selector( 103 | '[data-testid="FileResumeCardHeader-title"]') 104 | if resume_card: 105 | # Click the resume card (or its parent if needed) 106 | try: 107 | resume_card.click() 108 | except Exception: 109 | parent = resume_card.evaluate_handle( 110 | 'node => node.parentElement') 111 | if parent: 112 | parent.click() 113 | time.sleep(1) 114 | continuer_btn = None 115 | btns = page.query_selector_all('button:visible') 116 | for btn in btns: 117 | text = (btn.inner_text() or "").lower() 118 | if "continuer" in text or "continue" in text: 119 | continuer_btn = btn 120 | break 121 | if continuer_btn: 122 | click_and_wait(continuer_btn, 3) 123 | continue # go to next step 124 | 125 | # try to find a submit button ( dynamic text) idk if it's working 126 | submit_btn = None 127 | btns = page.query_selector_all('button:visible') 128 | for btn in btns: 129 | text = (btn.inner_text() or "").lower() 130 | if ( 131 | "déposer ma candidature" in text or 132 | "soumettre" in text or 133 | "submit" in text or 134 | "apply" in text or 135 | "bewerben" in text or # German 136 | "postular" in text # Spanish 137 | ): 138 | submit_btn = btn 139 | break 140 | # fallback: last visible button (often the submit) 141 | if not submit_btn and btns: 142 | submit_btn = btns[-1] 143 | if submit_btn: 144 | click_and_wait(submit_btn, 3) 145 | logger.info(f"Applied successfully to {job_url}") 146 | break 147 | 148 | # fallback: try to find a visible and enabled button to continue (other stesp) 149 | btn = page.query_selector( 150 | 'button[type="button"]:not([aria-disabled="true"]), button[type="submit"]:not([aria-disabled="true"])') 151 | if btn: 152 | click_and_wait(btn, 3) 153 | if "confirmation" in page.url or "submitted" in page.url: 154 | logger.info(f"Applied successfully to {job_url}") 155 | break 156 | else: 157 | logger.warning( 158 | f"No continue/submit button found at {current_url}") 159 | break 160 | page.close() 161 | return True 162 | except Exception as e: 163 | logger.error(f"Error applying to {job_url}: {e}") 164 | page.close() 165 | return False 166 | 167 | 168 | def setup_logger(): 169 | logger = logging.getLogger("indeed_apply") 170 | logger.setLevel(logging.INFO) 171 | fh = logging.FileHandler("indeed_apply.log") 172 | formatter = logging.Formatter('%(asctime)s %(levelname)s: %(message)s') 173 | fh.setFormatter(formatter) 174 | logger.addHandler(fh) 175 | return logger 176 | 177 | 178 | with Camoufox(user_data_dir=user_data_dir, 179 | persistent_context=True) as browser: 180 | logger = setup_logger() 181 | page = browser.new_page() 182 | page.goto("https://" + language + ".indeed.com") 183 | 184 | cookies = page.context.cookies() 185 | ppid_cookie = next( 186 | (cookie for cookie in cookies if cookie['name'] == 'PPID'), None) 187 | if not ppid_cookie: 188 | print("Token not found, please log in to Indeed first.") 189 | print("Redirecting to login page...") 190 | print("You need to restart the bot after logging in.") 191 | page.goto( 192 | "https://secure.indeed.com/auth?hl=" + language) 193 | time.sleep(1000) # wait for manual login 194 | else: 195 | print("Token found, proceeding with job search...") 196 | search_config = config.get("search", {}) 197 | base_url = search_config.get("base_url", "") 198 | start = search_config.get("start", "") 199 | end = search_config.get("end", "") 200 | 201 | listURL = [] 202 | i = start 203 | while i <= end: 204 | url = f"{base_url}&start={i}" 205 | listURL.append(url) 206 | i += 10 207 | 208 | all_job_links = [] 209 | for url in listURL: 210 | print(f"Visiting URL: {url}") 211 | page.goto(url) 212 | page.wait_for_load_state("domcontentloaded") 213 | print( 214 | "Waiting for page to load, if any cloudflare protection button appears... please click it.") 215 | time.sleep(10) 216 | 217 | try: 218 | links = collect_indeed_apply_links(page, language) 219 | all_job_links.extend(links) 220 | print(f"Found {len(links)} Indeed Apply jobs on this page.") 221 | except Exception as e: 222 | print("Error extracting jobs:", e) 223 | time.sleep(5) 224 | 225 | print(f"Total Indeed Apply jobs found: {len(all_job_links)}") 226 | for job_url in all_job_links: 227 | print(f"Applying to: {job_url}") 228 | success = apply_to_job(browser, job_url, language, logger) 229 | if not success: 230 | logger.error(f"Failed to apply to {job_url}") 231 | time.sleep(5) 232 | all_job_links = [] 233 | for url in listURL: 234 | print(f"Visiting URL: {url}") 235 | page.goto(url) 236 | page.wait_for_load_state("domcontentloaded") 237 | time.sleep(7) 238 | try: 239 | links = collect_indeed_apply_links(page, language) 240 | all_job_links.extend(links) 241 | print(f"Found {len(links)} Indeed Apply jobs on this page.") 242 | except Exception as e: 243 | print("Error extracting jobs:", e) 244 | time.sleep(5) 245 | 246 | print(f"Total Indeed Apply jobs found: {len(all_job_links)}") 247 | for job_url in all_job_links: 248 | print(f"Applying to: {job_url}") 249 | success = apply_to_job(browser, job_url, language, logger) 250 | if not success: 251 | logger.error(f"Failed to apply to {job_url}") 252 | time.sleep(5) 253 | --------------------------------------------------------------------------------