claude-code-arcane 1.3.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.
Files changed (67) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +5 -4
  3. package/agents/ecommerce/ecommerce-architect.md +60 -0
  4. package/dist/cli.js +2 -0
  5. package/docs/SKILLS-CATALOG.md +25 -2
  6. package/docs/USER-GUIDE.md +2 -2
  7. package/docs/coding-standards.md +26 -0
  8. package/package.json +2 -1
  9. package/profiles/ecommerce.yaml +48 -0
  10. package/profiles/job-hunt.yaml +12 -2
  11. package/rules/drafter-reviewer.md +39 -0
  12. package/skills/cart-checkout/SKILL.md +85 -0
  13. package/skills/cart-checkout/references/checkout-flow-spec.md +50 -0
  14. package/skills/cold-outreach/SKILL.md +10 -10
  15. package/skills/cold-outreach/references/recruiter-playbook.md +5 -5
  16. package/skills/commerce-analytics/SKILL.md +85 -0
  17. package/skills/commerce-analytics/references/ga4-ecommerce-events.md +63 -0
  18. package/skills/commerce-analytics/scripts/ga4_ecommerce_validate.py +134 -0
  19. package/skills/commerce-data-model/SKILL.md +84 -0
  20. package/skills/commerce-data-model/references/data-model-spec.md +79 -0
  21. package/skills/cover-letter/SKILL.md +8 -8
  22. package/skills/cv-ats-export/SKILL.md +19 -6
  23. package/skills/cv-ats-export/scripts/cv_export.py +49 -4
  24. package/skills/cv-ats-export/scripts/verify_pdf.py +90 -0
  25. package/skills/cv-tailor/SKILL.md +7 -7
  26. package/skills/cv-tailor/references/ats-keywords.md +9 -9
  27. package/skills/interview-prep/SKILL.md +7 -7
  28. package/skills/interview-prep/references/star-framework.md +10 -10
  29. package/skills/inventory-stock/SKILL.md +86 -0
  30. package/skills/inventory-stock/scripts/restock_forecast.py +173 -0
  31. package/skills/job-aplicar/SKILL.md +88 -0
  32. package/skills/job-hunt/SKILL.md +20 -13
  33. package/skills/job-hunt/references/templates/Aplicacion.md +6 -2
  34. package/skills/job-hunt/references/templates/CV Custom.md +1 -1
  35. package/skills/job-hunt/references/templates/Contacto.md +1 -1
  36. package/skills/job-hunt/references/templates/Dashboard.md +19 -15
  37. package/skills/job-hunt/references/templates/Perfil.md +2 -2
  38. package/skills/job-outcome/SKILL.md +55 -0
  39. package/skills/job-scrape/SKILL.md +95 -0
  40. package/skills/job-scrape/references/dedup-playbook.md +25 -0
  41. package/skills/job-scrape/references/getonbrd-cli.md +62 -0
  42. package/skills/job-scrape/references/getonbrd-urls.md +37 -0
  43. package/skills/job-scrape/references/linkedin-cli.md +64 -0
  44. package/skills/job-scrape/references/linkedin-urls.md +42 -0
  45. package/skills/job-scrape/references/seen_jobs.schema.json +68 -0
  46. package/skills/job-scrape/scripts/getonbrd_search.py +501 -0
  47. package/skills/job-scrape/scripts/linkedin_search.py +428 -0
  48. package/skills/job-search/SKILL.md +22 -19
  49. package/skills/job-search/references/scoring-rubric.md +8 -8
  50. package/skills/job-upskill/SKILL.md +58 -0
  51. package/skills/linkedin-optimize/SKILL.md +5 -5
  52. package/skills/master-profile/SKILL.md +17 -17
  53. package/skills/merchant-feed/SKILL.md +85 -0
  54. package/skills/merchant-feed/references/feed-attribute-map.md +47 -0
  55. package/skills/merchant-feed/scripts/feed_validate.py +140 -0
  56. package/skills/network-map/SKILL.md +7 -7
  57. package/skills/order-lifecycle/SKILL.md +86 -0
  58. package/skills/order-lifecycle/references/order-state-machine.md +53 -0
  59. package/skills/payments-architecture/SKILL.md +80 -0
  60. package/skills/personal-brand/SKILL.md +10 -10
  61. package/skills/personal-brand/references/post-pillars.md +14 -14
  62. package/skills/portfolio-site/SKILL.md +11 -11
  63. package/skills/product-listing/SKILL.md +84 -0
  64. package/skills/promotions-discounts/SKILL.md +80 -0
  65. package/skills/shipping-tax/SKILL.md +85 -0
  66. package/skills/storefront-architecture/SKILL.md +80 -0
  67. package/skills/subscription-billing/SKILL.md +81 -0
@@ -0,0 +1,68 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "title": "seen_jobs",
4
+ "description": "Registro de ofertas vistas por /job-scrape para deduplicacion entre corridas. Fuente de verdad SOLO del triage pre-nota; el estado del pipeline vive en el frontmatter de las notas de 03-Aplicaciones/ del career workspace y el dashboard es una vista derivada.",
5
+ "type": "object",
6
+ "required": ["schema_version", "actualizado", "jobs"],
7
+ "properties": {
8
+ "$schema": { "type": "string" },
9
+ "schema_version": { "type": "integer", "const": 1 },
10
+ "actualizado": { "type": "string", "format": "date", "description": "Fecha ISO de la ultima escritura" },
11
+ "jobs": {
12
+ "type": "object",
13
+ "description": "Mapa job_key -> entrada. job_key = slug normalizado de 'empresa|titulo' (lowercase, sin acentos, sin puntuacion, sin sufijos legales de empresa, sin calificadores '(remote)'/'(latam)' del titulo).",
14
+ "additionalProperties": {
15
+ "type": "object",
16
+ "required": ["title", "company", "sources", "first_seen", "last_seen", "status"],
17
+ "properties": {
18
+ "title": { "type": "string", "description": "Titulo como se vio la primera vez" },
19
+ "company": { "type": "string" },
20
+ "sources": {
21
+ "type": "array",
22
+ "minItems": 1,
23
+ "items": {
24
+ "type": "object",
25
+ "required": ["source", "url", "first_seen", "last_seen"],
26
+ "properties": {
27
+ "source": { "enum": ["linkedin", "getonbrd", "manual", "otro"] },
28
+ "source_id": { "type": ["string", "null"], "description": "ID nativo de la fuente (numerico LinkedIn, slug GetOnBoard)" },
29
+ "url": { "type": "string", "description": "URL canonica (sin utm/tracking; linkedin.com/jobs/view/<id>; getonbrd /empleos/ -> /jobs/)" },
30
+ "publicada": { "type": ["string", "null"], "format": "date" },
31
+ "first_seen": { "type": "string", "format": "date" },
32
+ "last_seen": { "type": "string", "format": "date" }
33
+ }
34
+ }
35
+ },
36
+ "first_seen": { "type": "string", "format": "date", "description": "Primera vez vista en cualquier fuente" },
37
+ "last_seen": { "type": "string", "format": "date" },
38
+ "perfil": { "type": ["string", "null"], "description": "Nota de perfil contra la que se evaluo (nombre de la nota de 01-Perfiles/)" },
39
+ "quick_score": { "enum": ["high", "medium", "low", null], "description": "Triage barato sobre datos del listing" },
40
+ "match_score": { "type": ["integer", "null"], "minimum": 0, "maximum": 100, "description": "Score de la rubrica completa de job-search, si se corrio" },
41
+ "status": {
42
+ "enum": ["new", "descartado", "nota_creada"],
43
+ "description": "Estados del triage. Deliberadamente NO existe 'aplicado': al crear la nota la entrada se congela y el pipeline sigue en el frontmatter de la nota (lo mueve /job-outcome)."
44
+ },
45
+ "descarte_motivo": { "type": ["string", "null"], "description": "Obligatorio si status=descartado (ver allOf)" },
46
+ "nota": { "type": ["string", "null"], "description": "Nombre de archivo de la nota en 03-Aplicaciones/ (obligatorio si status=nota_creada, ver allOf)" },
47
+ "salario_max_usd_mes": { "type": ["integer", "null"], "description": "Salario maximo publicado en USD/mes, para re-triage sin re-fetch" }
48
+ },
49
+ "allOf": [
50
+ {
51
+ "if": { "properties": { "status": { "const": "descartado" } }, "required": ["status"] },
52
+ "then": {
53
+ "required": ["descarte_motivo"],
54
+ "properties": { "descarte_motivo": { "type": "string", "minLength": 1 } }
55
+ }
56
+ },
57
+ {
58
+ "if": { "properties": { "status": { "const": "nota_creada" } }, "required": ["status"] },
59
+ "then": {
60
+ "required": ["nota"],
61
+ "properties": { "nota": { "type": "string", "minLength": 1 } }
62
+ }
63
+ }
64
+ ]
65
+ }
66
+ }
67
+ }
68
+ }
@@ -0,0 +1,501 @@
1
+ """
2
+ getonbrd_search.py - Search jobs on GetOnBoard's public API v0 (LATAM tech job board).
3
+
4
+ No authentication, no API key, no dependencies beyond the Python 3 standard
5
+ library. Mirrors the contract of linkedin_search.py so /job-scrape can use both
6
+ interchangeably.
7
+
8
+ Usage:
9
+ python getonbrd_search.py search -q "<keywords>" [flags]
10
+ python getonbrd_search.py detail <slug|url> [--format json|plain]
11
+
12
+ Search returns JSON:API records that already include the full job description,
13
+ so there is no per-job API call (the individual job endpoint is private/401).
14
+ `detail` finds the record through search and falls back to the SSR page's
15
+ schema.org JobPosting microdata for old or unindexed postings.
16
+
17
+ Full JDs are ~80% of a json payload: --brief truncates them for triage.
18
+ Public API is in beta and may change. Low volume, personal use.
19
+
20
+ Errors go to stderr as {"error": "...", "code": "..."} with exit code 1.
21
+ """
22
+
23
+ import argparse
24
+ import calendar
25
+ import html
26
+ import json
27
+ import random
28
+ import re
29
+ import sys
30
+ import time
31
+ import urllib.error
32
+ import urllib.parse
33
+ import urllib.request
34
+
35
+ API_BASE = "https://www.getonbrd.com/api/v0"
36
+ SEARCH_URL = f"{API_BASE}/search/jobs"
37
+ PUBLIC_JOB_URL = "https://www.getonbrd.com/jobs"
38
+
39
+ UA = (
40
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
41
+ "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
42
+ )
43
+
44
+ MAX_RETRIES = 5
45
+ JOBAGE_ALL = 9999
46
+ PER_PAGE = 20
47
+
48
+ # Chars of description kept per result under --brief.
49
+ BRIEF_DESCRIPTION_CHARS = 300
50
+
51
+ STOPWORDS = {
52
+ "y", "de", "e", "la", "el", "los", "las", "en", "para", "con", "a",
53
+ "and", "the", "of", "at", "in", "remote", "hybrid",
54
+ }
55
+
56
+
57
+ def write_error(error, code):
58
+ sys.stderr.write(json.dumps({"error": error, "code": code}) + "\n")
59
+
60
+
61
+ def _fetch(url, accept):
62
+ """Fetch with exponential backoff on 429/5xx. Returns None on a 404."""
63
+ delay = 0.5
64
+ for attempt in range(MAX_RETRIES + 1):
65
+ request = urllib.request.Request(url, headers={"User-Agent": UA, "Accept": accept})
66
+ try:
67
+ with urllib.request.urlopen(request, timeout=30) as response:
68
+ return response.read().decode("utf-8", errors="replace")
69
+ except urllib.error.HTTPError as e:
70
+ if e.code == 404:
71
+ return None
72
+ retryable = e.code == 429 or e.code >= 500
73
+ if not retryable or attempt == MAX_RETRIES:
74
+ raise RuntimeError(f"Request failed: {e.code} {e.reason}") from None
75
+ time.sleep(delay + random.random() * 0.5)
76
+ delay = min(delay * 2, 8)
77
+ raise RuntimeError("Request failed after max retries")
78
+
79
+
80
+ def json_fetch(url):
81
+ body = _fetch(url, "application/json")
82
+ return None if body is None else json.loads(body)
83
+
84
+
85
+ def html_fetch(url):
86
+ return _fetch(url, "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") or ""
87
+
88
+
89
+ BR_RE = re.compile(r"<\s*br\s*/?>", re.I)
90
+ BLOCK_END_RE = re.compile(r"</(p|li|ul|ol|div|h\d)>", re.I)
91
+ LI_OPEN_RE = re.compile(r"<li[^>]*>", re.I)
92
+ TAG_RE = re.compile(r"<[^>]+>")
93
+
94
+
95
+ def html_to_text(markup):
96
+ """HTML -> plain text, preserving paragraph/list breaks as newlines."""
97
+ if not markup:
98
+ return None
99
+ with_breaks = LI_OPEN_RE.sub("- ", BLOCK_END_RE.sub("\n", BR_RE.sub("\n", markup)))
100
+ text = html.unescape(TAG_RE.sub("", with_breaks)).replace("\xa0", " ")
101
+ text = re.sub(r"[ \t]+", " ", text)
102
+ text = re.sub(r" ?\n ?", "\n", text)
103
+ text = re.sub(r"\n{3,}", "\n\n", text).strip()
104
+ return text or None
105
+
106
+
107
+ def unix_to_iso_date(unix):
108
+ if not unix:
109
+ return None
110
+ return time.strftime("%Y-%m-%d", time.gmtime(unix))
111
+
112
+
113
+ def normalize_job(record):
114
+ """Normalize one JSON:API job record from /search/jobs into a flat dict."""
115
+ attrs = record.get("attributes") or {}
116
+ seniority = ((attrs.get("seniority") or {}).get("data") or {}).get("attributes", {}).get("name")
117
+ company = ((attrs.get("company") or {}).get("data") or {}).get("attributes", {}).get("name")
118
+ countries = attrs.get("countries") if isinstance(attrs.get("countries"), list) else []
119
+ modality = attrs.get("remote_modality")
120
+
121
+ location_parts = list(countries)
122
+ if modality and "Remote" not in countries:
123
+ location_parts.append(f"({modality})")
124
+
125
+ description_parts = [
126
+ attrs.get("description") or "",
127
+ f"\nFunciones:\n{attrs['functions']}" if attrs.get("functions") else "",
128
+ f"\nDeseable:\n{attrs['desirable']}" if attrs.get("desirable") else "",
129
+ ]
130
+
131
+ return {
132
+ "id": str(record.get("id")),
133
+ "title": attrs.get("title") or "(untitled)",
134
+ "company": company,
135
+ "location": ", ".join(location_parts) if location_parts else None,
136
+ "date": unix_to_iso_date(attrs.get("published_at")),
137
+ "url": (record.get("links") or {}).get("public_url")
138
+ or f"{PUBLIC_JOB_URL}/{record.get('id')}",
139
+ "seniority": seniority,
140
+ "remote": attrs["remote"] if isinstance(attrs.get("remote"), bool) else None,
141
+ "remote_modality": modality,
142
+ "remote_zone": attrs.get("remote_zone"),
143
+ "countries": countries,
144
+ "salary_min_usd_month": attrs.get("min_salary"),
145
+ "salary_max_usd_month": attrs.get("max_salary"),
146
+ "applications_count": attrs.get("applications_count"),
147
+ "description": html_to_text("\n".join(p for p in description_parts if p)),
148
+ }
149
+
150
+
151
+ def slug_from_input(value):
152
+ """Extract the job slug from a GetOnBoard URL (jobs/empleos, with or without category)."""
153
+ match = re.search(
154
+ r"getonbrd\.[a-z.]+/(?:jobs|empleos)/(?:[^/]+/)?([a-z0-9-]+)", value, re.I
155
+ )
156
+ if match:
157
+ return match.group(1).lower()
158
+ if re.fullmatch(r"[a-z0-9-]+", value, re.I):
159
+ return value.lower()
160
+ return None
161
+
162
+
163
+ def slug_tokens(slug):
164
+ """Slug -> tokens, dropping the trailing short hash if present."""
165
+ parts = slug.split("-")
166
+ if len(parts) > 1 and re.fullmatch(r"[0-9a-f]{3,5}", parts[-1]):
167
+ parts.pop()
168
+ return parts
169
+
170
+
171
+ def brief_job(job):
172
+ """
173
+ Same shape as a normalized job with the description truncated, so a consumer
174
+ can still keyword-match on the opening lines and knows when text was dropped.
175
+ """
176
+ full = job.get("description") or ""
177
+ truncated = len(full) > BRIEF_DESCRIPTION_CHARS
178
+ return {
179
+ **job,
180
+ "description": full[:BRIEF_DESCRIPTION_CHARS].rstrip() + "..." if truncated
181
+ else job.get("description"),
182
+ "description_truncated": truncated,
183
+ }
184
+
185
+
186
+ def matches_remote(job, mode):
187
+ mode = (mode or "").lower()
188
+ if mode == "remote":
189
+ return job["remote"] is True
190
+ if mode == "hybrid":
191
+ return job["remote_modality"] == "hybrid"
192
+ if mode in ("onsite", "on-site"):
193
+ return job["remote_modality"] == "no_remote"
194
+ return True
195
+
196
+
197
+ def within_jobage(job, days):
198
+ if not days or days <= 0 or days >= JOBAGE_ALL or not job["date"]:
199
+ return True
200
+ # timegm, not mktime: published_at is UTC, and mktime would read the date in
201
+ # local time - off by an hour in any DST zone, which drops boundary-day jobs.
202
+ published = calendar.timegm(time.strptime(job["date"], "%Y-%m-%d"))
203
+ return (time.time() - published) <= days * 86400
204
+
205
+
206
+ def salary_col(job):
207
+ if job["salary_min_usd_month"] is None and job["salary_max_usd_month"] is None:
208
+ return "-"
209
+ low = job["salary_min_usd_month"] if job["salary_min_usd_month"] is not None else "?"
210
+ high = job["salary_max_usd_month"] if job["salary_max_usd_month"] is not None else "?"
211
+ return f"{low}-{high}"
212
+
213
+
214
+ def build_search_url(args):
215
+ params = [
216
+ ("query", args.query),
217
+ ("per_page", str(PER_PAGE)),
218
+ ("page", str(args.page)),
219
+ ("expand[]", "seniority"),
220
+ ("expand[]", "company"),
221
+ ]
222
+ return f"{SEARCH_URL}?{urllib.parse.urlencode(params)}"
223
+
224
+
225
+ def render_table(jobs):
226
+ if not jobs:
227
+ return "No results."
228
+ header = (
229
+ "TITLE".ljust(36) + " " + "COMPANY".ljust(20) + " " + "USD/MO".ljust(10) + " "
230
+ + "SENIORITY".ljust(11) + " " + "DATE".ljust(10) + " ID"
231
+ )
232
+ rows = [
233
+ f"{(j['title'] or '')[:36]:<36} {(j['company'] or '-')[:20]:<20} {salary_col(j):<10} "
234
+ f"{(j['seniority'] or '-')[:11]:<11} {(j['date'] or '-'):<10} {j['id']}"
235
+ for j in jobs
236
+ ]
237
+ return "\n".join([header, "-" * len(header), *rows])
238
+
239
+
240
+ def render_plain(jobs):
241
+ return "\n\n".join(
242
+ f"{j['title']}\n {j['company'] or '-'} · {j['location'] or '-'} · {salary_col(j)} USD/mes"
243
+ f" · {j['seniority'] or '-'} · {j['date'] or '-'}\n id: {j['id']}\n {j['url']}"
244
+ for j in jobs
245
+ )
246
+
247
+
248
+ def run_search(args):
249
+ try:
250
+ data = json_fetch(build_search_url(args))
251
+ if data is None or not isinstance(data.get("data"), list):
252
+ write_error("Unexpected API response (no data array)", "BAD_RESPONSE")
253
+ return 1
254
+
255
+ jobs = [normalize_job(r) for r in data["data"]]
256
+ jobs = [j for j in jobs if within_jobage(j, args.jobage) and matches_remote(j, args.remote)]
257
+ if args.limit is not None and args.limit >= 0:
258
+ jobs = jobs[: args.limit]
259
+
260
+ if args.format == "table":
261
+ sys.stdout.write(render_table(jobs) + "\n")
262
+ elif args.format == "plain":
263
+ sys.stdout.write(render_plain(jobs) + "\n")
264
+ else:
265
+ # Full JDs are ~80% of this payload, and a triage pass only needs the
266
+ # structured fields; --brief truncates them so a multi-query run stays
267
+ # cheap to read. Fetch `detail <slug>` for the shortlist's full text.
268
+ payload = {
269
+ "meta": {
270
+ "count": len(jobs),
271
+ "page": args.page,
272
+ "total_pages": (data.get("meta") or {}).get("total_pages"),
273
+ "brief": bool(args.brief),
274
+ },
275
+ "results": [brief_job(j) for j in jobs] if args.brief else jobs,
276
+ }
277
+ sys.stdout.write(json.dumps(payload, indent=2, ensure_ascii=False) + "\n")
278
+ return 0
279
+ except Exception as e:
280
+ write_error(str(e), "SEARCH_FAILED")
281
+ return 1
282
+
283
+
284
+ def build_queries(tokens):
285
+ """
286
+ The search API ANDs every term, so the full slug usually over-constrains.
287
+ Try progressively more selective queries; the slug tail (stack + company)
288
+ is the most distinctive part.
289
+ """
290
+ filtered = [t for t in tokens if t not in STOPWORDS]
291
+ queries = [" ".join(tokens)]
292
+ if len(filtered) != len(tokens):
293
+ queries.append(" ".join(filtered))
294
+ if len(filtered) > 4:
295
+ queries.append(" ".join(filtered[-4:]))
296
+ if len(filtered) > 3:
297
+ queries.append(" ".join(filtered[-3:]))
298
+ return [q for q in dict.fromkeys(queries) if len(q) >= 3]
299
+
300
+
301
+ def detail_via_search(slug):
302
+ """Layer 1: find the record through the search endpoint (full data, no auth)."""
303
+ for query in build_queries(slug_tokens(slug)):
304
+ params = [
305
+ ("query", query),
306
+ ("per_page", str(PER_PAGE)),
307
+ ("expand[]", "seniority"),
308
+ ("expand[]", "company"),
309
+ ]
310
+ data = json_fetch(f"{SEARCH_URL}?{urllib.parse.urlencode(params)}")
311
+ if data is None or not isinstance(data.get("data"), list):
312
+ continue
313
+ for record in data["data"]:
314
+ if str(record.get("id")).lower() == slug:
315
+ return normalize_job(record)
316
+ return None
317
+
318
+
319
+ def micro_prop(markup, prop, start=0):
320
+ """First text content of an itemprop, searching from a start offset."""
321
+ match = re.search(rf'itemprop="{prop}"[^>]*>\s*([^<]+)', markup[start:], re.I)
322
+ return match.group(1).strip() if match else None
323
+
324
+
325
+ def micro_number(markup, prop):
326
+ raw = micro_prop(markup, prop)
327
+ if not raw:
328
+ return None
329
+ digits = re.sub(r"[^\d]", "", raw)
330
+ return int(digits) if digits else None
331
+
332
+
333
+ def detail_via_microdata(slug):
334
+ """
335
+ Layer 2 fallback: the SSR job page carries schema.org JobPosting microdata
336
+ (itemprop attributes), which is more stable than the page markup itself.
337
+ """
338
+ markup = html_fetch(f"{PUBLIC_JOB_URL}/{slug}")
339
+ if not markup:
340
+ return None
341
+
342
+ # Closed jobs drop the JobPosting itemtype but keep the itemprops,
343
+ # so key off itemprop="title" instead of the itemtype wrapper.
344
+ title = micro_prop(markup, "title")
345
+ if not title:
346
+ return None
347
+
348
+ org_match = re.search(r'itemprop="hiringOrganization"', markup, re.I)
349
+ company = micro_prop(markup, "name", org_match.start()) if org_match else None
350
+
351
+ description = None
352
+ desc_match = re.search(r'itemprop="description"', markup, re.I)
353
+ if desc_match:
354
+ # Slice from after the closing ">" of the tag that carries the itemprop,
355
+ # so the attribute text itself never leaks into the extracted description.
356
+ start = markup.index(">", desc_match.start()) + 1
357
+ end = len(markup)
358
+ for marker in (r'itemprop="skills"', r'id="job-apply', r"</main", r"</body"):
359
+ found = re.search(marker, markup[start:], re.I)
360
+ if found and found.start() > 0:
361
+ end = min(end, start + found.start())
362
+ description = html_to_text(markup[start:end])
363
+
364
+ date_posted = micro_prop(markup, "datePosted")
365
+
366
+ return {
367
+ "closed": bool(re.search(r"Closed job|No longer accepting", markup, re.I)),
368
+ "id": slug,
369
+ "title": title,
370
+ "company": company,
371
+ "location": micro_prop(markup, "address"),
372
+ "date": date_posted[:10] if date_posted else None,
373
+ "url": f"{PUBLIC_JOB_URL}/{slug}",
374
+ "seniority": micro_prop(markup, "qualifications"),
375
+ "remote": None,
376
+ "remote_modality": None,
377
+ "remote_zone": None,
378
+ "countries": [],
379
+ "salary_min_usd_month": micro_number(markup, "minValue"),
380
+ "salary_max_usd_month": micro_number(markup, "maxValue"),
381
+ "applications_count": None,
382
+ "description": description,
383
+ }
384
+
385
+
386
+ def run_detail(args):
387
+ slug = slug_from_input(args.id)
388
+ if not slug:
389
+ write_error(f'Could not parse a job slug from "{args.id}"', "BAD_ID")
390
+ return 1
391
+ try:
392
+ job = detail_via_search(slug)
393
+ via = "search-api"
394
+ if not job:
395
+ job = detail_via_microdata(slug)
396
+ via = "microdata"
397
+ if not job:
398
+ write_error(
399
+ "Job not found (search API and microdata fallback both missed)", "NOT_FOUND"
400
+ )
401
+ return 1
402
+
403
+ if args.format == "plain":
404
+ salary = (
405
+ f"{salary_col(job)} USD/mes"
406
+ if job["salary_min_usd_month"] is not None or job["salary_max_usd_month"] is not None
407
+ else None
408
+ )
409
+ lines = [
410
+ job["title"],
411
+ f"{job['company'] or '-'} · {job['location'] or '-'}",
412
+ "⚠ CERRADA - ya no acepta postulaciones" if job.get("closed") else "",
413
+ "",
414
+ f"Seniority: {job['seniority']}" if job["seniority"] else "",
415
+ f"Salario: {salary}" if salary else "",
416
+ f"Aplicantes: {job['applications_count']}"
417
+ if job["applications_count"] is not None else "",
418
+ f"Publicada: {job['date']}" if job["date"] else "",
419
+ "",
420
+ job["description"] or "(no description)",
421
+ "",
422
+ f"URL: {job['url']}",
423
+ ]
424
+ sys.stdout.write("\n".join(line for line in lines if line != "") + "\n")
425
+ else:
426
+ sys.stdout.write(json.dumps({"via": via, **job}, indent=2, ensure_ascii=False) + "\n")
427
+ return 0
428
+ except Exception as e:
429
+ write_error(str(e), "DETAIL_FAILED")
430
+ return 1
431
+
432
+
433
+ class JsonErrorParser(argparse.ArgumentParser):
434
+ """Keep the CLI's error contract: a JSON line on stderr and exit code 1."""
435
+
436
+ def error(self, message):
437
+ code = "NO_QUERY" if "query" in message and "required" in message else "BAD_ARG"
438
+ write_error(message, code)
439
+ raise SystemExit(1)
440
+
441
+
442
+ def build_parser():
443
+ parser = JsonErrorParser(
444
+ prog="getonbrd_search.py",
445
+ description="Search jobs on GetOnBoard (LATAM tech job board). No --location flag: "
446
+ "GetOnBoard is LATAM/remote by nature; filter with --remote and read the countries "
447
+ "field. Salary fields are USD/month when published.",
448
+ )
449
+ sub = parser.add_subparsers(dest="command", required=True)
450
+
451
+ search = sub.add_parser("search", help="search job postings")
452
+ search.add_argument("-q", "--query", required=True,
453
+ help="REQUIRED. Keywords; the API ANDs every term")
454
+ search.add_argument("--jobage", type=int, default=JOBAGE_ALL,
455
+ help="posted within N days (client-side filter). Default: all")
456
+ search.add_argument("--remote", choices=["remote", "hybrid", "onsite", "on-site"],
457
+ help="workplace type (client-side filter)")
458
+ search.add_argument("--page", type=int, default=1, help="1-indexed page (20 results/page)")
459
+ search.add_argument("-n", "--limit", type=int,
460
+ help="cap results emitted (client-side; 0 emits none)")
461
+ search.add_argument("--brief", action="store_true",
462
+ help="json only: truncate each description to 300 chars")
463
+ search.add_argument("--format", choices=["json", "table", "plain"], default="json")
464
+ search.set_defaults(func=run_search)
465
+
466
+ detail = sub.add_parser("detail", help="full detail of one posting")
467
+ detail.add_argument("id", help="slug from search results, or any getonbrd.com job URL")
468
+ detail.add_argument("--format", choices=["json", "plain"], default="json")
469
+ detail.set_defaults(func=run_detail)
470
+
471
+ return parser
472
+
473
+
474
+ def force_utf8_streams():
475
+ """
476
+ On Windows stdout defaults to cp1252, so a bullet or an accent in a JD raises
477
+ UnicodeEncodeError the moment output is piped to a file. Node writes UTF-8
478
+ unconditionally; Python has to be told.
479
+ """
480
+ for stream in (sys.stdout, sys.stderr):
481
+ if hasattr(stream, "reconfigure"):
482
+ stream.reconfigure(encoding="utf-8")
483
+
484
+
485
+ def main():
486
+ force_utf8_streams()
487
+ args = build_parser().parse_args()
488
+ if args.command == "search":
489
+ if len(args.query) < 3:
490
+ write_error(
491
+ 'the --query/-q flag must be at least 3 chars (e.g. -q "fullstack developer")',
492
+ "NO_QUERY",
493
+ )
494
+ sys.exit(1)
495
+ if args.page < 1:
496
+ args.page = 1
497
+ sys.exit(args.func(args))
498
+
499
+
500
+ if __name__ == "__main__":
501
+ main()