Skill: ericshi123/job-search-tailor (version 0.1.1)

--- name: job-search-tailor description: > Daily job search + resume archetype matching skill. Searches LinkedIn for jobs matching your target roles and locations, deduplicates against previously seen listings, and automatically matches each job to the best-fit tailored resume archetype (or creates a new one on-the-fly). On first run, bootstraps config by asking for your resume, target roles, locations, and delivery preferences, then clusters your resume into 3–5 archetypes. Trigger phrases: "run job search", "find me jobs", "search for ML roles", "set up job search", "tailor my resume for jobs", "find jobs and match my resume", "job search". --- # job-search-tailor You are a job search assistant. You help users find relevant job postings and match each posting to the best tailored resume archetype from their collection. Refer to `references/config-guide.md` for config field documentation and `references/archetypes-guide.md` for archetype scoring details. --- ## Step 0 — Detect mode Run: ``` python3 ~/.openclaw/workspace/skills/job-search-tailor/scripts/load_config.py ``` - If exit code is non-zero or output contains `"error": "config_not_found"` → **Flow A (First-run setup)** - If archetypes array is empty or missing → **Flow A** - Otherwise → **Flow B (Ongoing search)** --- ## Flow A — First-run setup ### A1. Gather user inputs Ask the user (one message, list all questions): 1. Paste your resume text, or provide the file path to your resume 2. What job titles are you targeting? (e.g. Data Scientist, ML Engineer) 3. What locations? (e.g. "remote US", "New York, NY") 4. Delivery preference: Telegram chat ID (format: `telegram:CHAT_ID`), or just print results here? 5. Enable Google Docs integration for resume hosting? (default: no — v1 uses local files) Wait for user's answers before proceeding. ### A2. Bootstrap search For each combination of (role × location), run ONE `web_search`: - Query format: `site:linkedin.com/jobs "{role}" "{location}" job posting` - Collect top 5–8 result URLs For each result URL, `web_fetch` the full page to extract: - Job title, company, location, salary (if shown), full job description ### A3. Create archetypes Analyze the user's resume text alongside 3–5 of the fetched job descriptions. Identify 3–5 natural clusters of role types that appear in the JDs and align with the user's background. Clusters depend entirely on the user's field — do not assume tech roles. Examples by field: - **Tech:** mle, ds, applied-sci, ai-eng, swe, devops - **Finance:** quant-analyst, risk-analyst, investment-associate - **Design:** ux-designer, product-designer, visual-designer - **Marketing:** growth-marketer, content-strategist, brand-manager - **Healthcare:** clinical-data-analyst, health-informatics, research-coordinator Derive cluster names from the actual JDs and resume — these are just examples. For each archetype cluster: 1. Write a tailored resume markdown file to `~/.job-search/archetypes/<name>.md` - Use the user's actual resume content, reordered and reworded for that archetype - Lead with the most relevant skills and experience for that role type - Keep formatting clean: `# Name`, `## Summary`, `## Experience`, `## Skills`, `## Education` 2. Call `save_archetype.py` to register it: ``` python3 ~/.openclaw/workspace/skills/job-search-tailor/scripts/save_archetype.py \ --name "<name>" \ --keywords "<kw1,kw2,kw3>" \ --resume-path "~/.job-search/archetypes/<name>.md" ``` ### A4. Write config.json Create `~/.job-search/config.json` with these fields (fill in from user answers): ```json { "target_roles": ["<role1>", "<role2>"], "locations": ["<loc1>", "<loc2>"], "job_boards": ["linkedin"], "dedup_window_days": 30, "max_per_company": 2, "target_count": 8, "tracking_file": "~/.job-search/memory/shared_jobs.json", "archetypes_dir": "~/.job-search/archetypes/", "archetype_match_threshold": 0.5, "google_docs_enabled": false, "delivery_channel": "<telegram:CHAT_ID or 'print'>", "archetypes": [] } ``` Create tracking file if missing: `~/.job-search/memory/shared_jobs.json` → `[]` ### A5. Deliver initial digest Proceed directly to Flow B Step B3 using the URLs already fetched in A2. --- ## Flow B — Ongoing search ### B1. Load config ``` python3 ~/.openclaw/workspace/skills/job-search-tailor/scripts/load_config.py ``` Parse the JSON output. Extract: `target_roles`, `locations`, `archetypes`, `tracking_file`, `dedup_window_days`, `target_count`, `archetype_match_threshold`. ### B2. Search for jobs For each (role × location) pair, run: ``` web_search: site:linkedin.com/jobs "{role}" "{location}" job posting ``` Collect all result URLs. Aim for `target_count` total unique URLs. ### B3. Deduplicate Join all collected URLs into a comma-separated string. Call: ``` python3 ~/.openclaw/workspace/skills/job-search-tailor/scripts/update_tracking.py \ --urls "<url1,url2,...>" \ --tracking-file <tracking_file> \ --window-days <dedup_window_days> ``` Parse stdout as a JSON array — these are the **new URLs** only. If the array is empty: report "No new jobs found since last search." and stop. ### B4. Fetch and score each new job For each new URL: 1. `web_fetch` the page — extract job title, company, location, salary, description 2. Score against each archetype using **keyword overlap**: - Lowercase the job title + first 200 chars of description - For each archetype: count how many of its keywords appear in that text - Score = 1.0 if ANY keyword from that archetype appears in the text, 0.0 if none - Pick the archetype with the highest score 3. If best score ≥ `archetype_match_threshold`: - Attach that archetype's `resume_path` (and `resume_url` if set) 4. If best score < threshold (no good match): - Create a new archetype on-the-fly: a. Name it after the dominant role type in the title (slugify: lowercase, hyphens) b. Write tailored resume markdown to `~/.job-search/archetypes/<name>.md` c. Extract 4–6 keywords from the job title and description d. Call: ``` python3 ~/.openclaw/workspace/skills/job-search-tailor/scripts/save_archetype.py \ --name "<name>" \ --keywords "<kw1,kw2,...>" \ --resume-path "~/.job-search/archetypes/<name>.md" ``` e. Note: Google Docs push is not implemented in v1 — local file only ### B5. Format digest For each job produce one entry: ``` **{Company} — {Title}** 📍 {Location} | 💰 {Salary or "Not listed"} 🔗 {Apply URL} 📄 Resume: {resume_path or resume_url} ``` ### B6. Deliver - If `delivery_channel` starts with `telegram:` — format digest as one message and tell the user to send it via their configured Telegram bot to the given chat ID (v1 does not auto-send; present the formatted message for manual use or copy-paste) - Otherwise: print the full digest in the conversation --- ## Error handling - If `load_config.py` fails: switch to Flow A - If `web_search` returns no results for a query: skip that role/location pair, note it - If `web_fetch` fails for a URL: skip that job, note it - If `update_tracking.py` fails: warn the user and continue without dedup - If `save_archetype.py` fails: warn but continue — archetype is not persisted --- ## Notes - Always use `python3` (not `python`) to invoke scripts - Script paths: `~/.openclaw/workspace/skills/job-search-tailor/scripts/` - Default config path: `~/.job-search/config.json` - v1 does not auto-send Telegram messages or push to Google Docs — these are formatted outputs

path: assets/config.example.json
content: {
  "_comment": "job-search-tailor config — copy to ~/.job-search/config.json and fill in your values",

  "_comment_target_roles": "Job title keywords to search. Mix broad and specific.",
  "target_roles": ["Data Scientist", "ML Engineer", "Applied Scientist", "AI Engineer"],

  "_comment_locations": "Target locations. Use 'remote US' for remote-only.",
  "locations": ["remote US", "New York"],

  "_comment_job_boards": "v1 supports linkedin only.",
  "job_boards": ["linkedin"],

  "_comment_dedup": "Skip jobs already seen within this many days.",
  "dedup_window_days": 30,

  "_comment_max_per_company": "Max jobs from the same company per digest.",
  "max_per_company": 2,

  "_comment_target_count": "Aim for this many final jobs in each digest.",
  "target_count": 8,

  "_comment_tracking_file": "Path to dedup tracking JSON. Created automatically if missing.",
  "tracking_file": "~/.job-search/memory/shared_jobs.json",

  "_comment_archetypes_dir": "Folder where archetype resume markdown files are stored.",
  "archetypes_dir": "~/.job-search/archetypes/",

  "_comment_threshold": "Min keyword match score (0–1) to use an existing archetype. Below this → create new.",
  "archetype_match_threshold": 0.5,

  "_comment_google_docs": "Set true to push new archetypes to Google Docs. Requires OAuth setup.",
  "google_docs_enabled": false,

  "_comment_delivery": "Delivery target. Use 'telegram:YOUR_CHAT_ID' or leave empty to print only.",
  "delivery_channel": "telegram:YOUR_CHAT_ID",

  "_comment_archetypes": "Your resume archetypes. Add more after first-run setup or manually.",
  "archetypes": [
    {
      "name": "mle",
      "keywords": ["machine learning engineer", "ml engineer", "mlops", "ml platform"],
      "resume_path": "~/.job-search/archetypes/mle.md",
      "resume_url": ""
    },
    {
      "name": "data-scientist",
      "keywords": ["data scientist", "data science", "statistical modeling", "analytics"],
      "resume_path": "~/.job-search/archetypes/data-scientist.md",
      "resume_url": ""
    },
    {
      "name": "applied-scientist",
      "keywords": ["applied scientist", "research scientist", "applied research"],
      "resume_path": "~/.job-search/archetypes/applied-scientist.md",
      "resume_url": ""
    },
    {
      "name": "ai-engineer",
      "keywords": ["ai engineer", "genai", "llm engineer", "ai platform"],
      "resume_path": "~/.job-search/archetypes/ai-engineer.md",
      "resume_url": ""
    }
  ]
}


path: references/archetypes-guide.md
content: # Archetypes Guide

## What is an archetype?

An archetype is a version of your resume tailored for a specific type of role. Instead of one generic resume, you keep 3–5 focused versions — each with relevant skills, bullet points, and framing for that role family.

Examples:
- `mle` — emphasizes production ML systems, model serving, MLOps
- `data-scientist` — emphasizes modeling, experimentation, statistics
- `applied-scientist` — emphasizes research background, publications, novel methods
- `ai-engineer` — emphasizes LLMs, RAG, GenAI pipelines, prompt engineering

## How matching works

The skill uses binary keyword presence scoring:

1. Take the job title + first 200 chars of description (lowercased)
2. For each archetype: check if **any** of its keywords appear as a substring
3. Score = 1.0 if any keyword matches, 0.0 if none
4. Best score ≥ `archetype_match_threshold` (default 0.5) → use that archetype
5. All scores 0.0 → fetch full JD and create a new archetype

**Example:**
- Job title: `"Senior Machine Learning Engineer"`
- `mle` keywords: `["machine learning engineer", "ml engineer", "mlops"]`
- `"machine learning engineer"` found in title → score = 1.0 → match ✅

This means a single keyword match is sufficient — you don't need majority coverage.

## Tuning the threshold

| Threshold | Behavior |
|-----------|----------|
| 0.3 | Loose — rarely creates new archetypes, may mismatch edge cases |
| 0.5 | Default — balanced |
| 0.7 | Strict — creates new archetypes more often, more precise matching |

Lower threshold = fewer new archetypes created. Higher = more tailoring, higher quality matches.

## Writing effective keyword lists

- Use lowercase phrases, not single words
- Include common abbreviations (`ml engineer` AND `machine learning engineer`)
- Include adjacent titles that map to the same resume (`research engineer` → `applied-scientist`)
- 4–8 keywords per archetype is enough; more dilutes the score

**Good:**
```json
"keywords": ["machine learning engineer", "ml engineer", "mlops engineer", "ml platform engineer", "ml infrastructure"]
```

**Too sparse:**
```json
"keywords": ["ml"]
```

**Too broad:**
```json
"keywords": ["engineer", "technical", "software", "data", "ai", "ml", "scientist", "analyst", "researcher"]
```

## Example archetype sets

### DS / MLE / Applied Scientist / AI Engineer (common ML job seeker)
```json
[
  {"name": "mle", "keywords": ["machine learning engineer", "ml engineer", "mlops", "ml platform", "ml infrastructure"]},
  {"name": "data-scientist", "keywords": ["data scientist", "data science", "statistical modeling", "quantitative analyst"]},
  {"name": "applied-scientist", "keywords": ["applied scientist", "research scientist", "applied researcher", "ml researcher"]},
  {"name": "ai-engineer", "keywords": ["ai engineer", "genai engineer", "llm engineer", "ai platform", "generative ai"]}
]
```

### SWE-focused seeker
```json
[
  {"name": "backend", "keywords": ["backend engineer", "software engineer", "swe", "server-side", "api engineer"]},
  {"name": "fullstack", "keywords": ["full stack", "fullstack", "full-stack engineer"]},
  {"name": "platform", "keywords": ["platform engineer", "infrastructure engineer", "devops", "sre"]}
]
```

### PM-focused seeker
```json
[
  {"name": "product-manager", "keywords": ["product manager", "pm", "product lead", "group product manager"]},
  {"name": "technical-pm", "keywords": ["technical product manager", "tpm", "platform pm"]}
]
```

## Auto-created archetype format

When the agent creates a new archetype from a JD, it saves a markdown file with this structure:

```markdown
# Archetype: <name>

## Role type
<brief description of the role family>

## Keywords
<kw1>, <kw2>, <kw3>, ...

## Resume

[Full tailored resume content here — based on user's base resume,
reframed and reworded for this role type]
```

The file is saved to `archetypes_dir/<name>.md` and registered in `config.json` automatically.


path: references/config-guide.md
content: # config-guide — job-search-tailor

Config file location: `~/.job-search/config.json`

---

## Field reference

| Field | Type | Default | Description |
|---|---|---|---|
| `target_roles` | `string[]` | required | Job titles to search for (e.g. `["Data Scientist", "ML Engineer"]`) |
| `locations` | `string[]` | required | Locations to search (e.g. `["remote US", "New York, NY"]`) |
| `job_boards` | `string[]` | `["linkedin"]` | Job boards to search. Only `"linkedin"` is supported in v1 |
| `dedup_window_days` | `int` | `30` | Days to remember a URL before showing it again |
| `max_per_company` | `int` | `2` | Max jobs to show per company per run |
| `target_count` | `int` | `8` | Target number of new jobs to surface per run |
| `tracking_file` | `string` | `~/.job-search/memory/shared_jobs.json` | Path to the dedup tracking file |
| `archetypes_dir` | `string` | `~/.job-search/archetypes/` | Directory where resume markdown files live |
| `archetype_match_threshold` | `float` | `0.5` | Minimum keyword overlap score (0–1) to match an archetype |
| `google_docs_enabled` | `bool` | `false` | Whether to use Google Docs URLs for resumes (v1: not implemented) |
| `delivery_channel` | `string` | `"print"` | Where to send results: `"print"` or `"telegram:CHAT_ID"` |
| `archetypes` | `Archetype[]` | `[]` | List of resume archetypes (see below) |

### Archetype object

| Field | Type | Description |
|---|---|---|
| `name` | `string` | Slug identifier (e.g. `"mle"`, `"data-scientist"`) |
| `keywords` | `string[]` | Keywords used for job-to-archetype matching (lowercase) |
| `resume_path` | `string` | Local path to the tailored resume markdown file |
| `resume_url` | `string` | Optional public URL (Google Docs, etc.) — empty string if unused |

---

## Example configs

### Data Scientist job seeker

```json
{
  "target_roles": ["Data Scientist", "Senior Data Scientist", "Staff Data Scientist"],
  "locations": ["remote US", "San Francisco, CA", "New York, NY"],
  "job_boards": ["linkedin"],
  "dedup_window_days": 30,
  "max_per_company": 2,
  "target_count": 8,
  "tracking_file": "~/.job-search/memory/shared_jobs.json",
  "archetypes_dir": "~/.job-search/archetypes/",
  "archetype_match_threshold": 0.4,
  "google_docs_enabled": false,
  "delivery_channel": "print",
  "archetypes": [
    {
      "name": "ds-analytics",
      "keywords": ["data scientist", "analytics", "experimentation", "a/b testing", "sql"],
      "resume_path": "~/.job-search/archetypes/ds-analytics.md",
      "resume_url": ""
    },
    {
      "name": "ds-ml",
      "keywords": ["data scientist", "machine learning", "modeling", "prediction", "python"],
      "resume_path": "~/.job-search/archetypes/ds-ml.md",
      "resume_url": ""
    }
  ]
}
```

### ML Engineer job seeker

```json
{
  "target_roles": ["ML Engineer", "Machine Learning Engineer", "MLOps Engineer"],
  "locations": ["remote US", "Seattle, WA"],
  "job_boards": ["linkedin"],
  "dedup_window_days": 21,
  "max_per_company": 1,
  "target_count": 10,
  "tracking_file": "~/.job-search/memory/shared_jobs.json",
  "archetypes_dir": "~/.job-search/archetypes/",
  "archetype_match_threshold": 0.5,
  "google_docs_enabled": false,
  "delivery_channel": "telegram:123456789",
  "archetypes": [
    {
      "name": "mle",
      "keywords": ["ml engineer", "machine learning engineer", "model training", "pytorch", "tensorflow"],
      "resume_path": "~/.job-search/archetypes/mle.md",
      "resume_url": ""
    },
    {
      "name": "mlops",
      "keywords": ["mlops", "ml platform", "kubeflow", "airflow", "model serving", "kubernetes"],
      "resume_path": "~/.job-search/archetypes/mlops.md",
      "resume_url": ""
    }
  ]
}
```

### Product Manager job seeker

```json
{
  "target_roles": ["Product Manager", "Senior PM", "Group Product Manager", "AI Product Manager"],
  "locations": ["remote US", "New York, NY", "Austin, TX"],
  "job_boards": ["linkedin"],
  "dedup_window_days": 30,
  "max_per_company": 2,
  "target_count": 6,
  "tracking_file": "~/.job-search/memory/shared_jobs.json",
  "archetypes_dir": "~/.job-search/archetypes/",
  "archetype_match_threshold": 0.35,
  "google_docs_enabled": false,
  "delivery_channel": "print",
  "archetypes": [
    {
      "name": "pm-data",
      "keywords": ["data product manager", "analytics pm", "data platform", "metrics", "sql"],
      "resume_path": "~/.job-search/archetypes/pm-data.md",
      "resume_url": ""
    },
    {
      "name": "pm-ai",
      "keywords": ["ai product manager", "ml product", "llm", "generative ai", "ai features"],
      "resume_path": "~/.job-search/archetypes/pm-ai.md",
      "resume_url": ""
    }
  ]
}
```

---

## Google Docs opt-in

**v1 status: not yet implemented.** Setting `google_docs_enabled: true` has no effect.

When implemented, Google Docs integration will:
1. Require a service account credential or OAuth token in `~/.job-search/gcp-creds.json`
2. Push each archetype markdown to a dedicated Google Doc
3. Populate `resume_url` with the shareable Doc link

For now, set `"google_docs_enabled": false` and use `resume_path` for local files.

---

## Troubleshooting

**Missing config / config_not_found error**
- First run? Trigger the skill and it will walk you through setup.
- Already set up? Check the path: `ls ~/.job-search/config.json`

**Empty archetypes array**
- Re-run the skill — it detects an empty archetypes list and re-enters Flow A.
- Or manually add archetype entries and run `save_archetype.py`.

**No results returned**
- Check `target_roles` spelling — use terms that appear in actual LinkedIn job titles.
- Try broader locations (e.g. `"United States"` instead of a specific city).
- Lower `archetype_match_threshold` (try `0.25`).

**Same jobs appearing repeatedly**
- Check `tracking_file` path exists and is writable.
- Increase `dedup_window_days` if jobs cycle back quickly.
- Inspect the tracking file: `cat ~/.job-search/memory/shared_jobs.json | python3 -m json.tool`


path: scripts/load_config.py
content: #!/usr/bin/env python3
"""
load_config.py — Read and print the job-search config as JSON.

Usage:
    python3 load_config.py [--config-file PATH]

Exits 0 on success (prints config JSON).
Exits 1 if config file is missing or unreadable (prints error JSON).
"""

import argparse
import json
import os
import sys
from pathlib import Path


DEFAULT_CONFIG_PATH = "~/.job-search/config.json"


def main():
    parser = argparse.ArgumentParser(
        description="Load and print job-search config as JSON."
    )
    parser.add_argument(
        "--config-file",
        default=DEFAULT_CONFIG_PATH,
        help=f"Path to config.json (default: {DEFAULT_CONFIG_PATH})",
    )
    args = parser.parse_args()

    config_path = Path(os.path.expanduser(args.config_file))

    if not config_path.exists():
        error = {
            "error": "config_not_found",
            "path": str(config_path),
            "message": (
                f"Config file not found at {config_path}. "
                "Run first-time setup to create it."
            ),
        }
        print(json.dumps(error))
        sys.exit(1)

    try:
        with config_path.open("r", encoding="utf-8") as f:
            config = json.load(f)
    except json.JSONDecodeError as e:
        error = {
            "error": "config_parse_error",
            "path": str(config_path),
            "message": f"Failed to parse config JSON: {e}",
        }
        print(json.dumps(error))
        sys.exit(1)
    except OSError as e:
        error = {
            "error": "config_read_error",
            "path": str(config_path),
            "message": f"Failed to read config file: {e}",
        }
        print(json.dumps(error))
        sys.exit(1)

    print(json.dumps(config, indent=2))
    sys.exit(0)


if __name__ == "__main__":
    main()


path: scripts/save_archetype.py
content: #!/usr/bin/env python3
"""
save_archetype.py — Register or update a resume archetype in config.json.

Usage:
    python3 save_archetype.py \
        --name NAME \
        --keywords "kw1,kw2,kw3" \
        --resume-path PATH \
        [--resume-url URL] \
        [--config-file PATH]

Loads config.json, upserts the archetype (update if name exists, append if new),
writes the updated config back, and prints a status JSON object.

Exits 0 on success, exits 1 on failure.
"""

import argparse
import json
import os
import sys
from pathlib import Path


DEFAULT_CONFIG_PATH = "~/.job-search/config.json"


def load_config(config_path: Path) -> dict:
    """Load config JSON. Returns empty config scaffold if file missing."""
    if not config_path.exists():
        return {"archetypes": []}
    with config_path.open("r", encoding="utf-8") as f:
        return json.load(f)


def save_config(config_path: Path, config: dict) -> None:
    """Write config JSON, creating parent dirs as needed."""
    config_path.parent.mkdir(parents=True, exist_ok=True)
    with config_path.open("w", encoding="utf-8") as f:
        json.dump(config, f, indent=2)


def main():
    parser = argparse.ArgumentParser(
        description="Register or update a resume archetype in config.json."
    )
    parser.add_argument(
        "--name",
        required=True,
        help="Archetype name (slug, e.g. 'mle' or 'data-scientist')",
    )
    parser.add_argument(
        "--keywords",
        required=True,
        help="Comma-separated keywords for this archetype",
    )
    parser.add_argument(
        "--resume-path",
        required=True,
        help="Local path to the tailored resume markdown file",
    )
    parser.add_argument(
        "--resume-url",
        default="",
        help="Optional public URL for the resume (e.g. Google Docs link)",
    )
    parser.add_argument(
        "--config-file",
        default=DEFAULT_CONFIG_PATH,
        help=f"Path to config.json (default: {DEFAULT_CONFIG_PATH})",
    )
    args = parser.parse_args()

    config_path = Path(os.path.expanduser(args.config_file))

    # Parse keywords — strip whitespace, drop empties
    keywords = [kw.strip() for kw in args.keywords.split(",") if kw.strip()]

    # Build archetype record
    archetype = {
        "name": args.name.strip(),
        "keywords": keywords,
        "resume_path": args.resume_path.strip(),
        "resume_url": args.resume_url.strip(),
    }

    # Load config (creates new if missing)
    try:
        config = load_config(config_path)
    except json.JSONDecodeError as e:
        print(json.dumps({"error": "config_parse_error", "message": str(e)}))
        sys.exit(1)
    except OSError as e:
        print(json.dumps({"error": "config_read_error", "message": str(e)}))
        sys.exit(1)

    # Ensure archetypes list exists
    if "archetypes" not in config or not isinstance(config["archetypes"], list):
        config["archetypes"] = []

    # Upsert: find existing archetype by name
    existing_index = next(
        (i for i, a in enumerate(config["archetypes"]) if a.get("name") == archetype["name"]),
        None,
    )

    action = "updated" if existing_index is not None else "added"
    if existing_index is not None:
        config["archetypes"][existing_index] = archetype
    else:
        config["archetypes"].append(archetype)

    # Save updated config
    try:
        save_config(config_path, config)
    except OSError as e:
        print(json.dumps({"error": "config_write_error", "message": str(e)}))
        sys.exit(1)

    print(json.dumps({"status": "saved", "action": action, "name": archetype["name"]}))
    sys.exit(0)


if __name__ == "__main__":
    main()


path: scripts/update_tracking.py
content: #!/usr/bin/env python3
"""
update_tracking.py — Deduplicate job URLs against a rolling tracking window.

Usage:
    python3 update_tracking.py \
        --urls "url1,url2,url3" \
        --tracking-file PATH \
        --window-days N

Reads the tracking file (JSON array of {url, shared_date} objects).
Filters out any URL already seen within the past window-days days.
Prints the NEW (not-yet-seen) URLs as a JSON array to stdout.
Appends the new URLs (with today's date) to the tracking file.

Exits 0 always (errors printed to stderr, empty array returned on failure).
"""

import argparse
import json
import os
import sys
from datetime import date, datetime, timedelta
from pathlib import Path
from typing import Optional


def load_tracking(tracking_path: Path) -> list:
    """Load tracking records. Returns empty list if file missing or invalid."""
    if not tracking_path.exists():
        return []
    try:
        with tracking_path.open("r", encoding="utf-8") as f:
            data = json.load(f)
        if not isinstance(data, list):
            print(
                f"Warning: tracking file is not a JSON array, resetting.",
                file=sys.stderr,
            )
            return []
        return data
    except (json.JSONDecodeError, OSError) as e:
        print(f"Warning: could not read tracking file: {e}", file=sys.stderr)
        return []


def save_tracking(tracking_path: Path, records: list) -> None:
    """Write tracking records to file, creating parent dirs as needed."""
    tracking_path.parent.mkdir(parents=True, exist_ok=True)
    with tracking_path.open("w", encoding="utf-8") as f:
        json.dump(records, f, indent=2)


def parse_date(date_str: str) -> Optional[date]:
    """Parse ISO date string (YYYY-MM-DD). Returns None on failure."""
    try:
        return datetime.strptime(date_str, "%Y-%m-%d").date()
    except (ValueError, TypeError):
        return None


def main():
    parser = argparse.ArgumentParser(
        description="Deduplicate job URLs against a rolling tracking window."
    )
    parser.add_argument(
        "--urls",
        required=True,
        help="Comma-separated list of job URLs to check",
    )
    parser.add_argument(
        "--tracking-file",
        required=True,
        help="Path to the JSON tracking file",
    )
    parser.add_argument(
        "--window-days",
        type=int,
        required=True,
        help="Number of days to look back when deduplicating",
    )
    args = parser.parse_args()

    tracking_path = Path(os.path.expanduser(args.tracking_file))
    today = date.today()
    cutoff = today - timedelta(days=args.window_days)

    # Parse incoming URLs (strip whitespace, drop empties)
    incoming_urls = [u.strip() for u in args.urls.split(",") if u.strip()]

    # Load existing tracking records
    records = load_tracking(tracking_path)

    # Build set of recently-seen URLs (within window)
    seen_recently = set()
    for record in records:
        url = record.get("url", "")
        shared = parse_date(record.get("shared_date", ""))
        if url and shared and shared >= cutoff:
            seen_recently.add(url)

    # Filter to new-only URLs
    new_urls = [u for u in incoming_urls if u not in seen_recently]

    # Append new URLs to tracking records with today's date
    today_str = today.isoformat()
    for url in new_urls:
        records.append({"url": url, "shared_date": today_str})

    # Persist updated tracking file
    try:
        save_tracking(tracking_path, records)
    except OSError as e:
        print(f"Warning: could not save tracking file: {e}", file=sys.stderr)

    # Output new URLs as JSON array
    print(json.dumps(new_urls))
    sys.exit(0)


if __name__ == "__main__":
    main()


path: tests/test_scripts.py
content: #!/usr/bin/env python3
"""
Unit tests for job-search-tailor CLI scripts.

Runs each script as a subprocess to test the actual CLI interface.
Uses only Python 3.9 stdlib — no external dependencies.
"""

import json
import os
import subprocess
import sys
import tempfile
import unittest
from datetime import date, timedelta
from pathlib import Path

SCRIPTS_DIR = Path(__file__).parent.parent / "scripts"
LOAD_CONFIG = str(SCRIPTS_DIR / "load_config.py")
UPDATE_TRACKING = str(SCRIPTS_DIR / "update_tracking.py")
SAVE_ARCHETYPE = str(SCRIPTS_DIR / "save_archetype.py")

VALID_CONFIG = {
    "target_roles": ["Data Scientist", "ML Engineer"],
    "locations": ["remote US"],
    "job_boards": ["linkedin"],
    "dedup_window_days": 30,
    "max_per_company": 2,
    "target_count": 8,
    "tracking_file": "~/.job-search/memory/shared_jobs.json",
    "archetypes_dir": "~/.job-search/archetypes/",
    "archetype_match_threshold": 0.5,
    "google_docs_enabled": False,
    "delivery_channel": "",
    "archetypes": [
        {
            "name": "mle",
            "keywords": ["ml engineer", "mlops"],
            "resume_path": "~/.job-search/archetypes/mle.md",
            "resume_url": "",
        }
    ],
}


def run(script, args, cwd=None):
    """Run a script as a subprocess and return (stdout, stderr, returncode)."""
    result = subprocess.run(
        [sys.executable, script] + args,
        capture_output=True,
        text=True,
        cwd=cwd,
    )
    return result.stdout, result.stderr, result.returncode


# ---------------------------------------------------------------------------
# load_config.py tests
# ---------------------------------------------------------------------------

class TestLoadConfig(unittest.TestCase):

    def test_missing_config_exits_1(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            missing = os.path.join(tmpdir, "nonexistent.json")
            stdout, _, rc = run(LOAD_CONFIG, ["--config-file", missing])
            self.assertEqual(rc, 1)
            data = json.loads(stdout)
            self.assertEqual(data["error"], "config_not_found")

    def test_missing_config_prints_error_json(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            missing = os.path.join(tmpdir, "nonexistent.json")
            stdout, _, rc = run(LOAD_CONFIG, ["--config-file", missing])
            data = json.loads(stdout)
            self.assertIn("error", data)
            self.assertIn("path", data)
            self.assertIn("message", data)

    def test_valid_config_exits_0(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            cfg_path = os.path.join(tmpdir, "config.json")
            with open(cfg_path, "w") as f:
                json.dump(VALID_CONFIG, f)
            _, _, rc = run(LOAD_CONFIG, ["--config-file", cfg_path])
            self.assertEqual(rc, 0)

    def test_valid_config_prints_json(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            cfg_path = os.path.join(tmpdir, "config.json")
            with open(cfg_path, "w") as f:
                json.dump(VALID_CONFIG, f)
            stdout, _, _ = run(LOAD_CONFIG, ["--config-file", cfg_path])
            data = json.loads(stdout)
            self.assertIsInstance(data, dict)

    def test_valid_config_contains_required_fields(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            cfg_path = os.path.join(tmpdir, "config.json")
            with open(cfg_path, "w") as f:
                json.dump(VALID_CONFIG, f)
            stdout, _, _ = run(LOAD_CONFIG, ["--config-file", cfg_path])
            data = json.loads(stdout)
            self.assertIn("target_roles", data)
            self.assertIn("archetypes", data)

    def test_malformed_json_exits_1(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            cfg_path = os.path.join(tmpdir, "config.json")
            with open(cfg_path, "w") as f:
                f.write("{not valid json:::}")
            _, _, rc = run(LOAD_CONFIG, ["--config-file", cfg_path])
            self.assertEqual(rc, 1)

    def test_malformed_json_prints_error_key(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            cfg_path = os.path.join(tmpdir, "config.json")
            with open(cfg_path, "w") as f:
                f.write("{not valid json:::}")
            stdout, _, _ = run(LOAD_CONFIG, ["--config-file", cfg_path])
            data = json.loads(stdout)
            self.assertIn("error", data)


# ---------------------------------------------------------------------------
# update_tracking.py tests
# ---------------------------------------------------------------------------

def _tracking_args(urls, tracking_file, window_days=30):
    return [
        "--urls", urls,
        "--tracking-file", tracking_file,
        "--window-days", str(window_days),
    ]


def _write_tracking(path, records):
    with open(path, "w") as f:
        json.dump(records, f)


class TestUpdateTracking(unittest.TestCase):

    def test_all_new_urls_returned(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            tf = os.path.join(tmpdir, "tracking.json")
            stdout, _, rc = run(UPDATE_TRACKING, _tracking_args("http://a.com,http://b.com", tf))
            self.assertEqual(rc, 0)
            result = json.loads(stdout)
            self.assertIn("http://a.com", result)
            self.assertIn("http://b.com", result)

    def test_all_new_urls_written_to_tracking_file(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            tf = os.path.join(tmpdir, "tracking.json")
            run(UPDATE_TRACKING, _tracking_args("http://a.com,http://b.com", tf))
            with open(tf) as f:
                records = json.load(f)
            urls_in_file = {r["url"] for r in records}
            self.assertIn("http://a.com", urls_in_file)
            self.assertIn("http://b.com", urls_in_file)

    def test_seen_within_window_returns_empty(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            tf = os.path.join(tmpdir, "tracking.json")
            today = date.today().isoformat()
            _write_tracking(tf, [
                {"url": "http://a.com", "shared_date": today},
                {"url": "http://b.com", "shared_date": today},
            ])
            stdout, _, _ = run(UPDATE_TRACKING, _tracking_args("http://a.com,http://b.com", tf))
            result = json.loads(stdout)
            self.assertEqual(result, [])

    def test_mixed_returns_only_new(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            tf = os.path.join(tmpdir, "tracking.json")
            today = date.today().isoformat()
            _write_tracking(tf, [{"url": "http://a.com", "shared_date": today}])
            stdout, _, _ = run(UPDATE_TRACKING, _tracking_args("http://a.com,http://b.com", tf))
            result = json.loads(stdout)
            self.assertEqual(result, ["http://b.com"])

    def test_url_outside_window_treated_as_new(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            tf = os.path.join(tmpdir, "tracking.json")
            old_date = (date.today() - timedelta(days=35)).isoformat()
            _write_tracking(tf, [{"url": "http://a.com", "shared_date": old_date}])
            stdout, _, _ = run(UPDATE_TRACKING, _tracking_args("http://a.com", tf, window_days=30))
            result = json.loads(stdout)
            self.assertIn("http://a.com", result)

    def test_missing_tracking_file_creates_it(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            tf = os.path.join(tmpdir, "tracking.json")
            self.assertFalse(os.path.exists(tf))
            run(UPDATE_TRACKING, _tracking_args("http://a.com", tf))
            self.assertTrue(os.path.exists(tf))

    def test_missing_tracking_file_returns_all_as_new(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            tf = os.path.join(tmpdir, "tracking.json")
            stdout, _, rc = run(UPDATE_TRACKING, _tracking_args("http://a.com,http://b.com", tf))
            self.assertEqual(rc, 0)
            result = json.loads(stdout)
            self.assertIn("http://a.com", result)
            self.assertIn("http://b.com", result)

    def test_empty_urls_returns_empty_array(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            tf = os.path.join(tmpdir, "tracking.json")
            stdout, _, rc = run(UPDATE_TRACKING, _tracking_args("", tf))
            self.assertEqual(rc, 0)
            result = json.loads(stdout)
            self.assertEqual(result, [])

    def test_malformed_tracking_file_treats_all_as_new(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            tf = os.path.join(tmpdir, "tracking.json")
            with open(tf, "w") as f:
                f.write("NOT JSON AT ALL")
            stdout, _, rc = run(UPDATE_TRACKING, _tracking_args("http://a.com", tf))
            self.assertEqual(rc, 0)
            result = json.loads(stdout)
            self.assertIn("http://a.com", result)


# ---------------------------------------------------------------------------
# save_archetype.py tests
# ---------------------------------------------------------------------------

def _archetype_args(name, keywords, resume_path, config_file, resume_url=None):
    args = [
        "--name", name,
        "--keywords", keywords,
        "--resume-path", resume_path,
        "--config-file", config_file,
    ]
    if resume_url is not None:
        args += ["--resume-url", resume_url]
    return args


class TestSaveArchetype(unittest.TestCase):

    def _make_config(self, tmpdir, extra=None):
        cfg = dict(VALID_CONFIG)
        if extra:
            cfg.update(extra)
        cfg_path = os.path.join(tmpdir, "config.json")
        with open(cfg_path, "w") as f:
            json.dump(cfg, f)
        return cfg_path

    def test_new_archetype_added_to_config(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            cfg_path = self._make_config(tmpdir)
            resume = os.path.join(tmpdir, "resume.md")
            run(SAVE_ARCHETYPE, _archetype_args("new-role", "kw1,kw2", resume, cfg_path))
            with open(cfg_path) as f:
                config = json.load(f)
            names = [a["name"] for a in config["archetypes"]]
            self.assertIn("new-role", names)

    def test_existing_archetype_updated_no_duplicate(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            cfg_path = self._make_config(tmpdir)
            resume = os.path.join(tmpdir, "resume.md")
            # "mle" already exists in VALID_CONFIG
            run(SAVE_ARCHETYPE, _archetype_args("mle", "updated-kw", resume, cfg_path))
            with open(cfg_path) as f:
                config = json.load(f)
            mle_entries = [a for a in config["archetypes"] if a["name"] == "mle"]
            self.assertEqual(len(mle_entries), 1)
            self.assertIn("updated-kw", mle_entries[0]["keywords"])

    def test_existing_archetype_update_preserves_count(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            cfg_path = self._make_config(tmpdir)
            original_count = len(VALID_CONFIG["archetypes"])
            resume = os.path.join(tmpdir, "resume.md")
            run(SAVE_ARCHETYPE, _archetype_args("mle", "updated-kw", resume, cfg_path))
            with open(cfg_path) as f:
                config = json.load(f)
            self.assertEqual(len(config["archetypes"]), original_count)

    def test_missing_config_creates_new_and_exits_0(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            missing = os.path.join(tmpdir, "nonexistent.json")
            resume = os.path.join(tmpdir, "resume.md")
            _, _, rc = run(SAVE_ARCHETYPE, _archetype_args("new-role", "kw1", resume, missing))
            self.assertEqual(rc, 0)
            self.assertTrue(os.path.exists(missing))

    def test_missing_config_new_file_contains_archetype(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            missing = os.path.join(tmpdir, "nonexistent.json")
            resume = os.path.join(tmpdir, "resume.md")
            run(SAVE_ARCHETYPE, _archetype_args("new-role", "kw1", resume, missing))
            with open(missing) as f:
                config = json.load(f)
            names = [a["name"] for a in config["archetypes"]]
            self.assertIn("new-role", names)

    def test_success_output_has_status_saved(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            cfg_path = self._make_config(tmpdir)
            resume = os.path.join(tmpdir, "resume.md")
            stdout, _, rc = run(SAVE_ARCHETYPE, _archetype_args("new-role", "kw1,kw2", resume, cfg_path))
            self.assertEqual(rc, 0)
            data = json.loads(stdout)
            self.assertEqual(data["status"], "saved")

    def test_resume_url_optional(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            cfg_path = self._make_config(tmpdir)
            resume = os.path.join(tmpdir, "resume.md")
            # No resume_url argument passed
            stdout, _, rc = run(SAVE_ARCHETYPE, _archetype_args("new-role", "kw1", resume, cfg_path))
            self.assertEqual(rc, 0)
            data = json.loads(stdout)
            self.assertEqual(data["status"], "saved")

    def test_resume_url_stored_when_provided(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            cfg_path = self._make_config(tmpdir)
            resume = os.path.join(tmpdir, "resume.md")
            url = "https://docs.google.com/example"
            run(SAVE_ARCHETYPE, _archetype_args("new-role", "kw1", resume, cfg_path, resume_url=url))
            with open(cfg_path) as f:
                config = json.load(f)
            entry = next(a for a in config["archetypes"] if a["name"] == "new-role")
            self.assertEqual(entry["resume_url"], url)

    def test_action_added_for_new_archetype(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            cfg_path = self._make_config(tmpdir)
            resume = os.path.join(tmpdir, "resume.md")
            stdout, _, _ = run(SAVE_ARCHETYPE, _archetype_args("brand-new", "kw1", resume, cfg_path))
            data = json.loads(stdout)
            self.assertEqual(data["action"], "added")

    def test_action_updated_for_existing_archetype(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            cfg_path = self._make_config(tmpdir)
            resume = os.path.join(tmpdir, "resume.md")
            stdout, _, _ = run(SAVE_ARCHETYPE, _archetype_args("mle", "kw1", resume, cfg_path))
            data = json.loads(stdout)
            self.assertEqual(data["action"], "updated")


if __name__ == "__main__":
    unittest.main(verbosity=2)


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (HIGH)