claude-code-arcane 2.1.0 → 2.3.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 (89) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +57 -55
  3. package/agents/audio/audio-director.md +90 -0
  4. package/agents/audio/audio-programmer.md +97 -0
  5. package/agents/audio/audio-qa.md +95 -0
  6. package/agents/audio/composer.md +88 -0
  7. package/agents/audio/technical-sound-designer.md +90 -0
  8. package/agents/audio/voice-director.md +96 -0
  9. package/agents/career/career-strategist.md +88 -0
  10. package/agents/career/cv-reviewer.md +81 -0
  11. package/agents/career/hiring-manager.md +68 -0
  12. package/agents/career/mock-interviewer.md +76 -0
  13. package/agents/freelance/client-screener.md +84 -0
  14. package/agents/freelance/discovery-call.md +65 -0
  15. package/agents/freelance/pipeline-strategist.md +99 -0
  16. package/agents/freelance/proposal-reviewer.md +82 -0
  17. package/dist/cli.js +52 -13
  18. package/docs/RELEASE-SETUP.md +1 -1
  19. package/docs/SKILLS-CATALOG.md +47 -3
  20. package/docs/USER-GUIDE.md +77 -72
  21. package/docs/agent-hierarchy.md +21 -1
  22. package/docs/presentations/build_arcane_deck.py +2 -2
  23. package/docs/presentations/build_arcane_deck_12.py +2 -2
  24. package/package.json +2 -2
  25. package/profiles/audio.yaml +45 -0
  26. package/profiles/freelance.yaml +43 -0
  27. package/profiles/job-hunt.yaml +5 -2
  28. package/rules/drafter-reviewer.md +16 -3
  29. package/rules/freelance-guardrails.md +53 -0
  30. package/rules/gamedev/audio-code.md +45 -0
  31. package/skills/adaptive-music/SKILL.md +171 -0
  32. package/skills/adaptive-music/references/anti-patterns.md +53 -0
  33. package/skills/audio-audit/SKILL.md +164 -0
  34. package/skills/audio-audit/references/anti-patterns.md +65 -0
  35. package/skills/audio-audit/scripts/check_assets.py +208 -0
  36. package/skills/audio-bible/SKILL.md +119 -0
  37. package/skills/audio-bible/references/anti-patterns.md +39 -0
  38. package/skills/audio-mix/SKILL.md +151 -0
  39. package/skills/audio-mix/references/anti-patterns.md +65 -0
  40. package/skills/audio-spec/SKILL.md +107 -0
  41. package/skills/audio-spec/references/anti-patterns.md +45 -0
  42. package/skills/career-registry/SKILL.md +95 -0
  43. package/skills/career-registry/scripts/career_registry.py +488 -0
  44. package/skills/client-screen/SKILL.md +83 -0
  45. package/skills/client-screen/references/red-flags.md +95 -0
  46. package/skills/cover-letter/SKILL.md +4 -3
  47. package/skills/cv-tailor/SKILL.md +11 -2
  48. package/skills/freelance-deliver/SKILL.md +90 -0
  49. package/skills/freelance-hunt/SKILL.md +126 -0
  50. package/skills/freelance-hunt/references/templates/Connects.md +39 -0
  51. package/skills/freelance-hunt/references/templates/Contrato.md +60 -0
  52. package/skills/freelance-hunt/references/templates/Propuesta.md +66 -0
  53. package/skills/freelance-kickoff/SKILL.md +94 -0
  54. package/skills/freelance-pipeline/SKILL.md +109 -0
  55. package/skills/freelance-pipeline/scripts/import_history.py +425 -0
  56. package/skills/freelance-profile/SKILL.md +93 -0
  57. package/skills/freelance-proposal/SKILL.md +112 -0
  58. package/skills/freelance-scan/SKILL.md +196 -0
  59. package/skills/freelance-scan/references/dedup.md +35 -0
  60. package/skills/freelance-scan/references/platforms.md +167 -0
  61. package/skills/freelance-scan/scripts/freelance_search.py +1077 -0
  62. package/skills/interview-prep/SKILL.md +11 -3
  63. package/skills/job-aplicar/SKILL.md +5 -1
  64. package/skills/job-hunt/SKILL.md +17 -0
  65. package/skills/job-outcome/SKILL.md +15 -1
  66. package/skills/job-upskill/SKILL.md +10 -2
  67. package/skills/middleware-integration/SKILL.md +186 -0
  68. package/skills/middleware-integration/references/anti-patterns.md +69 -0
  69. package/skills/midi-compose/SKILL.md +136 -0
  70. package/skills/midi-compose/references/smf-format.md +129 -0
  71. package/skills/midi-compose/scripts/compose_midi.py +326 -0
  72. package/skills/midi-compose/templates/example-composition.json +69 -0
  73. package/skills/music-composition/SKILL.md +106 -0
  74. package/skills/music-composition/references/form.md +57 -0
  75. package/skills/music-composition/references/game-music.md +82 -0
  76. package/skills/music-composition/references/harmony.md +82 -0
  77. package/skills/music-composition/references/melody.md +48 -0
  78. package/skills/music-composition/references/orchestration.md +69 -0
  79. package/skills/music-composition/references/rhythm-groove.md +49 -0
  80. package/skills/procedural-audio/SKILL.md +157 -0
  81. package/skills/procedural-audio/references/anti-patterns.md +45 -0
  82. package/skills/sfx-design/SKILL.md +130 -0
  83. package/skills/sfx-design/references/anti-patterns.md +53 -0
  84. package/skills/spatial-audio/SKILL.md +139 -0
  85. package/skills/spatial-audio/references/anti-patterns.md +53 -0
  86. package/skills/ui-sound-design/SKILL.md +133 -0
  87. package/skills/ui-sound-design/references/anti-patterns.md +57 -0
  88. package/skills/voice-pipeline/SKILL.md +150 -0
  89. package/skills/voice-pipeline/references/anti-patterns.md +69 -0
@@ -0,0 +1,1077 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Busca proyectos freelance en fuentes publicas y sin API key.
4
+
5
+ Toda fuente incluida cumple tres condiciones, porque este repo es publico y lo
6
+ instala cualquiera: no pide key ni registro, sus terminos permiten consumirla
7
+ desde una herramienta local, y expone trabajo freelance/contract de verdad.
8
+
9
+ Dos fuentes que parecen candidatas quedaron afuera a proposito (RemoteOK y
10
+ Remotive): sus terminos exigen un backlink do-follow desde un sitio web y
11
+ amenazan con suspender el acceso si falta. Un CLI local no tiene sitio web, asi
12
+ que no podemos cumplirlo, y usarlas igual haria que le suspendan la IP al
13
+ usuario. Detalle en ../references/platforms.md.
14
+
15
+ Ojo con los filtros: ni GetOnBrd ni Himalayas filtran por modalidad del lado del
16
+ servidor. Aceptan el parametro, devuelven 200, y te mandan full-time igual (se
17
+ probaron 7 variantes de nombre en GetOnBrd y 4 en Himalayas). Por eso el filtro
18
+ de freelance es del lado del cliente y hay que paginar para juntar volumen.
19
+
20
+ El hilo mensual "Ask HN: Freelancer? Seeking freelancer?" no esta en `search` a
21
+ proposito. Se midieron 6 meses con el propio subcomando `market`: 113
22
+ freelancers ofreciendose contra 2 ofertas de trabajo. Como fuente de proyectos no sirve; como fuente de que cobran
23
+ y como se posicionan otros, es la mejor que hay sin pagar. Por eso vive en el
24
+ subcomando `market`, que es lo que realmente es.
25
+
26
+ Buscar por categoria escondia trabajo real: "ecommerce" devolvia 0 mientras
27
+ habia un "Desarrollador Web Shopify" en el pool, porque nadie titula una oferta
28
+ "ecommerce". Por eso `search` expande sinonimos (ver SYNONYMS) y consulta el
29
+ server una vez por termino: el ranking del server depende de `query`, asi que
30
+ buscar "ecommerce" nunca trae el pool donde vive el de Shopify. Con --no-expand
31
+ se apaga.
32
+
33
+ Uso:
34
+ python freelance_search.py sources
35
+ python freelance_search.py search --query "unity developer" --source all
36
+ python freelance_search.py search --query python --source getonbrd --pages 3
37
+ python freelance_search.py market --months 3
38
+ python freelance_search.py detail --source getonbrd --id <id>
39
+
40
+ Salida: JSON a stdout. Errores: JSON con "error" y "code" a stdout, exit 1.
41
+ Solo stdlib. Compatible con Python 3.9.
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ import argparse
47
+ import collections
48
+ import html
49
+ import json
50
+ import os
51
+ import re
52
+ import sys
53
+ import time
54
+ import urllib.error
55
+ import urllib.parse
56
+ import urllib.request
57
+
58
+ UA = "Mozilla/5.0 (compatible; claude-code-arcane/freelance-scan; +https://github.com/SebastianLuser/claude-code-arcane)"
59
+ TIMEOUT = 30
60
+ MAX_RETRIES = 3
61
+ RETRY_SLEEP = 2
62
+
63
+ GETONBRD_API = "https://www.getonbrd.com/api/v0"
64
+ GETONBRD_JOBS = "https://www.getonbrd.com/jobs"
65
+ # /api/v0/modalities -> 1 Full time, 2 Part time, 3 Freelance, 4 Internship.
66
+ # Se filtra por locale_key y no por id: con expand[] el id viene como string "3"
67
+ # y sin expand como int 3, asi que comparar el id se rompe en silencio.
68
+ GETONBRD_FREELANCE_KEY = "freelance"
69
+ # expand[] inlinea los atributos de las referencias. Sin esto, company y tags
70
+ # vienen como {"data":{"id":...}} y no hay nombre que mostrar. Lo aprendimos de
71
+ # job-scrape/scripts/getonbrd_search.py, que ya lo usaba.
72
+ GETONBRD_EXPAND = ("company", "tags", "modality")
73
+
74
+ HIMALAYAS_API = "https://himalayas.app/jobs/api"
75
+ HIMALAYAS_CONTRACT = "Contractor"
76
+ HIMALAYAS_PAGE = 20 # el limit se topea en 20 aunque pidas mas
77
+
78
+ HN_SEARCH = "https://hn.algolia.com/api/v1/search_by_date"
79
+ HN_ITEM = "https://hn.algolia.com/api/v1/items"
80
+ HN_THREAD_QUERY = '"Seeking freelancer"'
81
+ HN_HIRING = "seeking_freelancer"
82
+ HN_AVAILABLE = "seeking_work"
83
+
84
+ # Fuentes de proyectos. HN no esta aca: ver `market` y el docstring de arriba.
85
+ SOURCES = ("getonbrd", "himalayas")
86
+
87
+ # --------------------------------------------------------------------------- #
88
+ # Fuentes con credencial por usuario
89
+ #
90
+ # No pueden entrar a la corrida automatica: la key es de una persona y este repo
91
+ # lo instala cualquiera. Pero omitirlas en silencio es peor, porque el usuario
92
+ # no se entera de que existe una fuente que podria usar. Asi que se declaran
93
+ # aca: `keys` explica como conseguir cada una, y si la variable de entorno esta
94
+ # puesta la fuente se activa sola sin tocar nada mas.
95
+ # --------------------------------------------------------------------------- #
96
+
97
+ UPWORK_GRAPHQL = "https://api.upwork.com/graphql"
98
+
99
+ KEYED_SOURCES = {
100
+ "upwork": {
101
+ "env": "UPWORK_ACCESS_TOKEN",
102
+ "scope": "el marketplace mas grande; proyectos de todo el mundo",
103
+ "como_conseguirla": [
104
+ "Entrar al API Center de tu cuenta de Upwork (freelancer o cliente, cualquier plan).",
105
+ "Pedir una API key nueva. No hace falta cuenta de empresa.",
106
+ "Upwork revisa a mano y contesta por mail en ~1 semana.",
107
+ "Completar los datos de la cuenta antes de pedirla: el rechazo mas comun es data incompleta.",
108
+ "Con el client id y secret, sacar un access token OAuth 2.0 y exportarlo en UPWORK_ACCESS_TOKEN.",
109
+ ],
110
+ "limites": "40.000 requests por dia, 10 por segundo por IP",
111
+ "lo_que_no_se_puede": (
112
+ "no hay mutation para enviar una propuesta ni para gastar Connects: "
113
+ "Upwork lo cerro a proposito contra el auto-bidding. Postularse sigue siendo a mano"
114
+ ),
115
+ "doc": "https://www.upwork.com/developer/documentation/graphql/api/docs/index.html",
116
+ },
117
+ "freelancer": {
118
+ "env": "FREELANCER_OAUTH_TOKEN",
119
+ "scope": "marketplace global, mucho volumen y mucha competencia por precio",
120
+ "como_conseguirla": [
121
+ "Crear una app en la seccion de desarrolladores de Freelancer.com.",
122
+ "Es self-service: no hay revision manual como en Upwork.",
123
+ "Exportar el token en FREELANCER_OAUTH_TOKEN.",
124
+ ],
125
+ "limites": "sin publicar",
126
+ "lo_que_no_se_puede": "el adaptador todavia no esta escrito; la guia esta para quien quiera hacerlo",
127
+ "doc": "https://developers.freelancer.com/",
128
+ "adapter": False,
129
+ },
130
+ }
131
+
132
+ # Expansion por sinonimos.
133
+ #
134
+ # Nadie titula una oferta "ecommerce": la titula "Shopify" o "WooCommerce". Se
135
+ # midio el pool completo de las dos fuentes (29 ofertas freelance) y buscar
136
+ # "ecommerce" devolvia 0 mientras habia un "Desarrollador Web Shopify" adentro;
137
+ # "website" devolvia 0 con un "Especialista WordPress" y un "Partner Tecnico
138
+ # Astro" presentes. Buscar por categoria escondia trabajo real.
139
+ #
140
+ # La clave es la palabra que el freelancer piensa; los valores son las que el
141
+ # cliente escribe. Solo se expande hacia abajo (categoria -> tecnologias): al
142
+ # reves inflaria los resultados de quien ya busca preciso.
143
+ SYNONYMS = {
144
+ "ecommerce": ["shopify", "woocommerce", "magento", "vtex", "tiendanube", "prestashop"],
145
+ "tienda": ["shopify", "woocommerce", "vtex", "tiendanube"],
146
+ "website": ["wordpress", "webflow", "astro", "landing", "next.js", "html"],
147
+ "web": ["wordpress", "webflow", "astro", "landing", "next.js"],
148
+ "sitio": ["wordpress", "webflow", "astro", "landing"],
149
+ "landing": ["wordpress", "webflow", "astro", "html"],
150
+ "cms": ["wordpress", "strapi", "contentful", "sanity", "headless"],
151
+ "backend": ["node", "nestjs", "express", "django", "fastapi", "spring", ".net", "laravel"],
152
+ "frontend": ["react", "vue", "angular", "svelte", "next.js", "tailwind"],
153
+ "fullstack": ["node", "react", "typescript", "django", "laravel"],
154
+ "full-stack": ["node", "react", "typescript", "django", "laravel"],
155
+ "mobile": ["react native", "flutter", "swift", "kotlin", "ios", "android"],
156
+ "go": ["golang"],
157
+ "golang": ["go"],
158
+ "api": ["rest", "graphql", "openapi", "webhook", "integracion"],
159
+ "integraciones": ["api", "webhook", "erp", "crm", "salesforce", "middleware"],
160
+ "saas": ["multi-tenant", "stripe", "billing", "subscription", "dashboard"],
161
+ "dashboard": ["bi", "reporting", "metabase", "looker", "analytics"],
162
+ "database": ["postgres", "mysql", "mongodb", "sql", "migracion"],
163
+ "migracion": ["legacy", "refactor", "modernizacion", "migration"],
164
+ "testing": ["qa", "automation", "cypress", "playwright", "pytest"],
165
+ "qa": ["testing", "cypress", "playwright", "automation"],
166
+ "devops": ["kubernetes", "docker", "terraform", "aws", "ci/cd"],
167
+ "data": ["sql", "etl", "airflow", "bigquery", "dbt"],
168
+ "scraping": ["crawler", "scraper", "playwright", "extraction"],
169
+ "ai": ["llm", "openai", "rag", "machine learning", "langchain"],
170
+ "automatizacion": ["zapier", "n8n", "make", "airflow", "automation"],
171
+ "automation": ["zapier", "n8n", "make", "workflow"],
172
+ # Unity y game design entran porque son perfil del usuario, no porque el
173
+ # mapa apunte a gamedev: Unreal y Godot quedan afuera a proposito. El foco
174
+ # de este perfil es software en general.
175
+ "unity": ["c#", "gameplay", "game design"],
176
+ "gamedesign": ["game design", "level design", "unity"],
177
+ }
178
+ MAX_EXPANDED_TERMS = 6 # tope de terminos extra: cada uno es una request mas
179
+
180
+ RATE_PATTERNS = (
181
+ r"(?:USD|US\$|\$|EUR|€)\s?(\d{1,4})(?:\s?[-a]\s?(?:USD|US\$|\$|EUR|€)?\s?(\d{1,4}))?\s?(?:/|per\s)\s?(hour|hr|h|day|d|week|wk|month|mo)",
182
+ r"(\d{1,4})(?:\s?[-a]\s?(\d{1,4}))?\s?(?:USD|US\$|\$|EUR|€)\s?(?:/|per\s)\s?(hour|hr|h|day|d|week|wk|month|mo)",
183
+ )
184
+
185
+
186
+ class SourceError(RuntimeError):
187
+ pass
188
+
189
+
190
+ def fetch(url: str) -> str:
191
+ request = urllib.request.Request(url, headers={"User-Agent": UA, "Accept": "application/json"})
192
+ last = ""
193
+ for attempt in range(MAX_RETRIES):
194
+ try:
195
+ with urllib.request.urlopen(request, timeout=TIMEOUT) as response:
196
+ return response.read().decode("utf-8", "replace")
197
+ except urllib.error.HTTPError as e:
198
+ # 4xx no se reintenta: no va a cambiar.
199
+ if 400 <= e.code < 500 and e.code != 429:
200
+ raise SourceError("HTTP {0} {1}".format(e.code, e.reason))
201
+ last = "HTTP {0} {1}".format(e.code, e.reason)
202
+ except Exception as e: # timeout, DNS, reset
203
+ last = "{0}: {1}".format(type(e).__name__, e)
204
+ if attempt < MAX_RETRIES - 1:
205
+ time.sleep(RETRY_SLEEP * (attempt + 1))
206
+ raise SourceError(last or "request failed")
207
+
208
+
209
+ def fetch_json(url: str):
210
+ body = fetch(url)
211
+ try:
212
+ return json.loads(body)
213
+ except ValueError as e:
214
+ raise SourceError("respuesta no es JSON: {0}".format(e))
215
+
216
+
217
+ def strip_html(raw: str) -> str:
218
+ text = re.sub(r"<p>", "\n", raw or "")
219
+ text = re.sub(r"<[^>]+>", "", text)
220
+ return html.unescape(text).strip()
221
+
222
+
223
+ # Comas y espacios separan terminos. Solo espacios no alcanza: "typescript,backend"
224
+ # es la forma natural de escribir una lista, y separando solo por espacios se
225
+ # buscaba ese string literal, que no aparece en ningun aviso. El resultado eran
226
+ # cero hits, que se leen como "no hay trabajo" en vez de "no busque nada".
227
+ TERM_SEPARATOR = r"[,\s]+"
228
+
229
+
230
+ def expand_query(query: str):
231
+ """
232
+ Devuelve (terminos_de_busqueda, expansiones_aplicadas).
233
+
234
+ El primer termino es siempre lo que el usuario escribio: es el que se pagina
235
+ en profundidad. Los sinonimos van despues y se consultan mas superficialmente.
236
+ """
237
+ words = [w for w in re.split(TERM_SEPARATOR, (query or "").strip().lower()) if w]
238
+ terms = list(words)
239
+ applied = {}
240
+ for word in words:
241
+ extra = [s for s in SYNONYMS.get(word, []) if s not in terms]
242
+ if not extra:
243
+ continue
244
+ room = MAX_EXPANDED_TERMS - (len(terms) - len(words))
245
+ if room <= 0:
246
+ break
247
+ applied[word] = extra[:room]
248
+ terms.extend(applied[word])
249
+ return terms, applied
250
+
251
+
252
+ def matches_terms(terms, *fields) -> bool:
253
+ """
254
+ Chequeo de relevancia del lado del cliente.
255
+
256
+ La busqueda de GetOnBrd ordena por relevancia, no filtra: `query=unity`
257
+ devuelve 21 paginas donde las ultimas no tienen nada que ver (SAP, COBOL,
258
+ un recruiter). Paginando en profundidad y quedandose con lo freelance de esa
259
+ cola larga, el resultado es basura relevante a nada.
260
+
261
+ Alcanza que UN termino aparezca: "unity developer" tiene que traer los que
262
+ dicen Unity aunque no digan developer, y con expansion "ecommerce" tiene que
263
+ traer los de Shopify.
264
+
265
+ Match por limite de palabra y no por substring. En este dominio hay muchas
266
+ busquedas de dos letras que son legitimas (go, ux, ai, qa) y por substring
267
+ "ux" matchearia dentro de "Linux" y "go" dentro de "Django". El costo es que
268
+ buscar "script" ya no encuentra "JavaScript"; se prefiere ese costo antes que
269
+ devolver ofertas de Linux a quien busca UX.
270
+
271
+ Los terminos con caracteres no alfanumericos (c#, .net, node.js, react
272
+ native) caen a substring: \\b no funciona contra `#`, `.` ni el espacio
273
+ interno de un termino de dos palabras.
274
+ """
275
+ terms = [t for t in (terms or []) if t]
276
+ if not terms:
277
+ return True
278
+ haystack = " ".join(str(f or "") for f in fields).lower()
279
+ for term in terms:
280
+ if term.isalnum():
281
+ if re.search(r"\b{0}\b".format(re.escape(term)), haystack):
282
+ return True
283
+ elif term in haystack:
284
+ return True
285
+ return False
286
+
287
+
288
+ def matches_query(query: str, *fields) -> bool:
289
+ """Version sin expansion, partiendo la query en palabras."""
290
+ return matches_terms([t for t in re.split(r"\s+", (query or "").strip().lower()) if t], *fields)
291
+
292
+
293
+ def record(**kw):
294
+ """Forma comun para que el dedup de /freelance-scan trate todo igual."""
295
+ base = {
296
+ "source": None, "id": None, "title": None, "company": None, "url": None,
297
+ "modality": None, "published_at": None, "location": None,
298
+ "salary_min": None, "salary_max": None, "tags": [], "excerpt": None,
299
+ }
300
+ base.update(kw)
301
+ return base
302
+
303
+
304
+ # --------------------------------------------------------------------------- #
305
+ # GetOnBrd: LatAm + remoto. Comparte API con /job-scrape, sin key.
306
+ # --------------------------------------------------------------------------- #
307
+
308
+ def expanded_name(node):
309
+ """Saca el nombre de una referencia JSON:API expandida, o None."""
310
+ data = (node or {}).get("data") or {}
311
+ return ((data.get("attributes") or {}).get("name")) or None
312
+
313
+
314
+ def expanded_names(node):
315
+ data = (node or {}).get("data") or []
316
+ if isinstance(data, dict):
317
+ data = [data]
318
+ return [n for n in ((item.get("attributes") or {}).get("name") for item in data) if n]
319
+
320
+
321
+ def getonbrd_search(query: str, pages: int, per_page: int = 50, terms=None):
322
+ """
323
+ `query` maneja el pool que devuelve el server; `terms` filtra lo que se queda.
324
+
325
+ Los dos hacen falta y no son lo mismo: el server rankea por relevancia
326
+ respecto de `query`, asi que buscar "ecommerce" nunca trae el pool donde vive
327
+ el "Desarrollador Web Shopify". Por eso el caller llama una vez por termino
328
+ expandido y dedupea; aca solo se resuelve una.
329
+ """
330
+ terms = terms if terms is not None else [query]
331
+ out = []
332
+ truncated = False
333
+ scanned = 0
334
+ irrelevant = 0
335
+ for page in range(1, pages + 1):
336
+ params = [("query", query), ("per_page", str(per_page)), ("page", str(page))]
337
+ params += [("expand[]", field) for field in GETONBRD_EXPAND]
338
+ payload = fetch_json("{0}/search/jobs?{1}".format(GETONBRD_API, urllib.parse.urlencode(params)))
339
+ data = payload.get("data") or []
340
+ scanned += len(data)
341
+ for job in data:
342
+ attrs = job.get("attributes") or {}
343
+ modality = (attrs.get("modality") or {}).get("data") or {}
344
+ if ((modality.get("attributes") or {}).get("locale_key")) != GETONBRD_FREELANCE_KEY:
345
+ continue
346
+ tags = expanded_names(attrs.get("tags"))
347
+ # El orden importa: la relevancia se chequea DESPUES de la modalidad,
348
+ # asi `irrelevant` cuenta solo freelance descartado por no tener nada
349
+ # que ver, que es el numero que le dice al usuario que afine la query.
350
+ if not matches_terms(terms, attrs.get("title"), " ".join(tags),
351
+ attrs.get("description_headline")):
352
+ irrelevant += 1
353
+ continue
354
+ out.append(record(
355
+ source="getonbrd",
356
+ id=str(job.get("id")),
357
+ title=attrs.get("title"),
358
+ company=expanded_name(attrs.get("company")),
359
+ url="{0}/{1}".format(GETONBRD_JOBS, job.get("id")),
360
+ modality="freelance",
361
+ published_at=attrs.get("published_at"),
362
+ location=attrs.get("remote_modality") or ", ".join(attrs.get("location_cities") or []),
363
+ salary_min=attrs.get("min_salary"),
364
+ salary_max=attrs.get("max_salary"),
365
+ tags=tags,
366
+ excerpt=(attrs.get("description_headline") or "")[:400] or None,
367
+ ))
368
+ total_pages = (payload.get("meta") or {}).get("total_pages") or 1
369
+ if page >= total_pages:
370
+ break
371
+ if page == pages and total_pages > pages:
372
+ truncated = True
373
+ return out, truncated, scanned, irrelevant
374
+
375
+
376
+ def getonbrd_detail(job_id: str):
377
+ payload = fetch_json("{0}/jobs/{1}".format(GETONBRD_API, urllib.parse.quote(str(job_id))))
378
+ data = payload.get("data") or {}
379
+ attrs = data.get("attributes") or {}
380
+ return {
381
+ "source": "getonbrd",
382
+ "id": str(data.get("id") or job_id),
383
+ "title": attrs.get("title"),
384
+ "url": "{0}/{1}".format(GETONBRD_JOBS, data.get("id") or job_id),
385
+ "modality_id": (((attrs.get("modality") or {}).get("data") or {}).get("id")),
386
+ "description": strip_html(attrs.get("description") or ""),
387
+ "desirable": strip_html(attrs.get("desirable") or ""),
388
+ "functions": strip_html(attrs.get("functions") or ""),
389
+ "min_salary": attrs.get("min_salary"),
390
+ "max_salary": attrs.get("max_salary"),
391
+ "remote_modality": attrs.get("remote_modality"),
392
+ "tags": attrs.get("tags") or [],
393
+ }
394
+
395
+
396
+ # --------------------------------------------------------------------------- #
397
+ # Himalayas: remoto global. employmentType "Contractor" existe pero no filtra.
398
+ # --------------------------------------------------------------------------- #
399
+
400
+ def himalayas_search(query: str, pages: int, terms=None):
401
+ """Himalayas devuelve un feed unico, asi que la expansion es solo del filtro."""
402
+ terms = terms if terms is not None else [query]
403
+ out = []
404
+ truncated = False
405
+ scanned = 0
406
+ irrelevant = 0
407
+ for page in range(pages):
408
+ params = urllib.parse.urlencode({"limit": HIMALAYAS_PAGE, "offset": page * HIMALAYAS_PAGE})
409
+ payload = fetch_json("{0}?{1}".format(HIMALAYAS_API, params))
410
+ jobs = payload.get("jobs") or []
411
+ if not jobs:
412
+ break
413
+ scanned += len(jobs)
414
+ for job in jobs:
415
+ if job.get("employmentType") != HIMALAYAS_CONTRACT:
416
+ continue
417
+ if not matches_terms(terms, job.get("title"), job.get("excerpt"),
418
+ " ".join(job.get("categories") or [])):
419
+ irrelevant += 1
420
+ continue
421
+ out.append(record(
422
+ source="himalayas",
423
+ id=str(job.get("guid") or job.get("applicationLink")),
424
+ title=job.get("title"),
425
+ company=job.get("companyName"),
426
+ url=job.get("applicationLink"),
427
+ modality="contract",
428
+ published_at=job.get("pubDate"),
429
+ location=", ".join(job.get("locationRestrictions") or []) or "remote",
430
+ salary_min=job.get("minSalary"),
431
+ salary_max=job.get("maxSalary"),
432
+ tags=job.get("categories") or [],
433
+ excerpt=(job.get("excerpt") or "")[:400] or None,
434
+ ))
435
+ total = payload.get("totalCount")
436
+ if total and (page + 1) * HIMALAYAS_PAGE >= total:
437
+ break
438
+ if page + 1 == pages:
439
+ truncated = True
440
+ return out, truncated, scanned, irrelevant
441
+
442
+
443
+ # --------------------------------------------------------------------------- #
444
+ # Hacker News: el hilo mensual "Freelancer? Seeking freelancer?".
445
+ #
446
+ # Esto NO es un buscador de proyectos, aunque el titulo del hilo lo sugiera. Se
447
+ # midieron 6 meses (marzo a agosto 2026): 113 comentarios de freelancers
448
+ # ofreciendose contra 2 ofertas de trabajo. Lo que si te da, y gratis, es
449
+ # como se presentan y cuanto cobran otros con tu perfil. Eso alimenta
450
+ # /freelance-profile y la decision de tarifa.
451
+ # --------------------------------------------------------------------------- #
452
+
453
+ # --------------------------------------------------------------------------- #
454
+ # Triage de resultados de WebSearch
455
+ #
456
+ # WebSearch es el unico camino que llega a los marketplaces sin credencial, pero
457
+ # ~la mitad de lo que devuelve no son ofertas: son landings de "Hire Golang
458
+ # developers", tablas de tarifas y gigs del Project Catalog. Se separan por
459
+ # patron de URL, que es la parte determinista y por eso vive aca y no en el
460
+ # criterio del modelo.
461
+ #
462
+ # Lo que no matchea ningun patron queda `unknown` a proposito. Adivinar mete
463
+ # landings en la cola, y una cola con basura se deja de mirar.
464
+ # --------------------------------------------------------------------------- #
465
+
466
+ URL_RULES = (
467
+ ("upwork", r"(^|\.)upwork\.com$", (
468
+ r"/jobs?/[^/]*~[0-9a-z]+", # /job/Slug_~017abc...
469
+ r"/freelance-jobs/apply/[^/]*~", # variante con /apply/
470
+ ), (
471
+ r"/hire/", r"/services/product/", r"/services/",
472
+ r"/freelance-jobs/[a-z]{2}/", r"/developer", r"/support",
473
+ r"/nx/search/", # la busqueda, no una oferta
474
+ )),
475
+ ("workana", r"(^|\.)workana\.com$", (
476
+ r"/job/[^/?]+$",
477
+ r"/(es|en|pt)/job/[^/?]+$",
478
+ ), (
479
+ r"/hire/", r"/skill/", r"/jobs\b", r"/work/",
480
+ )),
481
+ ("freelancer", r"(^|\.)freelancer\.(com|[a-z]{2})$", (
482
+ r"/projects/[^/]+/[^/]+",
483
+ ), (
484
+ r"/jobs/", r"/freelancers/", r"/hire/",
485
+ )),
486
+ ("peopleperhour", r"(^|\.)peopleperhour\.com$", (
487
+ r"/freelance-job/", r"/job/[^/?]+",
488
+ ), (
489
+ r"/hire-freelancers/", r"/freelance-[a-z-]+-jobs$", r"/site/",
490
+ )),
491
+ ("guru", r"(^|\.)guru\.com$", (
492
+ r"/jobs/[^/]+/\d+",
493
+ ), (
494
+ r"/m/hire/", r"/m/find/", r"/d/jobs/browse/", r"/help/",
495
+ )),
496
+ )
497
+
498
+
499
+ def classify_url(url: str):
500
+ """
501
+ Devuelve (plataforma, veredicto) donde veredicto es posting | landing | unknown.
502
+
503
+ Se mira solo el path: los parametros de query cambian por sesion y por
504
+ campania, asi que meterlos en la decision la vuelve inestable.
505
+ """
506
+ try:
507
+ parts = urllib.parse.urlsplit(url)
508
+ except ValueError:
509
+ return None, "unknown"
510
+
511
+ host = (parts.netloc or "").lower().split(":")[0]
512
+ path = parts.path or "/"
513
+
514
+ for name, host_re, posting_res, landing_res in URL_RULES:
515
+ if not re.search(host_re, host):
516
+ continue
517
+ for pattern in posting_res:
518
+ if re.search(pattern, path, re.IGNORECASE):
519
+ return name, "posting"
520
+ for pattern in landing_res:
521
+ if re.search(pattern, path, re.IGNORECASE):
522
+ return name, "landing"
523
+ return name, "unknown"
524
+
525
+ return None, "unknown"
526
+
527
+
528
+ def triage_urls(urls):
529
+ postings, landings, unknown, other = [], [], [], []
530
+ seen = set()
531
+ for raw in urls:
532
+ url = (raw or "").strip()
533
+ if not url or url in seen:
534
+ continue
535
+ seen.add(url)
536
+ platform, verdict = classify_url(url)
537
+ row = {"url": url, "platform": platform}
538
+ if platform is None:
539
+ other.append(row)
540
+ elif verdict == "posting":
541
+ postings.append(row)
542
+ elif verdict == "landing":
543
+ landings.append(row)
544
+ else:
545
+ unknown.append(row)
546
+
547
+ by_platform = {}
548
+ for row in postings:
549
+ by_platform[row["platform"]] = by_platform.get(row["platform"], 0) + 1
550
+
551
+ return {
552
+ # Lo unico que vale abrir. El resto se reporta para que se vea cuanto
553
+ # ruido trajo la busqueda, no para esconderlo.
554
+ "postings": postings,
555
+ "postings_por_plataforma": by_platform,
556
+ "descartados_landing": landings,
557
+ "sin_clasificar": unknown,
558
+ "fuera_de_plataformas_conocidas": other,
559
+ "total_recibidas": len(seen),
560
+ "advertencia": (
561
+ "El indice de busqueda no filtra por fecha: estas URLs pueden ser viejas. "
562
+ "Verificar la fecha al abrir cada una antes de invertir tiempo."
563
+ ),
564
+ }
565
+
566
+
567
+ # --------------------------------------------------------------------------- #
568
+ # Upwork (opt-in, requiere UPWORK_ACCESS_TOKEN)
569
+ # --------------------------------------------------------------------------- #
570
+
571
+ # La query va aparte para que se pueda ajustar sin tocar la logica. Sale de la
572
+ # doc GraphQL de Upwork; el transporte y el parseo estan testeados contra un
573
+ # payload fijo, pero la forma exacta de la query no se pudo verificar contra la
574
+ # API real porque hace falta una key aprobada. Si Upwork cambio un nombre de
575
+ # campo, el error de abajo lo dice con el mensaje del server en la mano.
576
+ UPWORK_QUERY = """
577
+ query marketplaceJobPostings($filter: MarketplaceJobPostingsSearchFilter,
578
+ $pagination: PaginationInput) {
579
+ marketplaceJobPostingsSearch(marketPlaceJobFilter: $filter,
580
+ searchType: USER_JOBS_SEARCH,
581
+ pagination: $pagination) {
582
+ totalCount
583
+ edges {
584
+ node {
585
+ id
586
+ title
587
+ description
588
+ ciphertext
589
+ duration
590
+ engagement
591
+ createdDateTime
592
+ amount { rawValue currency }
593
+ hourlyBudgetMin { rawValue }
594
+ hourlyBudgetMax { rawValue }
595
+ client { totalHires totalSpent { rawValue } verificationStatus location { country } }
596
+ skills { name }
597
+ }
598
+ }
599
+ }
600
+ }
601
+ """
602
+
603
+
604
+ def upwork_token():
605
+ return os.environ.get(KEYED_SOURCES["upwork"]["env"], "").strip()
606
+
607
+
608
+ def _walk_nodes(obj, out, depth=0):
609
+ """
610
+ Junta los `node` de cualquier conexion GraphQL sin asumir el anidamiento.
611
+
612
+ Upwork cambio la forma de la respuesta al menos una vez; caminar el arbol
613
+ cuesta poco y evita que un nivel extra rompa todo con un KeyError.
614
+ """
615
+ if depth > 8:
616
+ return
617
+ if isinstance(obj, dict):
618
+ if "node" in obj and isinstance(obj["node"], dict):
619
+ out.append(obj["node"])
620
+ return
621
+ for value in obj.values():
622
+ _walk_nodes(value, out, depth + 1)
623
+ elif isinstance(obj, list):
624
+ for value in obj:
625
+ _walk_nodes(value, out, depth + 1)
626
+
627
+
628
+ def _money(node, *path):
629
+ cur = node
630
+ for key in path:
631
+ if not isinstance(cur, dict):
632
+ return None
633
+ cur = cur.get(key)
634
+ if isinstance(cur, dict):
635
+ cur = cur.get("rawValue")
636
+ return cur
637
+
638
+
639
+ def upwork_search(query: str, pages: int, terms=None):
640
+ token = upwork_token()
641
+ if not token:
642
+ raise SourceError(
643
+ "falta {0}. Correr `keys` para ver como conseguir la credencial".format(
644
+ KEYED_SOURCES["upwork"]["env"]
645
+ )
646
+ )
647
+
648
+ found, seen = [], 0
649
+ for page in range(max(1, pages)):
650
+ payload = json.dumps({
651
+ "query": UPWORK_QUERY,
652
+ "variables": {
653
+ "filter": {"titleExpression_eq": query or "developer"},
654
+ "pagination": {"first": 50, "after": str(page * 50)},
655
+ },
656
+ }).encode("utf-8")
657
+
658
+ req = urllib.request.Request(
659
+ UPWORK_GRAPHQL,
660
+ data=payload,
661
+ headers={
662
+ "Authorization": "Bearer {0}".format(token),
663
+ "Content-Type": "application/json",
664
+ "User-Agent": UA,
665
+ },
666
+ )
667
+ try:
668
+ with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
669
+ body = json.loads(resp.read().decode("utf-8", "replace"))
670
+ except urllib.error.HTTPError as exc:
671
+ detail = exc.read().decode("utf-8", "replace")[:300] if exc.fp else ""
672
+ if exc.code in (401, 403):
673
+ raise SourceError(
674
+ "Upwork rechazo el token ({0}). Puede estar vencido: los access token "
675
+ "de OAuth 2.0 caducan y hay que refrescarlos. {1}".format(exc.code, detail)
676
+ )
677
+ raise SourceError("Upwork HTTP {0}: {1}".format(exc.code, detail))
678
+ except Exception as exc: # noqa: BLE001
679
+ raise SourceError("Upwork inalcanzable: {0}".format(exc))
680
+
681
+ # GraphQL responde 200 con los errores adentro, asi que hay que mirarlos.
682
+ if body.get("errors"):
683
+ msgs = "; ".join(str(e.get("message", e))[:160] for e in body["errors"][:3])
684
+ raise SourceError(
685
+ "Upwork devolvio errores de GraphQL: {0}. Si menciona un campo desconocido, "
686
+ "ajustar UPWORK_QUERY: el schema pudo haber cambiado.".format(msgs)
687
+ )
688
+
689
+ nodes = []
690
+ _walk_nodes(body.get("data"), nodes)
691
+ if not nodes:
692
+ break
693
+
694
+ for node in nodes:
695
+ seen += 1
696
+ title = node.get("title") or ""
697
+ desc = strip_html(node.get("description") or "")
698
+ skills = " ".join(s.get("name", "") for s in (node.get("skills") or [])
699
+ if isinstance(s, dict))
700
+ if not matches_terms(terms, title, desc, skills):
701
+ continue
702
+ client = node.get("client") or {}
703
+ cipher = node.get("ciphertext") or ""
704
+ found.append(record(
705
+ source="upwork",
706
+ id=node.get("id"),
707
+ title=title,
708
+ description=desc[:600],
709
+ url="https://www.upwork.com/jobs/{0}".format(cipher) if cipher else None,
710
+ modality="freelance",
711
+ published=node.get("createdDateTime"),
712
+ engagement=node.get("engagement"),
713
+ duration=node.get("duration"),
714
+ budget=_money(node, "amount"),
715
+ hourly_min=_money(node, "hourlyBudgetMin"),
716
+ hourly_max=_money(node, "hourlyBudgetMax"),
717
+ client_hires=client.get("totalHires"),
718
+ client_spent=_money(client, "totalSpent"),
719
+ client_verified=client.get("verificationStatus"),
720
+ client_country=(client.get("location") or {}).get("country"),
721
+ tags=[s.get("name") for s in (node.get("skills") or []) if isinstance(s, dict)],
722
+ ))
723
+
724
+ if len(nodes) < 50:
725
+ break
726
+
727
+ return found, seen, 0
728
+
729
+
730
+ def hn_latest_threads(limit: int = 3):
731
+ params = urllib.parse.urlencode({
732
+ "query": HN_THREAD_QUERY, "tags": "story", "hitsPerPage": limit,
733
+ })
734
+ payload = fetch_json("{0}?{1}".format(HN_SEARCH, params))
735
+ return [
736
+ {"id": h.get("objectID"), "title": h.get("title"),
737
+ "created_at": h.get("created_at"), "num_comments": h.get("num_comments")}
738
+ for h in (payload.get("hits") or [])
739
+ ]
740
+
741
+
742
+ def hn_classify(text: str) -> str:
743
+ """La convencion del hilo: el comentario arranca declarando de que lado esta."""
744
+ head = re.sub(r"[^A-Za-z ]", " ", text[:110]).upper()
745
+ head = re.sub(r"\s+", " ", head)
746
+ if "SEEKING FREELANCER" in head:
747
+ return HN_HIRING
748
+ if "SEEKING WORK" in head:
749
+ return HN_AVAILABLE
750
+ return "unknown"
751
+
752
+
753
+ def extract_rates(text: str):
754
+ """Tarifas mencionadas en el texto. Devuelve lo que encuentra, sin inventar."""
755
+ found = []
756
+ for pattern in RATE_PATTERNS:
757
+ for m in re.finditer(pattern, text, re.IGNORECASE):
758
+ low, high, unit = m.group(1), m.group(2), m.group(3).lower()
759
+ unit = {"hr": "hour", "h": "hour", "d": "day", "wk": "week", "mo": "month"}.get(unit, unit)
760
+ found.append({"min": int(low), "max": int(high) if high else None,
761
+ "unit": unit, "quote": m.group(0).strip()})
762
+ return found
763
+
764
+
765
+ def hn_market(months: int, query: str):
766
+ """Inteligencia de posicionamiento, no ofertas. Ver el comentario de arriba."""
767
+ profiles, offers, counts = [], [], collections.Counter()
768
+ threads_read = []
769
+
770
+ for thread in hn_latest_threads(months):
771
+ payload = fetch_json("{0}/{1}".format(HN_ITEM, thread["id"]))
772
+ threads_read.append({"title": thread["title"], "id": thread["id"],
773
+ "created_at": thread["created_at"]})
774
+ for child in (payload.get("children") or []):
775
+ if child.get("type") != "comment":
776
+ continue
777
+ text = strip_html(child.get("text") or "")
778
+ if not text:
779
+ continue
780
+ kind = hn_classify(text)
781
+ counts[kind] += 1
782
+ if query and query.strip().lower() not in text.lower():
783
+ continue
784
+ first = next((line.strip() for line in text.splitlines() if line.strip()), "")
785
+ entry = {
786
+ "author": child.get("author"),
787
+ "created_at": child.get("created_at"),
788
+ "url": "https://news.ycombinator.com/item?id={0}".format(child.get("id")),
789
+ "headline": first[:160],
790
+ "rates_mentioned": extract_rates(text),
791
+ "excerpt": text[:700],
792
+ }
793
+ if kind == HN_HIRING:
794
+ offers.append(entry)
795
+ else:
796
+ profiles.append(entry)
797
+
798
+ rates = [r for p in profiles for r in p["rates_mentioned"]]
799
+ return {
800
+ "threads_read": threads_read,
801
+ "composition": dict(counts),
802
+ "note": ("El hilo lo dominan freelancers ofreciendose, no clientes buscando. "
803
+ "Usalo para posicionamiento y tarifa, no como cola de trabajo. "
804
+ "Solo ~3% de los perfiles declara una tarifa parseable: mira siempre el n "
805
+ "de rates_summary antes de tratar la mediana como dato de mercado."),
806
+ "competitor_profiles": profiles,
807
+ "actual_offers": offers,
808
+ "rates_found": rates,
809
+ "rates_summary": rate_summary(rates),
810
+ }
811
+
812
+
813
+ def rate_summary(rates):
814
+ by_unit = {}
815
+ for r in rates:
816
+ by_unit.setdefault(r["unit"], []).append(r["min"])
817
+ out = {}
818
+ for unit, values in by_unit.items():
819
+ values.sort()
820
+ mid = len(values) // 2
821
+ out[unit] = {
822
+ "n": len(values), "min": values[0], "max": values[-1],
823
+ "median": values[mid] if len(values) % 2 else (values[mid - 1] + values[mid]) / 2,
824
+ }
825
+ return out
826
+
827
+
828
+ # --------------------------------------------------------------------------- #
829
+
830
+ def run_search(args) -> int:
831
+ if args.source == "all":
832
+ # Las fuentes con credencial se suman solas cuando la variable esta
833
+ # puesta. Sin ella no se intentan, porque fallarian en cada corrida y el
834
+ # usuario aprenderia a ignorar los errores.
835
+ wanted = list(SOURCES) + [
836
+ name for name, spec in KEYED_SOURCES.items()
837
+ if spec.get("adapter", True) and os.environ.get(spec["env"], "").strip()
838
+ ]
839
+ else:
840
+ wanted = [args.source]
841
+ base_query = args.query or "developer"
842
+
843
+ if args.no_expand:
844
+ terms, applied = [t for t in re.split(TERM_SEPARATOR, base_query.lower()) if t], {}
845
+ else:
846
+ terms, applied = expand_query(base_query)
847
+
848
+ results, errors, truncated, scanned = [], {}, [], {}
849
+ by_id = {}
850
+
851
+ for name in wanted:
852
+ try:
853
+ if name == "getonbrd":
854
+ # Una request por termino: el server rankea segun `query`, asi que
855
+ # "ecommerce" y "shopify" devuelven pools distintos. El termino del
856
+ # usuario se pagina hondo; los sinonimos, una pagina (el tope de
857
+ # relevancia es lo que importa y cada uno cuesta una request).
858
+ found, seen, off, cut = [], 0, 0, False
859
+ for i, term in enumerate(terms):
860
+ depth = args.pages if i < len(re.split(r"\s+", base_query.strip())) else 1
861
+ got, c, s, o = getonbrd_search(term, depth, terms=terms)
862
+ found.extend(got)
863
+ seen += s
864
+ off += o
865
+ cut = cut or c
866
+ elif name == "upwork":
867
+ found, seen, off = upwork_search(base_query, args.pages, terms=terms)
868
+ cut = False
869
+ else:
870
+ found, cut, seen, off = himalayas_search(base_query, args.pages, terms=terms)
871
+
872
+ fresh = 0
873
+ for row in found:
874
+ key = (row["source"], row["id"])
875
+ if key in by_id:
876
+ continue
877
+ by_id[key] = row
878
+ results.append(row)
879
+ fresh += 1
880
+ scanned[name] = {"scanned": seen, "freelance": fresh,
881
+ "freelance_pero_irrelevante": off}
882
+ if cut:
883
+ truncated.append(name)
884
+ except SourceError as e:
885
+ # Una fuente caida no invalida las otras: se reporta y se sigue.
886
+ errors[name] = str(e)
887
+
888
+ if errors and not results:
889
+ emit({"error": "todas las fuentes fallaron", "code": "ALL_SOURCES_FAILED", "detail": errors})
890
+ return 1
891
+
892
+ emit({
893
+ "query": args.query,
894
+ # Que se busco realmente. Sin esto el usuario no entiende por que una
895
+ # busqueda de "ecommerce" le trajo una oferta de Shopify.
896
+ "terms_searched": terms,
897
+ "synonyms_applied": applied,
898
+ "sources_requested": list(wanted),
899
+ "sources_failed": errors,
900
+ # El rendimiento por fuente se reporta siempre: filtrar freelance del lado
901
+ # del cliente significa descartar mucho, y eso tiene que quedar a la vista
902
+ # en vez de parecer que la fuente no tiene nada. `freelance_pero_irrelevante`
903
+ # alto quiere decir que la query es muy amplia para el catalogo de la fuente.
904
+ "yield_by_source": scanned,
905
+ "truncated": truncated,
906
+ "count": len(results),
907
+ "results": results,
908
+ })
909
+ return 0
910
+
911
+
912
+ def run_sources(_args) -> int:
913
+ emit({"project_sources": [
914
+ {"name": "getonbrd", "scope": "LatAm y remoto", "auth": "ninguna",
915
+ "freelance_filter": "client-side (modality id 3); el parametro del server no filtra",
916
+ "shares_cli_with": "job-scrape"},
917
+ {"name": "himalayas", "scope": "remoto global", "auth": "ninguna",
918
+ "freelance_filter": "client-side (employmentType Contractor); el parametro del server no filtra",
919
+ "note": "limit topeado en 20 por pagina"},
920
+ ], "market_intel_only": [
921
+ {"name": "hn", "subcommand": "market", "auth": "ninguna (10k req/hora)",
922
+ "why_not_projects": ("medido sobre 6 meses: 113 freelancers ofreciendose contra 2 ofertas. "
923
+ "Sirve para tarifa y posicionamiento, no como cola de trabajo")},
924
+ ], "con_credencial_propia": [
925
+ {"name": name, "env": spec["env"],
926
+ "configurada": bool(os.environ.get(spec["env"], "").strip()),
927
+ "detalle": "correr `keys` para ver como conseguirla"}
928
+ for name, spec in KEYED_SOURCES.items()
929
+ ], "excluded": [
930
+ {"name": "remoteok", "why": "sus terminos exigen backlink do-follow o suspenden el acceso; un CLI local no puede cumplirlo"},
931
+ {"name": "remotive", "why": "backlink do-follow obligatorio, maximo 4 requests por dia y datos con 24h de retraso"},
932
+ {"name": "jobicy", "why": "sin key y con terminos cumplibles, pero 99 de 100 avisos son full-time y jobType=contract devuelve HTTP 400"},
933
+ {"name": "workingnomads", "why": "sin campo de tipo de contrato: no hay forma de filtrar freelance sin adivinar por el titulo"},
934
+ {"name": "workana", "why": "sin API ni RSS publico (404 en las tres rutas probadas). Se busca con WebSearch y se abre a mano"},
935
+ {"name": "peopleperhour", "why": "sin RSS, y su robots.txt prohibe las URLs con filtros que usaria un buscador"},
936
+ {"name": "guru", "why": "sin RSS y con Disallow: /api/ explicito en robots.txt"},
937
+ {"name": "malt / freelancermap", "why": "API real detras de 401: credencial por usuario, mismo muro que Upwork"},
938
+ ], "sin_api_pero_alcanzables": {
939
+ "como": "WebSearch con allowed_domains, y despues `triage` para separar ofertas del ruido SEO",
940
+ "plataformas": ["upwork", "workana", "freelancer", "peopleperhour", "guru"],
941
+ "limite": "WebFetch devuelve 403 en todas: se descubre la oferta pero hay que abrirla a mano",
942
+ }})
943
+ return 0
944
+
945
+
946
+ def run_keys(_args) -> int:
947
+ """
948
+ Que fuentes necesitan credencial, como se consigue cada una y cual esta lista.
949
+
950
+ Existe porque omitirlas en silencio le esconde al usuario que hay una fuente
951
+ que podria estar usando. El estado se calcula mirando el entorno, asi que
952
+ responde por la maquina donde corre y no por lo que diga un README.
953
+ """
954
+ out = []
955
+ for name, spec in KEYED_SOURCES.items():
956
+ configurada = bool(os.environ.get(spec["env"], "").strip())
957
+ tiene_adaptador = spec.get("adapter", True)
958
+ if configurada and tiene_adaptador:
959
+ estado = "lista: la variable esta puesta, `search --source {0}` ya la usa".format(name)
960
+ elif configurada:
961
+ estado = "credencial puesta pero el adaptador no esta escrito todavia"
962
+ elif tiene_adaptador:
963
+ estado = "no disponible: falta {0}. Con la variable puesta se activa sola".format(spec["env"])
964
+ else:
965
+ estado = "no disponible: falta {0} y ademas el adaptador no esta escrito".format(spec["env"])
966
+ out.append({
967
+ "fuente": name,
968
+ "estado": estado,
969
+ "configurada": configurada,
970
+ "variable_de_entorno": spec["env"],
971
+ "scope": spec["scope"],
972
+ "como_conseguirla": spec["como_conseguirla"],
973
+ "limites": spec["limites"],
974
+ "lo_que_no_se_puede": spec["lo_que_no_se_puede"],
975
+ "doc": spec["doc"],
976
+ })
977
+ emit({
978
+ "fuentes_con_credencial": out,
979
+ "por_que_no_vienen_por_defecto": (
980
+ "la credencial es de una persona y este repo lo instala cualquiera. "
981
+ "Una fuente que necesita key funcionaria para uno y estaria rota para el resto"
982
+ ),
983
+ "sin_credencial_ya_funcionan": list(SOURCES),
984
+ })
985
+ return 0
986
+
987
+
988
+ def run_triage(args) -> int:
989
+ urls = list(args.url or [])
990
+ if not urls and not sys.stdin.isatty():
991
+ urls = [line.strip() for line in sys.stdin.read().splitlines()]
992
+ if not urls:
993
+ emit({"error": "no llegaron URLs; pasar --url o por stdin", "code": "NO_URLS"})
994
+ return 1
995
+ emit(triage_urls(urls))
996
+ return 0
997
+
998
+
999
+ def run_market(args) -> int:
1000
+ emit(hn_market(args.months, args.query))
1001
+ return 0
1002
+
1003
+
1004
+ def run_detail(args) -> int:
1005
+ if args.source != "getonbrd":
1006
+ emit({"error": "detail solo esta implementado para getonbrd; las otras fuentes ya traen el texto completo en search",
1007
+ "code": "NO_DETAIL"})
1008
+ return 1
1009
+ emit(getonbrd_detail(args.id))
1010
+ return 0
1011
+
1012
+
1013
+ def emit(payload) -> None:
1014
+ json.dump(payload, sys.stdout, ensure_ascii=False, indent=2)
1015
+ sys.stdout.write("\n")
1016
+
1017
+
1018
+ def force_utf8_streams() -> None:
1019
+ """
1020
+ En Windows stdout es cp1252, asi que un acento o una vinieta en una
1021
+ descripcion revienta con UnicodeEncodeError en cuanto se pipea a un archivo.
1022
+ Mismo fix que job-scrape/scripts/getonbrd_search.py.
1023
+ """
1024
+ for stream in (sys.stdout, sys.stderr):
1025
+ if hasattr(stream, "reconfigure"):
1026
+ stream.reconfigure(encoding="utf-8")
1027
+
1028
+
1029
+ def main(argv=None) -> int:
1030
+ force_utf8_streams()
1031
+ parser = argparse.ArgumentParser(description="Busca proyectos freelance en fuentes publicas sin API key.")
1032
+ sub = parser.add_subparsers(dest="command", required=True)
1033
+
1034
+ search = sub.add_parser("search", help="buscar proyectos freelance")
1035
+ search.add_argument("--query", default="", help="terminos a buscar")
1036
+ search.add_argument("--source", default="all",
1037
+ choices=list(SOURCES) + list(KEYED_SOURCES) + ["all"])
1038
+ search.add_argument("--pages", type=int, default=3, help="paginas por fuente (default 3)")
1039
+ search.add_argument("--no-expand", action="store_true",
1040
+ help="no expandir por sinonimos (ecommerce no busca shopify, etc)")
1041
+ search.set_defaults(func=run_search)
1042
+
1043
+ sources = sub.add_parser("sources", help="listar fuentes disponibles y las descartadas con su motivo")
1044
+ sources.set_defaults(func=run_sources)
1045
+
1046
+ keys = sub.add_parser("keys", help="fuentes que piden credencial, como conseguirla y cual esta lista")
1047
+ keys.set_defaults(func=run_keys)
1048
+
1049
+ triage = sub.add_parser(
1050
+ "triage",
1051
+ help="separar ofertas reales del ruido SEO en URLs traidas por WebSearch",
1052
+ )
1053
+ triage.add_argument("--url", action="append", help="repetible; si falta, lee de stdin")
1054
+ triage.set_defaults(func=run_triage)
1055
+
1056
+ market = sub.add_parser("market", help="que cobran y como se posicionan otros freelancers (hilo mensual de HN)")
1057
+ market.add_argument("--months", type=int, default=3, help="cuantos hilos mensuales leer (default 3)")
1058
+ market.add_argument("--query", default="", help="filtrar por stack o palabra")
1059
+ market.set_defaults(func=run_market)
1060
+
1061
+ detail = sub.add_parser("detail", help="traer el detalle completo de una oferta")
1062
+ detail.add_argument("--source", default="getonbrd", choices=list(SOURCES))
1063
+ detail.add_argument("--id", required=True)
1064
+ detail.set_defaults(func=run_detail)
1065
+
1066
+ args = parser.parse_args(argv)
1067
+ try:
1068
+ return args.func(args)
1069
+ except SourceError as e:
1070
+ emit({"error": str(e), "code": "SOURCE_ERROR"})
1071
+ return 1
1072
+ except KeyboardInterrupt:
1073
+ return 130
1074
+
1075
+
1076
+ if __name__ == "__main__":
1077
+ sys.exit(main())