What it does: You send a job posting link — the system reads it, picks the right CV, writes a personalised cover letter, and delivers two ready-to-send PDFs.
Step 1 — Install dependencies (run once)
pip install -r requirements.txtStep 2 — Create your personal profile
cp master_cv.example.json master_cv.jsonEdit master_cv.json with your real name, contact info, work history, and skills.
This file is private and gitignored — it never gets committed.
Step 3 — Add your API keys
cp .env.example .envOpen .env and fill in:
CV_OWNER_SLUG— your name slug used in all output filenames (e.g.Firstname_LASTNAME)OPENAI_API_KEY— from platform.openai.comTELEGRAM_BOT_TOKENandTELEGRAM_CHAT_ID— only needed for the Telegram bot (see docs/bot-setup.md)LINKEDIN_CLIENT_ID/LINKEDIN_CLIENT_SECRET— only needed for LinkedIn posting (see LinkedIn Content section below)
Step 4 — Verify everything is working
uv run main.py testIf you want to try the tool quickly, run these commands from the repository root:
# 1) Install Python deps
pip install -r requirements.txt# 2) Copy env and set your OpenAI key
cp .env.example .env
# Edit .env and set OPENAI_API_KEY (and TELEGRAM_* if using the bot)# 3) Create the internal profile (accepts .tex, .pdf, .jpg/.png)
Examples:
uv run main.py init-profile --cv templates/lato/CV_AI_Data_Lato.tex
uv run main.py init-profile --cv path/to/my_cv.pdf
uv run main.py init-profile --cv path/to/photo_of_cv.jpg# 4) Generate an application from a job URL (optional: pass --cv to override)
uv run main.py apply https://company.com/jobs/12345
uv run main.py apply https://company.com/jobs/12345 --cv path/to/my_cv.pdf# 5) Or start the Telegram bot and use /apply from your phone
uv run main.py bot# 6) Quick health check
uv run main.py testNotes:
- The
--cvflag forinit-profileandapplyaccepts.tex,.pdf, and common image formats (.jpg,.jpeg,.png,.webp). - PDF extraction and image OCR require additional system/software:
pdfminer.six,pytesseract, andPilloware Python dependencies (inrequirements.txt).tesseractOCR engine must be installed on your system for OCR to work. On macOS:brew install tesseract.
- If you only use a LaTeX
.texsource, you do NOT needtesseractor the OCR Python packages — the.texpath works without extra system deps. - The
--templateflag forapplysupportsaltacv(default) andlato.
Start the bot:
uv run main.py botThen in Telegram, send:
/apply https://company.com/jobs/your-job-link
The bot will:
- Read the job posting
- Generate a tailored CV + cover letter (takes ~30 seconds)
- Send you both PDFs to review
- Ask Approve or Reject
- Approve → saves both files to your private archive channel
- Reject → nothing is saved
uv run main.py apply https://company.com/jobs/your-job-linkThe two PDFs are saved in the Applied/ folder.
| What | Where |
|---|---|
| Generated applications (CV + cover letter per job) | Applied/YYYY-MM-DD_Company_Role/ |
| Spontaneous applications (no company) | Applied/YYYY-MM-DD_Spontannee_Role_lang/ |
Output Filenames: All generated CVs and cover letters follow the standardized naming pattern:
{CV_OWNER_SLUG}-{DocumentType}_{Role}_{Language}.{ext}
The slug is set via CV_OWNER_SLUG in your .env file. Examples:
Firstname_LASTNAME-CV_IT_fr.pdf— IT infrastructure CV (French)Firstname_LASTNAME-LettreMotivation_AI_fr.pdf— AI cover letter (French)Firstname_LASTNAME-CV_PhD_en.pdf— PhD application CV (English)
This naming convention makes it easy to identify document type and role at a glance.
Edit one file only:
templates/shared/personal_data.tex
All CVs pull from this file — you never need to update the same detail in multiple places.
Templates are organized by style family:
templates/altacv/ — AltaCV style (xelatex), used for spontaneous applications:
| File | Use for |
|---|---|
CV_AI_MLOps_fr.tex |
AI / MLOps roles (French) |
CV_AI_MLOps_en.tex |
AI / MLOps roles (English) |
CV_DevOps_Alternance_fr.tex |
DevOps alternance (French) |
CV_Polyvalent_fr.tex |
Polyvalent / interim agency (French) |
templates/lato/ — Lato/article style (pdflatex):
| File | Use for |
|---|---|
CV_AI_Data_Lato.tex |
AI / Data Science / Python roles (English) |
CV_IT_Infra_Lato.tex |
IT Support / Network roles (French) |
CV_PhD_Research_en.tex |
PhD / Research applications (English) |
templates/classic/ — ModernCV banking style (pdflatex), 16 role variants.
After editing a lato or classic template, rebuild the PDF:
./compile.sh ai # CV_AI_Data_Lato
./compile.sh it # CV_IT_Infra_Lato
./compile.sh phd # CV_PhD_Research_en
./compile.sh all # rebuild all CV_*.tex across all template foldersGenerate a pre-written CV without LLM — no job URL needed:
uv run main.py spontaneous ai # AI / MLOps (French)
uv run main.py spontaneous ai-en # AI / MLOps (English)
uv run main.py spontaneous mlops # MLOps (French)
uv run main.py spontaneous mlops-en # MLOps (English)
uv run main.py spontaneous devops # DevOps (French)
uv run main.py spontaneous devops-alternance # DevOps alternance (French)
uv run main.py spontaneous phd # PhD / Research (English)
uv run main.py spontaneous polyvalent # Polyvalent / interim (French)
# Add --city to select Montpellier vs Grenoble automatically:
uv run main.py spontaneous ai --city montpellierOutput goes to Applied/YYYY-MM-DD_Spontannee_{role}_{lang}/.
Open the relevant template in cover_letters/:
| File | Language |
|---|---|
Cover_Letter_Template_Fr.tex |
French |
Cover_Letter_Template_En.tex |
English |
Only edit the stock paragraphs (the text that describes your experience).
The four personalisation variables at the top (\CompanyName, \PositionTitle, etc.)
are filled automatically for each application — do not touch them.
This project includes a full pipeline for creating and publishing LinkedIn data-analysis carousel posts.
linkedin/
├── 01_IT_job_market/ # Published: French IT job market (15 cities)
│ ├── carousel.md # Source Markdown for the carousel
│ ├── carousel.pdf # Generated PDF (gitignored)
│ └── post.txt # Post title + text (TITRE DU DOCUMENT on line 1, text after ---)
├── 02_data_science/ # Published: Data jobs in France — offers vs candidates
│ ├── carousel.md
│ ├── carousel.pdf
│ └── post.txt
└── idees_posts.md # Post backlog and ideas (private — gitignored)
# From the repo root:
amir pdf --theme carousel linkedin/02_data_science/carousel.md -o linkedin/02_data_science/carousel.pdfRequires the amir-cli tool with the carousel theme installed.
Install the theme once: bash docs/install-carousel-theme.sh
- Go to developer.linkedin.com → Create App
- Add products: "Share on LinkedIn" + "Sign In with LinkedIn using OpenID Connect"
- Set redirect URI to
http://localhost:8765/callback - Copy Client ID and Secret into
.env(LINKEDIN_CLIENT_ID,LINKEDIN_CLIENT_SECRET) - Run the OAuth flow:
python scripts/linkedin_post.py --authA browser window opens. Approve. Token saved to .linkedin_token.json (gitignored).
python scripts/linkedin_post.py --post linkedin/02_data_science --dry-runPrints the title, first 120 chars of text, and PDF path — no API calls made.
python scripts/linkedin_post.py --post linkedin/02_data_scienceUploads the PDF and publishes the post. Prints the post URL when done.
- Maximum 2 posts per week, minimum 3 days between carousel/analytical posts
- Best days: Monday/Tuesday or Wednesday
- Avoid Friday afternoon, Saturday, Sunday
Before posting, always check the date of the last post:
# Check last post date
import json, requests
from datetime import datetime, timezone
from pathlib import Path
token_data = json.loads(Path(".linkedin_token.json").read_text())
token = token_data["access_token"]
owner_urn = f"urn:li:person:{token_data['sub']}"
headers = {"Authorization": f"Bearer {token}", "LinkedIn-Version": "202604",
"X-Restli-Protocol-Version": "2.0.0"}
r = requests.get(
f"https://api.linkedin.com/rest/posts?author={owner_urn}&q=author&count=5&sortBy=LAST_MODIFIED",
headers=headers, timeout=10)
ts = r.json()["elements"][0].get("publishedAt")
last = datetime.fromtimestamp(ts/1000, tz=timezone.utc)
print(f"Last post: {last.strftime('%A %d %B %Y')}")- LinkedIn-Version header: Calendar versioning (
YYYYMM). Current working version:202604(update if you see a426 NONEXISTENT_VERSIONerror) - Document post payload:
content.mediarequires"id": document_urn(not"document":)
node scripts/data_jobs_scraper.mjsReads ROME codes from docs/it_rome_codes.json, visits
candidat.francetravail.fr/metierscope/fiche-metier/{CODE}/ for each role,
extracts national offer count and candidate count, saves to docs/data_jobs_stats.json.
Key ROME codes for data jobs:
| Code | Métier |
|---|---|
| M1405 | Data Scientist |
| M1419 | Data Analyst |
| M1811 | Data Engineer |
| M1889 | Ingénieur IA |
| M1872 | Consultant BI |
Note: France Travail uses non-breaking spaces as thousands separators ("3 830").
The scraper strips all non-digit characters before parsing numbers.
For developers, AI agents, or anyone who wants to understand the internals:
| Topic | File |
|---|---|
| How the pipeline works (scraping, LLM, compilation) | docs/architecture.md |
| LaTeX macros, template structure, adding new styles | docs/latex-templates.md |
| Telegram bot setup, AUTO_APPLY, source files | docs/bot-setup.md |
| Git workflow, commit conventions, tracked files | docs/git-workflow.md |
Contact details are in templates/shared/personal_data.tex (private, not committed).