claude-code-arcane 1.4.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -0
- package/README.md +3 -3
- package/dist/cli.js +2 -0
- package/docs/SKILLS-CATALOG.md +6 -2
- package/docs/USER-GUIDE.md +1 -1
- package/docs/coding-standards.md +26 -0
- package/package.json +2 -1
- package/profiles/job-hunt.yaml +12 -2
- package/rules/drafter-reviewer.md +39 -0
- package/skills/cold-outreach/SKILL.md +10 -10
- package/skills/cold-outreach/references/recruiter-playbook.md +5 -5
- package/skills/cover-letter/SKILL.md +8 -8
- package/skills/cv-ats-export/SKILL.md +19 -6
- package/skills/cv-ats-export/scripts/cv_export.py +49 -4
- package/skills/cv-ats-export/scripts/verify_pdf.py +90 -0
- package/skills/cv-tailor/SKILL.md +7 -7
- package/skills/cv-tailor/references/ats-keywords.md +9 -9
- package/skills/interview-prep/SKILL.md +7 -7
- package/skills/interview-prep/references/star-framework.md +10 -10
- package/skills/job-aplicar/SKILL.md +88 -0
- package/skills/job-hunt/SKILL.md +20 -13
- package/skills/job-hunt/references/templates/Aplicacion.md +6 -2
- package/skills/job-hunt/references/templates/CV Custom.md +1 -1
- package/skills/job-hunt/references/templates/Contacto.md +1 -1
- package/skills/job-hunt/references/templates/Dashboard.md +19 -15
- package/skills/job-hunt/references/templates/Perfil.md +2 -2
- package/skills/job-outcome/SKILL.md +55 -0
- package/skills/job-scrape/SKILL.md +95 -0
- package/skills/job-scrape/references/dedup-playbook.md +25 -0
- package/skills/job-scrape/references/getonbrd-cli.md +62 -0
- package/skills/job-scrape/references/getonbrd-urls.md +37 -0
- package/skills/job-scrape/references/linkedin-cli.md +64 -0
- package/skills/job-scrape/references/linkedin-urls.md +42 -0
- package/skills/job-scrape/references/seen_jobs.schema.json +68 -0
- package/skills/job-scrape/scripts/getonbrd_search.py +501 -0
- package/skills/job-scrape/scripts/linkedin_search.py +428 -0
- package/skills/job-search/SKILL.md +22 -19
- package/skills/job-search/references/scoring-rubric.md +8 -8
- package/skills/job-upskill/SKILL.md +58 -0
- package/skills/linkedin-optimize/SKILL.md +5 -5
- package/skills/master-profile/SKILL.md +17 -17
- package/skills/network-map/SKILL.md +7 -7
- package/skills/personal-brand/SKILL.md +10 -10
- package/skills/personal-brand/references/post-pillars.md +14 -14
- package/skills/portfolio-site/SKILL.md +11 -11
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
"""
|
|
2
|
+
linkedin_search.py - Search jobs on LinkedIn's public jobs-guest endpoints.
|
|
3
|
+
|
|
4
|
+
Any country/region plus remote. No authentication, no API key, no dependencies
|
|
5
|
+
beyond the Python 3 standard library.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
python linkedin_search.py search -l "<place>" [flags]
|
|
9
|
+
python linkedin_search.py detail <id|url> [--format json|plain]
|
|
10
|
+
|
|
11
|
+
Ported from the TypeScript CLI vendored from MadsLorentzen/ai-job-search: skill
|
|
12
|
+
scripts run in the user's project without a build step, and TypeScript there
|
|
13
|
+
means requiring Node 24+ for native type stripping (see docs/coding-standards.md).
|
|
14
|
+
|
|
15
|
+
Personal use only. This reads LinkedIn's public job pages; automated access is
|
|
16
|
+
against LinkedIn's Terms of Service, so keep volume low and do not use it
|
|
17
|
+
commercially or for bulk data collection. Run it on your own responsibility.
|
|
18
|
+
|
|
19
|
+
Errors go to stderr as {"error": "...", "code": "..."} with exit code 1;
|
|
20
|
+
non-fatal diagnostics use the same shape with a "warning" key.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import html
|
|
25
|
+
import json
|
|
26
|
+
import random
|
|
27
|
+
import re
|
|
28
|
+
import sys
|
|
29
|
+
import time
|
|
30
|
+
import urllib.error
|
|
31
|
+
import urllib.parse
|
|
32
|
+
import urllib.request
|
|
33
|
+
|
|
34
|
+
SEARCH_URL = "https://www.linkedin.com/jobs-guest/jobs/api/seeMoreJobPostings/search"
|
|
35
|
+
DETAIL_URL = "https://www.linkedin.com/jobs-guest/jobs/api/jobPosting"
|
|
36
|
+
|
|
37
|
+
UA = (
|
|
38
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
|
|
39
|
+
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# Below this size a response is empty/an error page, not a markup change.
|
|
43
|
+
MARKUP_SANITY_BYTES = 5000
|
|
44
|
+
|
|
45
|
+
MAX_RETRIES = 6
|
|
46
|
+
JOBAGE_ALL = 9999
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def write_error(error, code):
|
|
50
|
+
sys.stderr.write(json.dumps({"error": error, "code": code}) + "\n")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def write_warning(warning, code):
|
|
54
|
+
"""Non-fatal diagnostic on stderr. Same JSON-line shape, `warning` key."""
|
|
55
|
+
sys.stderr.write(json.dumps({"warning": warning, "code": code}) + "\n")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def html_fetch(url):
|
|
59
|
+
"""Fetch HTML with exponential backoff on 429/5xx. Returns "" on a 404."""
|
|
60
|
+
delay = 0.5
|
|
61
|
+
for attempt in range(MAX_RETRIES + 1):
|
|
62
|
+
request = urllib.request.Request(
|
|
63
|
+
url,
|
|
64
|
+
headers={
|
|
65
|
+
"User-Agent": UA,
|
|
66
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
67
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
68
|
+
"X-Requested-With": "XMLHttpRequest",
|
|
69
|
+
},
|
|
70
|
+
)
|
|
71
|
+
try:
|
|
72
|
+
with urllib.request.urlopen(request, timeout=30) as response:
|
|
73
|
+
return response.read().decode("utf-8", errors="replace")
|
|
74
|
+
except urllib.error.HTTPError as e:
|
|
75
|
+
if e.code == 404:
|
|
76
|
+
return ""
|
|
77
|
+
retryable = e.code == 429 or e.code >= 500
|
|
78
|
+
if not retryable or attempt == MAX_RETRIES:
|
|
79
|
+
raise RuntimeError(f"Request failed: {e.code} {e.reason}") from None
|
|
80
|
+
time.sleep(delay + random.random() * 0.5)
|
|
81
|
+
delay = min(delay * 2, 8)
|
|
82
|
+
raise RuntimeError("Request failed after max retries")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def unescape(markup):
|
|
86
|
+
"""
|
|
87
|
+
html.unescape plus -> plain space. U+00A0 is invisible in a diff and
|
|
88
|
+
breaks keyword matching downstream, which is the whole point of pulling the JD.
|
|
89
|
+
"""
|
|
90
|
+
return html.unescape(markup).replace("\xa0", " ")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def strip_tags(markup):
|
|
94
|
+
return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", markup)).strip()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def clean(markup):
|
|
98
|
+
return unescape(strip_tags(markup))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
CARD_SPLIT_RE = re.compile(r'data-entity-urn="urn:li:jobPosting:')
|
|
102
|
+
CARD_ID_RE = re.compile(r"^(\d+)")
|
|
103
|
+
CARD_LINK_RE = re.compile(r'class="base-card__full-link[^"]*"[^>]*href="([^"]+)"', re.I)
|
|
104
|
+
CARD_TITLE_RE = re.compile(r'class="base-search-card__title"[^>]*>(.*?)</h3>', re.I | re.S)
|
|
105
|
+
CARD_SR_ONLY_RE = re.compile(r'class="sr-only"[^>]*>(.*?)</span>', re.I | re.S)
|
|
106
|
+
CARD_SUBTITLE_RE = re.compile(r'class="base-search-card__subtitle"[^>]*>(.*?)</h4>', re.I | re.S)
|
|
107
|
+
CARD_HREF_RE = re.compile(r'href="([^"]+)"', re.I)
|
|
108
|
+
CARD_LOCATION_RE = re.compile(r'class="job-search-card__location"[^>]*>(.*?)</span>', re.I | re.S)
|
|
109
|
+
CARD_DATE_RE = re.compile(r'class="job-search-card__listdate[^"]*"[^>]*datetime="([^"]+)"', re.I)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def parse_job_cards(markup):
|
|
113
|
+
"""
|
|
114
|
+
Parse the search response: a flat list of <li> job cards. We split on the
|
|
115
|
+
job-posting URN and parse each chunk independently so one malformed card
|
|
116
|
+
cannot break the rest.
|
|
117
|
+
"""
|
|
118
|
+
results = []
|
|
119
|
+
|
|
120
|
+
for chunk in CARD_SPLIT_RE.split(markup)[1:]:
|
|
121
|
+
id_match = CARD_ID_RE.match(chunk)
|
|
122
|
+
if not id_match:
|
|
123
|
+
continue
|
|
124
|
+
job_id = id_match.group(1)
|
|
125
|
+
|
|
126
|
+
# Full link + title (title lives in the sr-only span or the <h3> title).
|
|
127
|
+
link_match = CARD_LINK_RE.search(chunk)
|
|
128
|
+
url = unescape(link_match.group(1)).split("?")[0] if link_match else ""
|
|
129
|
+
|
|
130
|
+
title = None
|
|
131
|
+
h3 = CARD_TITLE_RE.search(chunk)
|
|
132
|
+
if h3:
|
|
133
|
+
title = clean(h3.group(1))
|
|
134
|
+
if not title:
|
|
135
|
+
sr = CARD_SR_ONLY_RE.search(chunk)
|
|
136
|
+
if sr:
|
|
137
|
+
title = clean(sr.group(1))
|
|
138
|
+
if not title:
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
# Company (subtitle <h4> with optional inner <a>).
|
|
142
|
+
company = None
|
|
143
|
+
company_url = None
|
|
144
|
+
sub = CARD_SUBTITLE_RE.search(chunk)
|
|
145
|
+
if sub:
|
|
146
|
+
href = CARD_HREF_RE.search(sub.group(1))
|
|
147
|
+
if href:
|
|
148
|
+
company_url = unescape(href.group(1)).split("?")[0]
|
|
149
|
+
company = clean(sub.group(1)) or None
|
|
150
|
+
|
|
151
|
+
# Location + date.
|
|
152
|
+
loc = CARD_LOCATION_RE.search(chunk)
|
|
153
|
+
location = (clean(loc.group(1)) or None) if loc else None
|
|
154
|
+
date_match = CARD_DATE_RE.search(chunk)
|
|
155
|
+
|
|
156
|
+
results.append(
|
|
157
|
+
{
|
|
158
|
+
"id": job_id,
|
|
159
|
+
"title": title,
|
|
160
|
+
"company": company,
|
|
161
|
+
"companyUrl": company_url,
|
|
162
|
+
"location": location,
|
|
163
|
+
"date": date_match.group(1) if date_match else None,
|
|
164
|
+
"url": url or f"https://www.linkedin.com/jobs/view/{job_id}",
|
|
165
|
+
}
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
# Regex parsing degrades silently when LinkedIn changes its markup: a page
|
|
169
|
+
# full of content yields zero cards, which reads exactly like "no job matches
|
|
170
|
+
# the query". Warn so the caller can tell the two apart.
|
|
171
|
+
if not results and len(markup) > MARKUP_SANITY_BYTES:
|
|
172
|
+
write_warning(
|
|
173
|
+
f"parsed 0 job cards out of a {len(markup)}-byte response; "
|
|
174
|
+
"LinkedIn markup may have changed",
|
|
175
|
+
"NO_CARDS_PARSED",
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
return results
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
DETAIL_TITLE_RE = re.compile(
|
|
182
|
+
r'class="(?:top-card-layout__title|topcard__title)[^"]*"[^>]*>(.*?)</h[12]>', re.I | re.S
|
|
183
|
+
)
|
|
184
|
+
DETAIL_ORG_RE = re.compile(
|
|
185
|
+
r'class="topcard__org-name-link[^"]*"[^>]*href="([^"]+)"[^>]*>(.*?)</a>', re.I | re.S
|
|
186
|
+
)
|
|
187
|
+
DETAIL_LOCATION_RE = re.compile(
|
|
188
|
+
r'class="topcard__flavor topcard__flavor--bullet"[^>]*>(.*?)</span>', re.I | re.S
|
|
189
|
+
)
|
|
190
|
+
DETAIL_DESC_RE = re.compile(
|
|
191
|
+
r'class="(?:show-more-less-html__markup|description__text[^"]*)"[^>]*>(.*?)</div>', re.I | re.S
|
|
192
|
+
)
|
|
193
|
+
DETAIL_CRITERIA_RE = re.compile(
|
|
194
|
+
r'class="description__job-criteria-subheader"[^>]*>(.*?)</h3>'
|
|
195
|
+
r'.*?class="description__job-criteria-text[^"]*"[^>]*>(.*?)</span>',
|
|
196
|
+
re.I | re.S,
|
|
197
|
+
)
|
|
198
|
+
DETAIL_APPLY_RE = re.compile(r'class="topcard__link[^"]*"[^>]*href="([^"]+)"', re.I)
|
|
199
|
+
BR_RE = re.compile(r"<\s*br\s*/?>", re.I)
|
|
200
|
+
BLOCK_END_RE = re.compile(r"</(p|li|ul|ol|div|h\d)>", re.I)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def parse_job_detail(markup, job_id):
|
|
204
|
+
"""Parse the single-job detail page."""
|
|
205
|
+
title_match = DETAIL_TITLE_RE.search(markup)
|
|
206
|
+
org_match = DETAIL_ORG_RE.search(markup)
|
|
207
|
+
company = (clean(org_match.group(2)) or None) if org_match else None
|
|
208
|
+
company_url = unescape(org_match.group(1)).split("?")[0] if org_match else None
|
|
209
|
+
|
|
210
|
+
loc_match = DETAIL_LOCATION_RE.search(markup)
|
|
211
|
+
location = (clean(loc_match.group(1)) or None) if loc_match else None
|
|
212
|
+
|
|
213
|
+
# Rich description block. Keep paragraph/line breaks as newlines.
|
|
214
|
+
description = None
|
|
215
|
+
desc_match = DETAIL_DESC_RE.search(markup)
|
|
216
|
+
if desc_match:
|
|
217
|
+
with_breaks = BLOCK_END_RE.sub("\n", BR_RE.sub("\n", desc_match.group(1)))
|
|
218
|
+
description = re.sub(r"\n{3,}", "\n\n", unescape(strip_tags(with_breaks))).strip() or None
|
|
219
|
+
|
|
220
|
+
# Job-criteria items: subheader label -> text value.
|
|
221
|
+
criteria = {
|
|
222
|
+
clean(label).lower(): clean(value)
|
|
223
|
+
for label, value in DETAIL_CRITERIA_RE.findall(markup)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
apply_match = DETAIL_APPLY_RE.search(markup)
|
|
227
|
+
|
|
228
|
+
return {
|
|
229
|
+
"id": job_id,
|
|
230
|
+
"title": clean(title_match.group(1)) if title_match else "(untitled)",
|
|
231
|
+
"company": company,
|
|
232
|
+
"companyUrl": company_url,
|
|
233
|
+
"location": location,
|
|
234
|
+
"date": None,
|
|
235
|
+
"url": f"https://www.linkedin.com/jobs/view/{job_id}",
|
|
236
|
+
"description": description,
|
|
237
|
+
"seniority": criteria.get("seniority level"),
|
|
238
|
+
"employmentType": criteria.get("employment type"),
|
|
239
|
+
"jobFunction": criteria.get("job function"),
|
|
240
|
+
"industries": criteria.get("industries"),
|
|
241
|
+
"applyUrl": unescape(apply_match.group(1)).split("?")[0] if apply_match else None,
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def jobage_to_tpr(days):
|
|
246
|
+
"""Convert a job-age in days to LinkedIn's f_TPR seconds value."""
|
|
247
|
+
if not days or days <= 0 or days >= JOBAGE_ALL:
|
|
248
|
+
return None
|
|
249
|
+
return f"r{days * 86400}"
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def work_type_flag(mode):
|
|
253
|
+
"""Workplace-type flag: on-site=1, remote=2, hybrid=3."""
|
|
254
|
+
return {"remote": "2", "hybrid": "3", "onsite": "1", "on-site": "1"}.get((mode or "").lower())
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def normalize_id(value):
|
|
258
|
+
"""
|
|
259
|
+
Accept a raw job ID, a job-view URL, or a job URN. The ID may be followed by
|
|
260
|
+
a slash as well as a query string: the share button yields URLs shaped like
|
|
261
|
+
`/jobs/view/<id>/?refId=...`, so anchoring on `?` alone rejects the most
|
|
262
|
+
common paste.
|
|
263
|
+
"""
|
|
264
|
+
urn = re.search(r"urn:li:jobPosting:(\d+)", value)
|
|
265
|
+
if urn:
|
|
266
|
+
return urn.group(1)
|
|
267
|
+
url = re.search(r"-(\d{6,})(?:[/?]|$)", value) or re.search(r"/(\d{6,})(?:[/?]|$)", value)
|
|
268
|
+
if url:
|
|
269
|
+
return url.group(1)
|
|
270
|
+
if re.fullmatch(r"\d{6,}", value):
|
|
271
|
+
return value
|
|
272
|
+
return None
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def build_search_url(args):
|
|
276
|
+
params = []
|
|
277
|
+
if args.query:
|
|
278
|
+
params.append(("keywords", args.query))
|
|
279
|
+
if args.location:
|
|
280
|
+
params.append(("location", args.location))
|
|
281
|
+
tpr = jobage_to_tpr(args.jobage)
|
|
282
|
+
if tpr:
|
|
283
|
+
params.append(("f_TPR", tpr))
|
|
284
|
+
work_type = work_type_flag(args.remote)
|
|
285
|
+
if work_type:
|
|
286
|
+
params.append(("f_WT", work_type))
|
|
287
|
+
params.append(("start", str((args.page - 1) * 10)))
|
|
288
|
+
return f"{SEARCH_URL}?{urllib.parse.urlencode(params)}"
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def render_table(cards):
|
|
292
|
+
if not cards:
|
|
293
|
+
return "No results."
|
|
294
|
+
header = (
|
|
295
|
+
"ID".ljust(11) + " " + "TITLE".ljust(42) + " " + "COMPANY".ljust(26) + " "
|
|
296
|
+
+ "LOCATION".ljust(24) + " DATE"
|
|
297
|
+
)
|
|
298
|
+
rows = [
|
|
299
|
+
f"{c['id']:<11} {(c['title'] or '')[:42]:<42} {(c['company'] or '-')[:26]:<26} "
|
|
300
|
+
f"{(c['location'] or '-')[:24]:<24} {c['date'] or '-'}"
|
|
301
|
+
for c in cards
|
|
302
|
+
]
|
|
303
|
+
return "\n".join([header, "-" * len(header), *rows])
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def render_plain(cards):
|
|
307
|
+
return "\n\n".join(
|
|
308
|
+
f"{c['title']}\n {c['company'] or '-'} · {c['location'] or '-'} · {c['date'] or '-'}\n"
|
|
309
|
+
f" id: {c['id']}\n {c['url']}"
|
|
310
|
+
for c in cards
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def run_search(args):
|
|
315
|
+
try:
|
|
316
|
+
cards = parse_job_cards(html_fetch(build_search_url(args)))
|
|
317
|
+
if args.limit is not None and args.limit >= 0:
|
|
318
|
+
cards = cards[: args.limit]
|
|
319
|
+
|
|
320
|
+
if args.format == "table":
|
|
321
|
+
sys.stdout.write(render_table(cards) + "\n")
|
|
322
|
+
elif args.format == "plain":
|
|
323
|
+
sys.stdout.write(render_plain(cards) + "\n")
|
|
324
|
+
else:
|
|
325
|
+
payload = {"meta": {"count": len(cards), "page": args.page}, "results": cards}
|
|
326
|
+
sys.stdout.write(json.dumps(payload, indent=2, ensure_ascii=False) + "\n")
|
|
327
|
+
return 0
|
|
328
|
+
except Exception as e:
|
|
329
|
+
write_error(str(e), "SEARCH_FAILED")
|
|
330
|
+
return 1
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def run_detail(args):
|
|
334
|
+
job_id = normalize_id(args.id)
|
|
335
|
+
if not job_id:
|
|
336
|
+
write_error(f'Could not parse a job ID from "{args.id}"', "BAD_ID")
|
|
337
|
+
return 1
|
|
338
|
+
try:
|
|
339
|
+
markup = html_fetch(f"{DETAIL_URL}/{job_id}")
|
|
340
|
+
if not markup:
|
|
341
|
+
write_error("Job not found", "NOT_FOUND")
|
|
342
|
+
return 1
|
|
343
|
+
job = parse_job_detail(markup, job_id)
|
|
344
|
+
|
|
345
|
+
if args.format == "plain":
|
|
346
|
+
lines = [
|
|
347
|
+
job["title"],
|
|
348
|
+
f"{job['company'] or '-'} · {job['location'] or '-'}",
|
|
349
|
+
"",
|
|
350
|
+
f"Seniority: {job['seniority']}" if job["seniority"] else "",
|
|
351
|
+
f"Employment: {job['employmentType']}" if job["employmentType"] else "",
|
|
352
|
+
f"Function: {job['jobFunction']}" if job["jobFunction"] else "",
|
|
353
|
+
f"Industries: {job['industries']}" if job["industries"] else "",
|
|
354
|
+
"",
|
|
355
|
+
job["description"] or "(no description)",
|
|
356
|
+
"",
|
|
357
|
+
f"URL: {job['url']}",
|
|
358
|
+
f"Apply: {job['applyUrl']}" if job["applyUrl"] else "",
|
|
359
|
+
]
|
|
360
|
+
sys.stdout.write("\n".join(line for line in lines if line != "") + "\n")
|
|
361
|
+
else:
|
|
362
|
+
sys.stdout.write(json.dumps(job, indent=2, ensure_ascii=False) + "\n")
|
|
363
|
+
return 0
|
|
364
|
+
except Exception as e:
|
|
365
|
+
write_error(str(e), "DETAIL_FAILED")
|
|
366
|
+
return 1
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
class JsonErrorParser(argparse.ArgumentParser):
|
|
370
|
+
"""Keep the CLI's error contract: a JSON line on stderr and exit code 1."""
|
|
371
|
+
|
|
372
|
+
def error(self, message):
|
|
373
|
+
code = "NO_LOCATION" if "location" in message and "required" in message else "BAD_ARG"
|
|
374
|
+
write_error(message, code)
|
|
375
|
+
raise SystemExit(1)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def build_parser():
|
|
379
|
+
parser = JsonErrorParser(
|
|
380
|
+
prog="linkedin_search.py",
|
|
381
|
+
description="Search jobs on LinkedIn (any country/region, plus remote). "
|
|
382
|
+
"Personal use only - uses LinkedIn's public pages; keep volume low (LinkedIn ToS).",
|
|
383
|
+
)
|
|
384
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
385
|
+
|
|
386
|
+
search = sub.add_parser("search", help="search job postings")
|
|
387
|
+
search.add_argument("-l", "--location", required=True,
|
|
388
|
+
help='REQUIRED. e.g. "Berlin, Germany", "Spain", "Remote"')
|
|
389
|
+
search.add_argument("-q", "--query", help="keywords (job title, skill, or role)")
|
|
390
|
+
search.add_argument("--jobage", type=int, default=JOBAGE_ALL,
|
|
391
|
+
help="posted within N days: 1, 7, 14, 30. Default: all")
|
|
392
|
+
search.add_argument("--remote", choices=["remote", "hybrid", "onsite", "on-site"],
|
|
393
|
+
help="filter by workplace type")
|
|
394
|
+
search.add_argument("--page", type=int, default=1, help="1-indexed page (10 results/page)")
|
|
395
|
+
search.add_argument("-n", "--limit", type=int,
|
|
396
|
+
help="cap results emitted (client-side; 0 emits none)")
|
|
397
|
+
search.add_argument("--format", choices=["json", "table", "plain"], default="json")
|
|
398
|
+
search.set_defaults(func=run_search)
|
|
399
|
+
|
|
400
|
+
detail = sub.add_parser("detail", help="full detail of one posting")
|
|
401
|
+
detail.add_argument("id", help="job ID, jobs/view URL (with or without trailing slash), or URN")
|
|
402
|
+
detail.add_argument("--format", choices=["json", "plain"], default="json")
|
|
403
|
+
detail.set_defaults(func=run_detail)
|
|
404
|
+
|
|
405
|
+
return parser
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def force_utf8_streams():
|
|
409
|
+
"""
|
|
410
|
+
On Windows stdout defaults to cp1252, so a bullet or an accent in a JD raises
|
|
411
|
+
UnicodeEncodeError the moment output is piped to a file. Node writes UTF-8
|
|
412
|
+
unconditionally; Python has to be told.
|
|
413
|
+
"""
|
|
414
|
+
for stream in (sys.stdout, sys.stderr):
|
|
415
|
+
if hasattr(stream, "reconfigure"):
|
|
416
|
+
stream.reconfigure(encoding="utf-8")
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def main():
|
|
420
|
+
force_utf8_streams()
|
|
421
|
+
args = build_parser().parse_args()
|
|
422
|
+
if getattr(args, "page", 1) < 1:
|
|
423
|
+
args.page = 1
|
|
424
|
+
sys.exit(args.func(args))
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
if __name__ == "__main__":
|
|
428
|
+
main()
|
|
@@ -7,7 +7,7 @@ user-invocable: true
|
|
|
7
7
|
allowed-tools: Read, Glob, Grep, Write, Edit, WebFetch, WebSearch
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
-
# Job Search
|
|
10
|
+
# Job Search - Búsqueda, scoring y plan
|
|
11
11
|
|
|
12
12
|
Encontrás ofertas, las **scoreás** contra el perfil del usuario, priorizás, y armás un **plan de búsqueda de 7 días** con objetivos diarios. Alimentás el workspace con notas de aplicación y empresa para que el resto del flujo (`/cv-tailor`, `/cover-letter`, etc.) las consuma.
|
|
13
13
|
|
|
@@ -18,45 +18,48 @@ Encontrás ofertas, las **scoreás** contra el perfil del usuario, priorizás, y
|
|
|
18
18
|
|
|
19
19
|
## Modos
|
|
20
20
|
|
|
21
|
-
### `search <query>`
|
|
21
|
+
### `search <query>` - Encontrar ofertas
|
|
22
|
+
|
|
23
|
+
> Si el skill `job-scrape` está instalado, para corridas recurrentes preferí `/job-scrape`: usa CLIs locales (LinkedIn + GetOnBoard) con dedup entre corridas. Este modo queda para búsquedas puntuales o fuentes que los CLIs no cubren.
|
|
24
|
+
|
|
22
25
|
1. **Sourcing.** Conseguir ofertas frescas que matcheen el perfil (rol + stack + modalidad + mercado). Dos caminos:
|
|
23
|
-
- **Apify MCP (opcional).** Si hay un MCP de Apify conectado, podés usar un actor de scraping de LinkedIn Jobs para traer ofertas estructuradas. Ejemplo de pedido: *"Scrapeá LinkedIn jobs para [rol] posteadas en los últimos 7 días en empresas de 50
|
|
26
|
+
- **Apify MCP (opcional).** Si hay un MCP de Apify conectado, podés usar un actor de scraping de LinkedIn Jobs para traer ofertas estructuradas. Ejemplo de pedido: *"Scrapeá LinkedIn jobs para [rol] posteadas en los últimos 7 días en empresas de 50-5000 empleados; filtrá por match con mi background en [dominio]; devolvé tabla rankeada con empresa, rol, ubicación, salario, fecha de posteo y fit score 1-10 basado en mi resume; marcá el top 10 para aplicar hoy."* Buscá el actor con ToolSearch (`apify`) si el MCP está presente. **⚠️ OBLIGATORIO: antes de invocar Apify, mostrá el warning de abajo y pedí confirmación explícita.**
|
|
24
27
|
- **WebSearch/WebFetch (default recomendado).** Camino sin Apify: buscar en job boards (muchos con feed/API pública: WeWorkRemotely, RemoteOK, etc.) y fetchear los JDs. Es el default seguro y gratis.
|
|
25
28
|
|
|
26
|
-
> ### ⚠️ Warning Apify
|
|
29
|
+
> ### ⚠️ Warning Apify - mostralo SIEMPRE antes de usar esta parte
|
|
27
30
|
>
|
|
28
31
|
> Antes de correr cualquier actor de Apify (especialmente scraping de LinkedIn), presentá estos puntos al usuario y esperá confirmación explícita. No invoques Apify "en silencio".
|
|
29
32
|
>
|
|
30
33
|
> 1. **No es gratis.** Apify cobra por uso (compute units / por resultado); un scrape grande consume crédito. El tier free es limitado.
|
|
31
34
|
> 2. **Scrapear LinkedIn viola sus Términos de Servicio.** LinkedIn detecta y bloquea scraping activamente y puede **restringir o banear la cuenta**. La legalidad de scrapear datos públicos está en zona gris (caso hiQ vs LinkedIn); el riesgo de ToS/cuenta es real y es del usuario, no de Apify. Preferir actors sobre **job boards públicos** antes que LinkedIn directo; si igual se scrapea LinkedIn, hacerlo de forma consciente y no con una cuenta crítica.
|
|
32
|
-
> 3. **El API token de Apify es sensible.** Autoriza correr actors (y gastar el plan) en nombre del usuario. Mantenerlo seguro; no exponerlo ni commitearlo. (Apify no recolecta conversaciones/tokens y trata a los actors como no confiables: les da un proxy y no les entrega las credenciales
|
|
35
|
+
> 3. **El API token de Apify es sensible.** Autoriza correr actors (y gastar el plan) en nombre del usuario. Mantenerlo seguro; no exponerlo ni commitearlo. (Apify no recolecta conversaciones/tokens y trata a los actors como no confiables: les da un proxy y no les entrega las credenciales - un actor malicioso puede usar las tools habilitadas pero no exfiltrar el token.)
|
|
33
36
|
> 4. **Calidad/estabilidad variable.** Los scrapers de LinkedIn se rompen seguido; los resultados pueden venir incompletos o desactualizados.
|
|
34
37
|
>
|
|
35
38
|
> Si el usuario no confirma, usá el camino WebSearch/WebFetch.
|
|
36
39
|
2. Para cada oferta candidata: extraer rol, empresa, stack, modalidad, ubicación, rango salarial, link, fecha de cierre.
|
|
37
40
|
3. Scorear (ver rúbrica) y filtrar por el umbral del perfil.
|
|
38
|
-
4. Crear nota en `03-Aplicaciones/` (template `Aplicacion`, estado `interesado`, con `
|
|
41
|
+
4. Crear nota en `03-Aplicaciones/` (template `Aplicacion`, estado `interesado`, con `match_score`) + nota en `04-Empresas/` (template `Empresa`) por cada una sobre el umbral.
|
|
39
42
|
5. Actualizar el `00-Dashboard.md` con las activas por prioridad, marcando el **top 10 para hoy**.
|
|
40
43
|
|
|
41
|
-
### `score <job-url>`
|
|
44
|
+
### `score <job-url>` - Scorear una oferta puntual
|
|
42
45
|
1. WebFetch del JD.
|
|
43
|
-
2. Aplicar la rúbrica contra el perfil → score 0
|
|
46
|
+
2. Aplicar la rúbrica contra el perfil → score 0-100 + breakdown + recomendación (aplicar / borderline / skip).
|
|
44
47
|
3. Si pasa, crear las notas como arriba.
|
|
45
48
|
|
|
46
|
-
### `plan`
|
|
49
|
+
### `plan` - Plan de búsqueda de 7 días
|
|
47
50
|
Generar un plan accionable con objetivos diarios. Ejemplo de esqueleto (ajustar al estado del usuario):
|
|
48
51
|
|
|
49
|
-
- **Día 1
|
|
50
|
-
- **Día 2
|
|
51
|
-
- **Día 3
|
|
52
|
-
- **Día 4
|
|
53
|
-
- **Día 5
|
|
54
|
-
- **Día 6
|
|
55
|
-
- **Día 7
|
|
52
|
+
- **Día 1 - Base.** Perfil maestro + perfiles por rol al día; LinkedIn optimizado (`/linkedin-optimize`).
|
|
53
|
+
- **Día 2 - Sourcing.** Buscar y scorear 15-20 ofertas; quedarte con las ≥ umbral.
|
|
54
|
+
- **Día 3 - Research.** Empresas + contactos objetivo de las top.
|
|
55
|
+
- **Día 4 - Aplicaciones tanda 1.** CV custom + cover letter para las 5 de mayor prioridad (`/cv-tailor`, `/cv-ats-export`, `/cover-letter`).
|
|
56
|
+
- **Día 5 - Outreach.** Mensajes a recruiters/hiring managers de las top (`/cold-outreach`).
|
|
57
|
+
- **Día 6 - Aplicaciones tanda 2** + follow-ups de la tanda 1.
|
|
58
|
+
- **Día 7 - Review.** Métricas (aplicadas / respuestas / entrevistas), ajustar perfil/CV según señales, planear próxima semana.
|
|
56
59
|
|
|
57
60
|
## Rúbrica de scoring
|
|
58
61
|
|
|
59
|
-
Ver `references/scoring-rubric.md`. En resumen, score ponderado 0
|
|
62
|
+
Ver `references/scoring-rubric.md`. En resumen, score ponderado 0-100 sobre:
|
|
60
63
|
- Match de stack/skills (peso alto)
|
|
61
64
|
- Seniority correcto (penalizar over/under-qualified)
|
|
62
65
|
- Modalidad y mercado/elegibilidad (remoto, zona horaria, visa)
|
|
@@ -68,14 +71,14 @@ Umbral default ≥75 (configurable en el frontmatter del perfil, campo `match_sc
|
|
|
68
71
|
|
|
69
72
|
## Tracking
|
|
70
73
|
|
|
71
|
-
- Estados de aplicación: `interesado` → `aplicado` → `
|
|
74
|
+
- Estados de aplicación: `interesado` → `aplicado` → `entrevista` → `oferta`, y cierres: `contratado` / `rechazado` / `sin_respuesta` / `declinada` / `descartado` (los registra `/job-outcome`).
|
|
72
75
|
- Mantener el `00-Dashboard.md` como vista única: activas por prioridad, próximas entrevistas, qué cierra pronto, a seguir esta semana.
|
|
73
76
|
|
|
74
77
|
## Reglas
|
|
75
78
|
|
|
76
79
|
- No aplicar a todo: el umbral existe para concentrar esfuerzo donde hay match real.
|
|
77
80
|
- Marcar gaps con honestidad al scorear (no inflar el match).
|
|
78
|
-
- Respetar elegibilidad (visa/zona horaria)
|
|
81
|
+
- Respetar elegibilidad (visa/zona horaria) - un score alto con bloqueo de elegibilidad no sirve.
|
|
79
82
|
- No guardar datos sensibles de recruiters en remotos sin confirmación.
|
|
80
83
|
|
|
81
84
|
## Handoff
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Job Scoring Rubric
|
|
2
2
|
|
|
3
|
-
Score ponderado 0
|
|
3
|
+
Score ponderado 0-100 para decidir dónde invertir esfuerzo. El objetivo no es aplicar a todo: es concentrarte en ofertas con match real. Umbral default **≥75** (configurable por perfil en `match_score_minimo`).
|
|
4
4
|
|
|
5
5
|
## Dimensiones y pesos (default)
|
|
6
6
|
|
|
@@ -13,12 +13,12 @@ Score ponderado 0–100 para decidir dónde invertir esfuerzo. El objetivo no es
|
|
|
13
13
|
| **Empresa/cultura/etapa** | 10 | Encaje con preferencia (startup vs enterprise, producto vs consultora, valores). |
|
|
14
14
|
| **Calidad de la oferta** | 10 | Claridad del JD, recencia (fresca > vieja), pocos aplicantes, proceso definido. |
|
|
15
15
|
|
|
16
|
-
Sumá las dimensiones → score 0
|
|
16
|
+
Sumá las dimensiones → score 0-100.
|
|
17
17
|
|
|
18
18
|
## Ajustes / banderas
|
|
19
19
|
|
|
20
|
-
- **Frase gatillo positiva** (ej. "code-forward", "high autonomy", stack que amás): +2
|
|
21
|
-
- **Red flag en el JD** (ego, "rockstar", scope imposible, "trabajamos como familia"): −5
|
|
20
|
+
- **Frase gatillo positiva** (ej. "code-forward", "high autonomy", stack que amás): +2-5 si refuerza tu fit.
|
|
21
|
+
- **Red flag en el JD** (ego, "rockstar", scope imposible, "trabajamos como familia"): −5-10 y nota de cautela.
|
|
22
22
|
- **Cierre inminente:** no cambia el score, pero sube la **prioridad** de acción.
|
|
23
23
|
- **Elegibilidad bloqueada** (visa que no tenés, zona horaria incompatible obligatoria): cap el score a <50 aunque el resto sea perfecto.
|
|
24
24
|
|
|
@@ -27,17 +27,17 @@ Sumá las dimensiones → score 0–100.
|
|
|
27
27
|
| Score | Acción |
|
|
28
28
|
|---|---|
|
|
29
29
|
| **≥85** | Prioridad alta. Aplicar con CV custom + cover letter + outreach. |
|
|
30
|
-
| **75
|
|
31
|
-
| **60
|
|
30
|
+
| **75-84** | Aplicar; CV custom sí, outreach si hay contacto fácil. |
|
|
31
|
+
| **60-74** | Borderline. Aplicar solo si hay tiempo o un ángulo fuerte (referido). |
|
|
32
32
|
| **<60** | Skip salvo razón estratégica. |
|
|
33
33
|
|
|
34
34
|
## Prioridad (distinta del score)
|
|
35
35
|
|
|
36
36
|
`prioridad` en el frontmatter combina score + urgencia + elegibilidad:
|
|
37
37
|
- **alta:** score ≥85, o ≥75 con cierre inminente / referido caliente.
|
|
38
|
-
- **media:** 75
|
|
38
|
+
- **media:** 75-84 sin urgencia.
|
|
39
39
|
- **baja:** borderline o a la espera de validar requisitos.
|
|
40
40
|
|
|
41
41
|
## Registro
|
|
42
42
|
|
|
43
|
-
Guardá el `score` en el frontmatter de la nota de `03-Aplicaciones/` y reflejá las activas en `00-Dashboard.md` agrupadas por prioridad. Anotá el breakdown si el score fue borderline
|
|
43
|
+
Guardá el `score` en el frontmatter de la nota de `03-Aplicaciones/` y reflejá las activas en `00-Dashboard.md` agrupadas por prioridad. Anotá el breakdown si el score fue borderline - ayuda a decidir y a re-evaluar después.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: job-upskill
|
|
3
|
+
description: "Aggregate skill gaps across all scored job applications, weigh them by lost match points, and build a prioritized study plan with real resources. Triggers: analizar mis gaps, plan de estudio, que me falta aprender, upskill, en que skills invertir, gaps de las postulaciones, heatmap de skills."
|
|
4
|
+
argument-hint: "[url | nota de aplicación]"
|
|
5
|
+
category: "career"
|
|
6
|
+
user-invocable: true
|
|
7
|
+
allowed-tools: Read, Glob, Grep, Write, Bash, WebFetch, WebSearch
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# /job-upskill - Análisis de gaps y plan de estudio
|
|
11
|
+
|
|
12
|
+
Detecta qué skills te están costando puntos de match en las ofertas reales y arma un plan de estudio priorizado. Adaptado del /upskill de MadsLorentzen/ai-job-search: en vez de un CSV, la evidencia sale del frontmatter y las secciones de las notas de `03-Aplicaciones/` del career workspace (rutas relativas al workspace: `--workspace`, env `CAREER_WORKSPACE`, o `./career-workspace/`).
|
|
13
|
+
|
|
14
|
+
Argumento: `$ARGUMENTS` (vacío = modo agregado; URL o nota = modo targeted)
|
|
15
|
+
|
|
16
|
+
## Modo agregado (sin argumento)
|
|
17
|
+
|
|
18
|
+
1. **Recolectar evidencia.** Grep de `match_score:` en `03-Aplicaciones/*.md` (excluir `_index`; aceptar también el campo legacy `score:` de notas viejas). De cada nota leer: `match_score`, `estado`, `perfil`, y los gaps mencionados en las secciones "Match con mi perfil" / "Gaps reales" y los gaps reconocidos en `## Notas`.
|
|
19
|
+
|
|
20
|
+
2. **Canonicalizar.** Unificar sinónimos antes de agregar ("AWS" = "Amazon Web Services"; "K8s" = "Kubernetes"; "GCP" = "Google Cloud"). Un término canónico por gap.
|
|
21
|
+
|
|
22
|
+
3. **Ponderar.** `peso(gap) = Σ (100 - match_score)/100` sobre las notas que lo mencionan (la frecuencia queda implícita en la suma). Boost x1.5 por cada nota con `estado: rechazado` que lo mencione (señal real de mercado). Ofertas con score bajo exponen más gaps y pesan más.
|
|
23
|
+
|
|
24
|
+
4. **Clasificar** cada gap: `[tooling]` (frameworks, infra, CI/CD), `[domain]` (industria, arquitectura), `[soft]` (comunicación, liderazgo, idiomas), `[credential]` (títulos, certificaciones).
|
|
25
|
+
|
|
26
|
+
5. **Heatmap.** Ordenar por peso y cortar en Critical / High / Medium / Low por cuartiles (documentar los umbrales usados en el reporte). Mostrar el heatmap en el chat antes de seguir.
|
|
27
|
+
|
|
28
|
+
6. **Plan de estudio** solo para Critical y High (sumar Medium si hay menos de 5 gaps):
|
|
29
|
+
- WebSearch por gap **incluyendo el año actual** en la query (ej. "best NestJS course 2026").
|
|
30
|
+
- 2-3 recursos reales por gap, priorizando gratuitos/oficiales/hands-on. **Nunca inventar recursos: solo citar lo encontrado en resultados reales.**
|
|
31
|
+
- Horas estimadas a proficiencia útil (rangos realistas, ej. "~20h") considerando la base existente del perfil (no arrancar de cero si ya hay fundamentos).
|
|
32
|
+
- Secuenciar por ROI (peso/horas) y dependencias (prerequisitos primero, quick wins temprano).
|
|
33
|
+
|
|
34
|
+
7. **Reporte** en `07-Recursos/Upskill - YYYY-MM-DD.md`, previo approval del usuario sobre el heatmap:
|
|
35
|
+
- Frontmatter: `tipo: upskill`, `fecha`, `aplicaciones_analizadas` (número), `perfiles` (lista).
|
|
36
|
+
- Heatmap con peso y clasificación por gap.
|
|
37
|
+
- Evidencia: por cada gap Critical/High, wikilinks a las notas de aplicación que lo mencionan.
|
|
38
|
+
- Plan de estudio secuenciado con recursos, horas y dependencias.
|
|
39
|
+
- Si existe un reporte anterior en `07-Recursos/`, sección "Desde el último reporte": gaps cerrados vs nuevos.
|
|
40
|
+
|
|
41
|
+
8. **No edita** notas de aplicaciones, perfiles ni dashboard. Solo crea el reporte.
|
|
42
|
+
|
|
43
|
+
## Modo targeted (`/job-upskill <url|nota>`)
|
|
44
|
+
|
|
45
|
+
1. Obtener el JD: nota existente → leer su "Match con mi perfil"; URL → `detail` del script que corresponda (`.claude/skills/job-scrape/scripts/getonbrd_search.py` o `linkedin_search.py`; si job-scrape no está instalado, WebFetch).
|
|
46
|
+
2. Comparar contra el perfil de `01-Perfiles/` que corresponda: gaps solo de esa oferta, clasificados.
|
|
47
|
+
3. Mini-plan: 1-2 recursos por gap (WebSearch, año actual), horas estimadas.
|
|
48
|
+
4. **Responder en el chat.** Guardar como `07-Recursos/Upskill - YYYY-MM-DD - <Empresa>.md` solo si el usuario lo pide (no llenar Recursos de reportes de una sola oferta).
|
|
49
|
+
|
|
50
|
+
## Reglas
|
|
51
|
+
|
|
52
|
+
- Nunca fabricar recursos ni estadísticas; todo sale de WebSearch real o de las notas.
|
|
53
|
+
- Reporte en el idioma de las notas del workspace, sin guiones largos, con wikilinks a las notas fuente.
|
|
54
|
+
- Los gaps `[credential]` (ej. título universitario) se reportan pero no entran al plan de estudio salvo pedido explícito (son decisiones de otra escala, no items de estudio semanal).
|
|
55
|
+
|
|
56
|
+
## Handoff
|
|
57
|
+
|
|
58
|
+
Reporte COMPLETE en `07-Recursos/`. Retomar el pipeline con `/job-aplicar` en las ofertas abiertas, o `/job-scrape` para una corrida nueva apuntando a los gaps que ya se están cerrando.
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: linkedin-optimize
|
|
3
|
-
description: "Optimize a LinkedIn profile to attract recruiters and rank in search
|
|
3
|
+
description: "Optimize a LinkedIn profile to attract recruiters and rank in search - headline, About, experience with KPIs, keywords, value proposition and a 30-second pitch. No LinkedIn Premium needed. Triggers: optimizar LinkedIn, mejorar perfil LinkedIn, headline LinkedIn, about LinkedIn, LinkedIn para reclutadores, LinkedIn SEO, pitch profesional."
|
|
4
4
|
argument-hint: "[section | full] (paste your current profile)"
|
|
5
5
|
category: "career"
|
|
6
6
|
user-invocable: true
|
|
7
7
|
allowed-tools: Read, Glob, Grep, Write, Edit
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
-
# LinkedIn Optimize
|
|
10
|
+
# LinkedIn Optimize - Perfil que atrae oportunidades
|
|
11
11
|
|
|
12
12
|
Optimizás un perfil de LinkedIn para que (1) ranquee en las búsquedas de recruiters y (2) convenza al humano que lo abre. Trabajás contra el **perfil maestro** (`01-Perfiles/`) y lo que el usuario pega de su perfil actual.
|
|
13
13
|
|
|
@@ -31,8 +31,8 @@ Cubrí estas siete (el usuario puede pedir una con `[section]` o todas con `full
|
|
|
31
31
|
- Las keywords del headline pesan fuerte en el ranking de búsqueda.
|
|
32
32
|
|
|
33
33
|
### 2. About (sección "Acerca de")
|
|
34
|
-
- Primera 2
|
|
35
|
-
- Estructura: gancho → qué hacés y para quién → 2
|
|
34
|
+
- Primera 2-3 líneas son las que se ven sin "ver más": que enganchen.
|
|
35
|
+
- Estructura: gancho → qué hacés y para quién → 2-3 logros con número → stack/keywords → CTA suave.
|
|
36
36
|
- Primera persona, tono humano, escaneable (párrafos cortos o bullets).
|
|
37
37
|
|
|
38
38
|
### 3. Experiencia con KPIs
|
|
@@ -70,7 +70,7 @@ LinkedIn en **inglés** si apuntás a roles internacionales (mayor alcance), esp
|
|
|
70
70
|
|
|
71
71
|
## Reglas
|
|
72
72
|
|
|
73
|
-
- No inventar logros ni métricas
|
|
73
|
+
- No inventar logros ni métricas - todo debe ser defendible.
|
|
74
74
|
- Keywords naturales, no stuffing.
|
|
75
75
|
- Recordar que no se puede acceder/editar LinkedIn: el output es para copiar/pegar.
|
|
76
76
|
|