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.
- package/CHANGELOG.md +29 -0
- package/README.md +57 -55
- package/agents/audio/audio-director.md +90 -0
- package/agents/audio/audio-programmer.md +97 -0
- package/agents/audio/audio-qa.md +95 -0
- package/agents/audio/composer.md +88 -0
- package/agents/audio/technical-sound-designer.md +90 -0
- package/agents/audio/voice-director.md +96 -0
- package/agents/career/career-strategist.md +88 -0
- package/agents/career/cv-reviewer.md +81 -0
- package/agents/career/hiring-manager.md +68 -0
- package/agents/career/mock-interviewer.md +76 -0
- package/agents/freelance/client-screener.md +84 -0
- package/agents/freelance/discovery-call.md +65 -0
- package/agents/freelance/pipeline-strategist.md +99 -0
- package/agents/freelance/proposal-reviewer.md +82 -0
- package/dist/cli.js +52 -13
- package/docs/RELEASE-SETUP.md +1 -1
- package/docs/SKILLS-CATALOG.md +47 -3
- package/docs/USER-GUIDE.md +77 -72
- package/docs/agent-hierarchy.md +21 -1
- package/docs/presentations/build_arcane_deck.py +2 -2
- package/docs/presentations/build_arcane_deck_12.py +2 -2
- package/package.json +2 -2
- package/profiles/audio.yaml +45 -0
- package/profiles/freelance.yaml +43 -0
- package/profiles/job-hunt.yaml +5 -2
- package/rules/drafter-reviewer.md +16 -3
- package/rules/freelance-guardrails.md +53 -0
- package/rules/gamedev/audio-code.md +45 -0
- package/skills/adaptive-music/SKILL.md +171 -0
- package/skills/adaptive-music/references/anti-patterns.md +53 -0
- package/skills/audio-audit/SKILL.md +164 -0
- package/skills/audio-audit/references/anti-patterns.md +65 -0
- package/skills/audio-audit/scripts/check_assets.py +208 -0
- package/skills/audio-bible/SKILL.md +119 -0
- package/skills/audio-bible/references/anti-patterns.md +39 -0
- package/skills/audio-mix/SKILL.md +151 -0
- package/skills/audio-mix/references/anti-patterns.md +65 -0
- package/skills/audio-spec/SKILL.md +107 -0
- package/skills/audio-spec/references/anti-patterns.md +45 -0
- package/skills/career-registry/SKILL.md +95 -0
- package/skills/career-registry/scripts/career_registry.py +488 -0
- package/skills/client-screen/SKILL.md +83 -0
- package/skills/client-screen/references/red-flags.md +95 -0
- package/skills/cover-letter/SKILL.md +4 -3
- package/skills/cv-tailor/SKILL.md +11 -2
- package/skills/freelance-deliver/SKILL.md +90 -0
- package/skills/freelance-hunt/SKILL.md +126 -0
- package/skills/freelance-hunt/references/templates/Connects.md +39 -0
- package/skills/freelance-hunt/references/templates/Contrato.md +60 -0
- package/skills/freelance-hunt/references/templates/Propuesta.md +66 -0
- package/skills/freelance-kickoff/SKILL.md +94 -0
- package/skills/freelance-pipeline/SKILL.md +109 -0
- package/skills/freelance-pipeline/scripts/import_history.py +425 -0
- package/skills/freelance-profile/SKILL.md +93 -0
- package/skills/freelance-proposal/SKILL.md +112 -0
- package/skills/freelance-scan/SKILL.md +196 -0
- package/skills/freelance-scan/references/dedup.md +35 -0
- package/skills/freelance-scan/references/platforms.md +167 -0
- package/skills/freelance-scan/scripts/freelance_search.py +1077 -0
- package/skills/interview-prep/SKILL.md +11 -3
- package/skills/job-aplicar/SKILL.md +5 -1
- package/skills/job-hunt/SKILL.md +17 -0
- package/skills/job-outcome/SKILL.md +15 -1
- package/skills/job-upskill/SKILL.md +10 -2
- package/skills/middleware-integration/SKILL.md +186 -0
- package/skills/middleware-integration/references/anti-patterns.md +69 -0
- package/skills/midi-compose/SKILL.md +136 -0
- package/skills/midi-compose/references/smf-format.md +129 -0
- package/skills/midi-compose/scripts/compose_midi.py +326 -0
- package/skills/midi-compose/templates/example-composition.json +69 -0
- package/skills/music-composition/SKILL.md +106 -0
- package/skills/music-composition/references/form.md +57 -0
- package/skills/music-composition/references/game-music.md +82 -0
- package/skills/music-composition/references/harmony.md +82 -0
- package/skills/music-composition/references/melody.md +48 -0
- package/skills/music-composition/references/orchestration.md +69 -0
- package/skills/music-composition/references/rhythm-groove.md +49 -0
- package/skills/procedural-audio/SKILL.md +157 -0
- package/skills/procedural-audio/references/anti-patterns.md +45 -0
- package/skills/sfx-design/SKILL.md +130 -0
- package/skills/sfx-design/references/anti-patterns.md +53 -0
- package/skills/spatial-audio/SKILL.md +139 -0
- package/skills/spatial-audio/references/anti-patterns.md +53 -0
- package/skills/ui-sound-design/SKILL.md +133 -0
- package/skills/ui-sound-design/references/anti-patterns.md +57 -0
- package/skills/voice-pipeline/SKILL.md +150 -0
- 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())
|