diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7e862f5 --- /dev/null +++ b/.gitignore @@ -0,0 +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 +!test_answer_engine.py +!test_indeed_bot.py \ 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..d478421 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,264 @@ # 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` +1. Install Selenium. Use `pip` to install the Selenium package: + ```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 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 + +1. Clone the repository: + ```sh + git clone https://github.com/Gabo-Tech/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"], + "keywordsToAvoid": ["C++", ".NET"], + "locations": ["New York", "Los Angeles", "San Francisco"], + "driver_path": "/usr/local/bin/geckodriver", + "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"], + "workplaceType": ["Remote", "Hybrid", "On-site"], + "less_than_10_applicants": true + } + } + ``` + +4. Update the location codes in the script: + ```python + LOCATION_MAPPING = { + "Canada": "101174742", + "Portugal": "100364837", + "Switzerland": "106693272", + "United States": "103644278", + "Belgium": "100565514", + "Netherlands": "102890719", + "DACH": "91000006", + "Benelux": "91000005", + "European Union": "91000000", + "European Economic Area": "91000002", + "Germany": "101282230", + "Spain": "105646813", + "United Kingdom": "101165590" + } + ``` + 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 -Fork and clone/download the repository and change the configuration file with: +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 + +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"). +- **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.). +- **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 + +#### 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 +``` + +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. + +Run the E2E tests: +```bash +pytest e2e_tests.py +``` + +### 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](https://github.com/Gabo-Tech/EasyApply-Linkedin/blob/master/LICENCE) file for details. \ No newline at end of file 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/config.json b/config.json deleted file mode 100644 index 0b432a4..0000000 --- a/config.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "email" : "your_email", - "password" : "your_password", - "keywords" : "your_keywords", - "location" : "your_location", - "driver_path" : "your_path_to_webdriver" -} \ No newline at end of file diff --git a/configExample.json b/configExample.json new file mode 100644 index 0000000..8090c6a --- /dev/null +++ b/configExample.json @@ -0,0 +1,291 @@ +{ + "email": "example@domain.com", + "password": "YourSecurePassword123!", + "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", + "Robotic", + "Data", + "Science", + "Cloud", + "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": "/path/to/geckodriver", + "sortBy": "R", + "filters": { + "easy_apply": true, + "experience": [], + "jobType": [ + "Full-time", + "Contract" + ], + "timePostedRange": [], + "workplaceType": [ + "Remote", + "Hybrid" + ], + "less_than_10_applicants": false + }, + "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", + "On-site" + ], + "jobType": "Contract", + "jobTypeAlternative": [ + "Full-time" + ], + "prereferredEnd": "Backend", + "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 + } + }, + "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" + ] + }, + "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", + "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/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/e2e_tests.py b/e2e_tests.py new file mode 100644 index 0000000..a17eff9 --- /dev/null +++ b/e2e_tests.py @@ -0,0 +1,113 @@ +import pytest +from selenium import webdriver +from selenium.webdriver.firefox.service import Service as FirefoxService +from main 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": "***,****,****", + "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" + ], + "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/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 0232efa..7ff5350 100644 --- a/main.py +++ b/main.py @@ -1,179 +1,718 @@ -from selenium import webdriver +import argparse +import json +import time +import urllib.parse +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 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", + "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" + } + + 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", + } -class EasyApplyLinkedin: + LOCATION_MAPPING = { + "Texas": "102748797", + "Canada": "101174742", + "Portugal": "100364837", + "Switzerland": "106693272", + "United States": "103644278", + "Belgium": "100565514", + "Netherlands": "102890719", + "DACH": "91000006", + "Benelux": "91000005", + "European Union": "91000000", + "European Economic Area": "91000002", + "Germany": "101282230", + "Spain": "105646813", + "United Kingdom": "101165590", + } def __init__(self, data): - """Parameter initialization""" + super().__init__(data, start_driver=True) + self.collection = data.get("collection", "") + self.sort_by = data["sortBy"] + self.locations = data["locations"] + self.filters = data["filters"] - self.email = data['email'] - self.password = data['password'] - self.keywords = data['keywords'] - self.location = data['location'] - self.driver = webdriver.Chrome(data['driver_path']) + 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): - """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) - + 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")) + ) + 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() + 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) + 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() + search_keywords.send_keys(Keys.RETURN) - 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) + time.sleep(5) + if not self.check_no_results(): + break + else: + self.log_info(f"No matching jobs found in {self.locations[self.current_location_index]}.") + self.current_location_index += 1 - # 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) + except TimeoutException: + 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}") + self.current_location_index += 1 + + def construct_url(self): + current_location = self.locations[self.current_location_index] + combined_keywords = f'{self.keywords} NOT {self.keywords_to_avoid}' + + params = { + "keywords": combined_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): + while self.current_location_index < len(self.locations): + search_url = self.construct_url() + self.driver.get(search_url) + time.sleep(5) + + if self.check_no_results(): + self.log_info(f"No matching jobs found in {self.locations[self.current_location_index]}.") + self.current_location_index += 1 + else: + break + + def check_no_results(self): + 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 find_offers(self): + if self.collection: + self.apply_collection() else: - self.close_session() + 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: + 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) - def submit_apply(self,job_add): - """This function submits the application for the job add found""" + except NoSuchElementException: + self.log_info("No apply button found, continuing to next job...") + continue - 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 + 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 + + 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): 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 + + 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") + ) + ) + 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) + self.log_info("Application submitted.") + self.handle_done_button() + break + except NoSuchElementException: + self.log_info("Submit button not found, continuing to next job...") + self.close_application_modal() + break + self.fill_form(modal_dialog) + except TimeoutException: + self.log_info("No more steps found, exiting...") + break + except Exception as e: + 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 + + def fill_form(self, modal_dialog): + 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]" + ) + for element in form_elements: + try: + 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) + 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:]) + 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") + + 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 == "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) + + 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 to submit if submit application is available... 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 + next_button = modal_dialog.find_element(By.CSS_SELECTOR, "button[data-easy-apply-next-button]") + next_button.click() + time.sleep(2) except NoSuchElementException: - print('Not direct application, going to next...') + self.log_info("Next button not found, form might be complete or there is an issue.") + + def handle_checkboxes(self, element): + checkboxes = element.find_elements(By.CSS_SELECTOR, "input[type='checkbox']") + for index in range(len(checkboxes)): + checkbox_label = None 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 - - time.sleep(1) - - def close_session(self): - """This function closes the actual session""" - - print('End of the session, see you later!') - self.driver.close() - - def apply(self): - """Apply to job offers""" - - 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() + 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) + 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): + 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): + 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 handle_done_button(self): + 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: + self.log_info("Done button not found, skipping to next job.") + + def close_application_modal(self): + 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: + self.log_info("Close button not found, skipping to next job.") + + def handle_discard_dialog(self): + 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: + self.log_info("Discard button not found, skipping to next job.") -if __name__ == '__main__': +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() - with open('config.json') as config_file: + with open("config.json") as config_file: data = json.load(config_file) - bot = EasyApplyLinkedin(data) - bot.apply() \ No newline at end of file + 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 new file mode 100644 index 0000000..8364cb9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,30 @@ +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 +iniconfig==1.1.1 +packaging==21.0 +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 +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 new file mode 100644 index 0000000..bf473e4 --- /dev/null +++ b/unit_tests.py @@ -0,0 +1,65 @@ +import unittest +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": "test@example.com", + "password": "secret", + "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, + }, + "aiSettings": {"enabled": False}, + "user_inputs": {}, + } + 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("TypeScript", url) + self.assertIn("Angular", url) + self.assertIn("React", url) + self.assertIn("geoId=106693272", url) + self.assertIn("f_AL=true", url) + + 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) + + 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()