Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# git-crypt configuration for career-ops
#
# To encrypt your personal data:
# 1. Install git-crypt: brew install git-crypt
# 2. Initialize: git-crypt init
# 3. Add yourself: git-crypt add-gpg-user YOUR_EMAIL
# 4. Uncomment the lines below to encrypt sensitive files
# 5. Commit these changes

# cv.md filter=git-crypt diff=git-crypt
# config/profile.yml filter=git-crypt diff=git-crypt
# modes/_profile.md filter=git-crypt diff=git-crypt
# portals.yml filter=git-crypt diff=git-crypt
# data/** filter=git-crypt diff=git-crypt
# reports/** filter=git-crypt diff=git-crypt
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ When using [OpenCode](https://opencode.ai), the following slash commands are ava
| `/career-ops-compare` | `/career-ops ofertas` | Compare and rank multiple offers |
| `/career-ops-contact` | `/career-ops contacto` | LinkedIn outreach (find contacts + draft) |
| `/career-ops-deep` | `/career-ops deep` | Deep company research |
| `/career-ops-cover-letter` | `/career-ops cover-letter` | Generate a tailored cover letter |
| `/career-ops-pdf` | `/career-ops pdf` | Generate ATS-optimized CV |
| `/career-ops-training` | `/career-ops training` | Evaluate course/cert against goals |
| `/career-ops-project` | `/career-ops project` | Evaluate portfolio project idea |
Expand Down Expand Up @@ -217,6 +218,7 @@ Default modes are in `modes/` (English). Additional language-specific modes are
| Asks to compare offers | `ofertas` |
| Wants LinkedIn outreach | `contacto` |
| Asks for company research | `deep` |
| Wants to generate cover letter | `cover-letter` |
| Preps for interview at specific company | `interview-prep` |
| Wants to generate CV/PDF | `pdf` |
| Evaluates a course/cert | `training` |
Expand Down
272 changes: 272 additions & 0 deletions README.fr.md

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,15 @@ career-ops/

- **[cv-santiago](https://github.com/santifer/cv-santiago)** -- The portfolio website (santifer.io) with AI chatbot, LLMOps dashboard, and case studies. If you need a portfolio to showcase alongside your job search, fork it and make it yours.

## Security & Privacy (git-crypt)

If you plan to store your `career-ops` repository in a private GitHub repository but want to encrypt your personal data (CV, tracker, reports), we provide a `.gitattributes` configuration for `git-crypt`.

1. Install: `brew install git-crypt`
2. Initialize: `git-crypt init`
3. Uncomment the files in `.gitattributes`
4. Everything in `data/`, `reports/`, and your `cv.md` will be encrypted on GitHub but readable locally by Claude Code.

## About the Author

I'm Santiago -- Head of Applied AI, former founder (built and sold a business that still runs with my name on it). I built career-ops to manage my own job search. It worked: I used it to land my current role.
Expand Down
24 changes: 19 additions & 5 deletions batch/batch-runner.sh
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ DRY_RUN=false
RETRY_FAILED=false
START_FROM=0
MAX_RETRIES=2
MIN_SCORE=0

usage() {
cat <<'USAGE'
Expand All @@ -41,6 +42,7 @@ Options:
--retry-failed Only retry offers marked as "failed" in state
--start-from N Start from offer ID N (skip earlier IDs)
--max-retries N Max retry attempts per offer (default: 2)
--min-score N Minimum score required to save the report (default: 0)
-h, --help Show this help

Files:
Expand Down Expand Up @@ -73,6 +75,7 @@ while [[ $# -gt 0 ]]; do
--retry-failed) RETRY_FAILED=true; shift ;;
--start-from) START_FROM="$2"; shift 2 ;;
--max-retries) MAX_RETRIES="$2"; shift 2 ;;
--min-score) MIN_SCORE="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown option: $1"; usage; exit 1 ;;
esac
Expand Down Expand Up @@ -334,6 +337,7 @@ process_offer() {
local esc_url esc_jd_file esc_report_num esc_date esc_id
esc_url="${url//\\/\\\\}"
esc_url="${esc_url//|/\\|}"
esc_url="${esc_url//&/\\&}"
esc_jd_file="${jd_file//\\/\\\\}"
esc_jd_file="${esc_jd_file//|/\\|}"
esc_report_num="${report_num//|/\\|}"
Expand Down Expand Up @@ -365,13 +369,22 @@ process_offer() {
# Try to extract score from worker output
local score="-"
local score_match
score_match=$(grep -oP '"score":\s*[\d.]+' "$log_file" 2>/dev/null | head -1 | grep -oP '[\d.]+' || true)
score_match=$(grep -oE '"score":\s*[0-9.]+' "$log_file" 2>/dev/null | head -1 | grep -oE '[0-9.]+' || true)
if [[ -n "$score_match" ]]; then
score="$score_match"
fi

update_state "$id" "$url" "completed" "$started_at" "$completed_at" "$report_num" "$score" "-" "$retries"
echo " ✅ Completed (score: $score, report: $report_num)"
# Check minimum score threshold
if [[ "$score" != "-" ]] && awk -v score="$score" -v min="$MIN_SCORE" 'BEGIN{exit !(score >= min)}'; then
update_state "$id" "$url" "completed" "$started_at" "$completed_at" "$report_num" "$score" "-" "$retries"
echo " ✅ Completed (score: $score, report: $report_num)"
elif [[ "$score" != "-" ]]; then
update_state "$id" "$url" "skipped" "$started_at" "$completed_at" "$report_num" "$score" "Score $score below min $MIN_SCORE" "$retries"
echo " ⏭️ Skipped (score: $score < min $MIN_SCORE, report: $report_num)"
else
update_state "$id" "$url" "completed" "$started_at" "$completed_at" "$report_num" "$score" "-" "$retries"
echo " ✅ Completed (score: -, report: $report_num)"
fi
else
retries=$((retries + 1))
local error_msg
Expand Down Expand Up @@ -401,7 +414,7 @@ print_summary() {
return
fi

local total=0 completed=0 failed=0 pending=0
local total=0 completed=0 failed=0 pending=0 skipped=0
local score_sum=0 score_count=0

while IFS=$'\t' read -r sid _ sstatus _ _ _ sscore _ _; do
Expand All @@ -415,11 +428,12 @@ print_summary() {
fi
;;
failed) failed=$((failed + 1)) ;;
skipped) skipped=$((skipped + 1)) ;;
*) pending=$((pending + 1)) ;;
esac
done < "$STATE_FILE"

echo "Total: $total | Completed: $completed | Failed: $failed | Pending: $pending"
echo "Total: $total | Completed: $completed | Skipped: $skipped | Failed: $failed | Pending: $pending"

if (( score_count > 0 )); then
local avg
Expand Down
59 changes: 57 additions & 2 deletions check-liveness.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,14 @@
import { chromium } from 'playwright';
import { readFile } from 'fs/promises';
import { classifyLiveness } from './liveness-core.mjs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';

async function checkUrl(page, url) {
if (!url.startsWith('http://') && !url.startsWith('https://')) {
return { result: 'expired', reason: 'Invalid URL protocol' };
}

try {
const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 });

Expand Down Expand Up @@ -62,7 +68,50 @@ async function checkUrl(page, url) {
.filter(Boolean);
});

return classifyLiveness({ status, finalUrl, bodyText, applyControls });
const postingDate = await page.evaluate(() => {
// Strategy 1: ld+json schema
try {
const scripts = document.querySelectorAll('script[type="application/ld+json"]');
for (const script of scripts) {
const data = JSON.parse(script.textContent);
const findDate = (obj) => {
if (!obj) return null;
if (obj['@type'] === 'JobPosting' && obj.datePosted) return obj.datePosted;
if (Array.isArray(obj)) {
for (const item of obj) {
const res = findDate(item);
if (res) return res;
}
}
if (typeof obj === 'object') {
if (obj['@graph']) return findDate(obj['@graph']);
}
return null;
};
const date = findDate(data);
if (date) return date;
}
} catch (e) {}

// Strategy 2: meta itemProp
const meta = document.querySelector('meta[itemprop="datePosted"]');
if (meta && meta.content) return meta.content;

// Strategy 3: time element with datetime
const time = document.querySelector('time[datetime]');
if (time) return time.getAttribute('datetime');

return null;
});

return classifyLiveness({
status,
finalUrl,
bodyText,
applyControls,
postingDate,
staleThresholdDays: 45
});

} catch (err) {
return { result: 'expired', reason: `navigation error: ${err.message.split('\n')[0]}` };
Expand All @@ -80,7 +129,13 @@ async function main() {

let urls;
if (args[0] === '--file') {
const text = await readFile(args[1], 'utf-8');
const filePath = resolve(args[1]);
const rootDir = dirname(fileURLToPath(import.meta.url));
if (!filePath.startsWith(rootDir)) {
console.error('Security Error: Path traversal attempt blocked for --file');
process.exit(1);
}
const text = await readFile(filePath, 'utf-8');
urls = text.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#'));
} else {
urls = args;
Expand Down
12 changes: 12 additions & 0 deletions config/template.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Resume Template Configuration
colors:
primary: "#2563EB"
accent: "#0F172A"
text: "#334155"
background: "#FFFFFF"
fonts:
primary: "Inter, sans-serif"
secondary: "Georgia, serif"
layout:
spacing: "1.5rem"
borderRadius: "4px"
48 changes: 37 additions & 11 deletions dashboard/internal/data/career.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package data

import (
"encoding/json"
"fmt"
"os"
"path/filepath"
Expand All @@ -11,6 +12,11 @@ import (
"github.com/santifer/career-ops/dashboard/internal/model"
)

type ApplicationData struct {
Version string `json:"version"`
Applications []model.CareerApplication `json:"applications"`
}

var (
reReportLink = regexp.MustCompile(`\[(\d+)\]\(([^)]+)\)`)
reScoreValue = regexp.MustCompile(`(\d+\.?\d*)/5`)
Expand All @@ -24,13 +30,35 @@ var (
reBatchID = regexp.MustCompile(`(?m)^\*\*Batch ID:\*\*\s*(\d+)`)
)

// ParseApplications reads applications.md and returns parsed applications.
// It tries both {path}/applications.md and {path}/data/applications.md for compatibility.
// ParseApplications reads applications data and returns parsed applications.
func ParseApplications(careerOpsPath string) []model.CareerApplication {
// Try JSON format first
jsonPath := filepath.Join(careerOpsPath, "data", "applications.json")
if content, err := os.ReadFile(jsonPath); err == nil {
var data ApplicationData
if err := json.Unmarshal(content, &data); err == nil {
apps := data.Applications
enrichAppsWithURLs(careerOpsPath, apps)
return apps
}
}

// Try fallback to applications.json in root
jsonPathRoot := filepath.Join(careerOpsPath, "applications.json")
if content, err := os.ReadFile(jsonPathRoot); err == nil {
var data ApplicationData
if err := json.Unmarshal(content, &data); err == nil {
apps := data.Applications
enrichAppsWithURLs(careerOpsPath, apps)
return apps
}
}

fmt.Println("[DEPRECATED] Using legacy markdown parsing for applications.md. Please run the JSON migration script.")

filePath := filepath.Join(careerOpsPath, "applications.md")
content, err := os.ReadFile(filePath)
if err != nil {
// Fallback: try data/ subdirectory
filePath = filepath.Join(careerOpsPath, "data", "applications.md")
content, err = os.ReadFile(filePath)
if err != nil {
Expand Down Expand Up @@ -104,12 +132,12 @@ func ParseApplications(careerOpsPath string) []model.CareerApplication {
apps = append(apps, app)
}

// Enrich with job URLs using 5-tier strategy:
// 1. **URL:** field in report header (newest reports)
// 2. **Batch ID:** in report -> batch-input.tsv URL lookup
// 3. report_num -> batch-state completed mapping (legacy)
// 4. scan-history.tsv (pipeline scan entries matched by company+role)
// 5. company name fallback from batch-input.tsv
enrichAppsWithURLs(careerOpsPath, apps)

return apps
}

func enrichAppsWithURLs(careerOpsPath string, apps []model.CareerApplication) {
batchURLs := loadBatchInputURLs(careerOpsPath)
reportNumURLs := loadJobURLs(careerOpsPath)

Expand Down Expand Up @@ -156,8 +184,6 @@ func ParseApplications(careerOpsPath string) []model.CareerApplication {

// Strategy 5: company name fallback from batch-input.tsv
enrichAppURLsByCompany(careerOpsPath, apps)

return apps
}

// loadBatchInputURLs reads batch-input.tsv and returns a map of batch ID -> job URL.
Expand Down
21 changes: 21 additions & 0 deletions dashboard/internal/theme/catppuccin.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,24 @@ func newCatppuccinMocha() Theme {
Pink: lipgloss.Color("#f5c2e7"),
}
}

func newCatppuccinLatte() Theme {
return Theme{
// Catppuccin Latte palette
Base: lipgloss.Color("#eff1f5"),
Surface: lipgloss.Color("#ccd0da"),
Overlay: lipgloss.Color("#bcc0cc"),
Text: lipgloss.Color("#4c4f69"),
Subtext: lipgloss.Color("#6c6f85"),

// Accents
Blue: lipgloss.Color("#1e66f5"),
Mauve: lipgloss.Color("#8839ef"),
Green: lipgloss.Color("#40a02b"),
Yellow: lipgloss.Color("#df8e1d"),
Sky: lipgloss.Color("#04a5e5"),
Peach: lipgloss.Color("#fe640b"),
Red: lipgloss.Color("#d20f39"),
Pink: lipgloss.Color("#ea76cb"),
}
}
4 changes: 3 additions & 1 deletion dashboard/internal/theme/theme.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ type Theme struct {
// NewTheme creates a theme by name. Currently only "catppuccin-mocha" is supported.
func NewTheme(name string) Theme {
switch name {
case "catppuccin-mocha", "":
case "catppuccin-latte", "light":
return newCatppuccinLatte()
case "catppuccin-mocha", "dark", "":
return newCatppuccinMocha()
default:
return newCatppuccinMocha()
Expand Down
Loading