argo-search 1.0.1 → 2.9.7

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 (463) hide show
  1. package/LICENSE +201 -21
  2. package/README.en.md +822 -0
  3. package/README.es.md +864 -0
  4. package/README.ja.md +864 -0
  5. package/README.ko.md +864 -0
  6. package/README.md +867 -320
  7. package/SKILL.md +135 -0
  8. package/assets/readme/hero.svg +121 -0
  9. package/assets/readme/made-with-beautify.svg +10 -0
  10. package/assets/readme/proof-routes.svg +73 -0
  11. package/assets/readme/settings-keys.png +0 -0
  12. package/assets/readme/settings-tools.png +0 -0
  13. package/assets/readme/why-better.svg +104 -0
  14. package/assets/readme/workflow.svg +83 -0
  15. package/backends/domain_profiles.json +2308 -96
  16. package/backends/engine_registry.yaml +3314 -503
  17. package/backends/query_synonyms_cn.json +35 -0
  18. package/backends/quota_profiles.json +2409 -161
  19. package/backends/source_types_cn.json +83 -0
  20. package/backends/text_lexicon.json +44 -0
  21. package/bin/argo +703 -0
  22. package/bin/argo.js +71 -39
  23. package/config.yaml +4976 -363
  24. package/cordis.patch.yml +21 -0
  25. package/data/countries.json +40 -0
  26. package/data/lang_matrix/lang_capability.json +842 -0
  27. package/data/lang_matrix/matrix_2026-09-10.json +11921 -0
  28. package/data/regions_cn.json +1 -0
  29. package/docs/ADDING_NEW_ENGINE.md +184 -0
  30. package/docs/ARGO_INTRO.md +124 -0
  31. package/docs/DATA_SOURCE_GAPS.md +81 -0
  32. package/docs/DESIGN_LOCAL_DATA_FUSION.md +90 -0
  33. package/docs/ENGINE_CATALOG.md +564 -0
  34. package/docs/MCP_SETUP.md +106 -0
  35. package/docs/PLATFORM_PATHS.md +111 -0
  36. package/docs/RELEASE_NOTES_v2.8.0.md +71 -0
  37. package/docs/RELEASE_NOTES_v2.8.2.md +63 -0
  38. package/docs/RELEASE_NOTES_v2.8.3.md +55 -0
  39. package/docs/RELEASE_NOTES_v2.8.4.md +79 -0
  40. package/docs/RELEASE_NOTES_v2.8.5.md +102 -0
  41. package/docs/RELEASE_NOTES_v2.8.6.md +66 -0
  42. package/docs/RELEASE_NOTES_v2.8.7.md +65 -0
  43. package/docs/RELEASE_NOTES_v2.8.8.md +67 -0
  44. package/docs/RELEASE_NOTES_v2.8.9.md +67 -0
  45. package/docs/RELEASE_NOTES_v2.9.0.md +64 -0
  46. package/docs/SEARCH_ARCHIVE.md +115 -0
  47. package/docs/assets/architecture.svg +104 -0
  48. package/docs/assets/hero.svg +98 -0
  49. package/docs/engines/bangumi.md +27 -0
  50. package/docs/engines/biorxiv.md +27 -0
  51. package/docs/engines/clawhub.md +27 -0
  52. package/docs/engines/crt_sh.md +27 -0
  53. package/docs/engines/datacite.md +27 -0
  54. package/docs/engines/deps_dev.md +27 -0
  55. package/docs/engines/douban_movie.md +27 -0
  56. package/docs/engines/endoflife.md +27 -0
  57. package/docs/engines/flk_law.md +27 -0
  58. package/docs/engines/fxtwitter.md +27 -0
  59. package/docs/engines/google_news.md +27 -0
  60. package/docs/engines/gov_regulations.md +27 -0
  61. package/docs/engines/guqian.md +27 -0
  62. package/docs/engines/hackernews.md +27 -0
  63. package/docs/engines/iplant.md +27 -0
  64. package/docs/engines/keenable.md +27 -0
  65. package/docs/engines/met_museum.md +27 -0
  66. package/docs/engines/nasa_images.md +27 -0
  67. package/docs/engines/netease_music.md +27 -0
  68. package/docs/engines/npm.md +27 -0
  69. package/docs/engines/nvd.md +27 -0
  70. package/docs/engines/openreview.md +27 -0
  71. package/docs/engines/openstd.md +27 -0
  72. package/docs/engines/parallel_free.md +27 -0
  73. package/docs/engines/people_daily.md +27 -0
  74. package/docs/engines/realtime_index.md +27 -0
  75. package/docs/engines/sec_edgar.md +27 -0
  76. package/docs/engines/seltz.md +27 -0
  77. package/docs/engines/skillsmp.md +27 -0
  78. package/docs/engines/sspai.md +27 -0
  79. package/docs/engines/std_samr.md +27 -0
  80. package/docs/engines/train.md +27 -0
  81. package/docs/engines/un_comtrade.md +27 -0
  82. package/docs/engines/wikisource.md +27 -0
  83. package/docs/engines/zdic.md +27 -0
  84. package/docs/engines/zenodo.md +27 -0
  85. package/docs/engines/zhihu_hot_app.md +27 -0
  86. package/docs//344/270/272/344/273/200/344/271/210/351/200/211/346/213/251argo.md +176 -0
  87. package/engines/README.md +10 -0
  88. package/engines/_template_cli.yaml +39 -0
  89. package/engines/_template_http.yaml +37 -0
  90. package/engines/plugins/_template_plugin.py +50 -0
  91. package/engines/specs/_cisa_kev.yaml +54 -0
  92. package/engines/specs/_osv.yaml +63 -0
  93. package/engines/specs/aljazeera.yaml +43 -0
  94. package/engines/specs/antara.yaml +43 -0
  95. package/engines/specs/artic.yaml +20 -0
  96. package/engines/specs/aviation_weather.yaml +39 -0
  97. package/engines/specs/bangumi.yaml +21 -0
  98. package/engines/specs/biorxiv.yaml +24 -0
  99. package/engines/specs/carbon_intensity.yaml +32 -0
  100. package/engines/specs/cinii.yaml +44 -0
  101. package/engines/specs/clawhub.yaml +49 -0
  102. package/engines/specs/cleveland.yaml +19 -0
  103. package/engines/specs/cn_ai_news.yaml +43 -0
  104. package/engines/specs/core.yaml +35 -0
  105. package/engines/specs/crt_sh.yaml +38 -0
  106. package/engines/specs/ct24.yaml +43 -0
  107. package/engines/specs/datacite.yaml +27 -0
  108. package/engines/specs/datagov_uk.yaml +41 -0
  109. package/engines/specs/ddgs_images.yaml +45 -0
  110. package/engines/specs/deepseek_search.yaml +74 -0
  111. package/engines/specs/deezer.yaml +20 -0
  112. package/engines/specs/deps_dev.yaml +21 -0
  113. package/engines/specs/digup.yaml +36 -0
  114. package/engines/specs/doi.yaml +39 -0
  115. package/engines/specs/douban_movie.yaml +22 -0
  116. package/engines/specs/dw_news.yaml +47 -0
  117. package/engines/specs/egov_law.yaml +21 -0
  118. package/engines/specs/elpais.yaml +43 -0
  119. package/engines/specs/endoflife.yaml +21 -0
  120. package/engines/specs/energy_charts.yaml +21 -0
  121. package/engines/specs/factcheck_org.yaml +52 -0
  122. package/engines/specs/faz.yaml +43 -0
  123. package/engines/specs/federal_register.yaml +57 -0
  124. package/engines/specs/firecrawl.yaml +53 -0
  125. package/engines/specs/flk_law.yaml +24 -0
  126. package/engines/specs/folha.yaml +43 -0
  127. package/engines/specs/france24.yaml +44 -0
  128. package/engines/specs/full_fact.yaml +47 -0
  129. package/engines/specs/fxtwitter.yaml +31 -0
  130. package/engines/specs/gbfs_nyc.yaml +32 -0
  131. package/engines/specs/gdacs.yaml +21 -0
  132. package/engines/specs/gdelt.yaml +64 -0
  133. package/engines/specs/google_news.yaml +22 -0
  134. package/engines/specs/gov_regulations.yaml +38 -0
  135. package/engines/specs/guardian_rss.yaml +59 -0
  136. package/engines/specs/hurriyet.yaml +43 -0
  137. package/engines/specs/iplant.yaml +25 -0
  138. package/engines/specs/jikan.yaml +24 -0
  139. package/engines/specs/jolpica.yaml +20 -0
  140. package/engines/specs/k10plus.yaml +20 -0
  141. package/engines/specs/keenable.yaml +44 -0
  142. package/engines/specs/lefigaro.yaml +43 -0
  143. package/engines/specs/listenbrainz.yaml +19 -0
  144. package/engines/specs/matichon.yaml +43 -0
  145. package/engines/specs/met_museum.yaml +21 -0
  146. package/engines/specs/nasa_images.yaml +45 -0
  147. package/engines/specs/netease_music.yaml +38 -0
  148. package/engines/specs/nhk.yaml +43 -0
  149. package/engines/specs/nhtsa_vpic.yaml +20 -0
  150. package/engines/specs/noaa_swpc.yaml +20 -0
  151. package/engines/specs/nvd.yaml +38 -0
  152. package/engines/specs/obis.yaml +20 -0
  153. package/engines/specs/opencitations.yaml +52 -0
  154. package/engines/specs/openf1.yaml +21 -0
  155. package/engines/specs/openfoodfacts.yaml +45 -0
  156. package/engines/specs/openligadb.yaml +20 -0
  157. package/engines/specs/openreview.yaml +37 -0
  158. package/engines/specs/openstd.yaml +21 -0
  159. package/engines/specs/people_daily.yaml +22 -0
  160. package/engines/specs/realtime_index.yaml +34 -0
  161. package/engines/specs/ror.yaml +20 -0
  162. package/engines/specs/satnogs.yaml +20 -0
  163. package/engines/specs/sec_edgar.yaml +46 -0
  164. package/engines/specs/skillsmp.yaml +50 -0
  165. package/engines/specs/sogou_weixin_browser.yaml +30 -0
  166. package/engines/specs/soilgrids.yaml +25 -0
  167. package/engines/specs/sspai.yaml +36 -0
  168. package/engines/specs/stackexchange.yaml +46 -0
  169. package/engines/specs/std_samr.yaml +21 -0
  170. package/engines/specs/tass.yaml +43 -0
  171. package/engines/specs/tle_mirror.yaml +21 -0
  172. package/engines/specs/train.yaml +37 -0
  173. package/engines/specs/tvmaze.yaml +20 -0
  174. package/engines/specs/twitter_syndication.yaml +31 -0
  175. package/engines/specs/un_comtrade.yaml +23 -0
  176. package/engines/specs/unpaywall.yaml +54 -0
  177. package/engines/specs/vnpress.yaml +43 -0
  178. package/engines/specs/weather.yaml +32 -0
  179. package/engines/specs/who_don.yaml +21 -0
  180. package/engines/specs/who_gho.yaml +20 -0
  181. package/engines/specs/wikimedia_commons.yaml +34 -0
  182. package/engines/specs/wikisource.yaml +22 -0
  183. package/engines/specs/worms.yaml +19 -0
  184. package/engines/specs/x_trending.yaml +34 -0
  185. package/engines/specs/yna.yaml +43 -0
  186. package/engines/specs/ynet.yaml +43 -0
  187. package/engines/specs/zbmath.yaml +40 -0
  188. package/engines/specs/zdic.yaml +22 -0
  189. package/engines/specs/zenodo.yaml +27 -0
  190. package/engines/specs/zhihu_hot_app.yaml +35 -0
  191. package/mcp/clients.yaml +74 -0
  192. package/package.json +51 -12
  193. package/packages/dsh-plugin/README.md +134 -0
  194. package/packages/dsh-plugin/cordis.patch.yml +48 -0
  195. package/packages/dsh-plugin/dsh/client.js +1244 -0
  196. package/packages/dsh-plugin/dsh/index.js +2290 -0
  197. package/packages/dsh-plugin/dsh/native-tools.mjs +743 -0
  198. package/packages/dsh-plugin/package.json +38 -0
  199. package/packages/dsh-plugin/test/client.test.js +232 -0
  200. package/packages/dsh-plugin/test/config.test.js +30 -0
  201. package/packages/dsh-plugin/test/index.test.js +1338 -0
  202. package/references/query-templates.md +96 -0
  203. package/references/query-templates.yaml +118 -0
  204. package/scripts/ab_eval.py +219 -0
  205. package/scripts/ab_eval_p0p1.py +266 -0
  206. package/scripts/adaptive.py +271 -70
  207. package/scripts/answer.py +154 -0
  208. package/scripts/archive_run.py +591 -0
  209. package/scripts/argo_engine_registry.py +172 -0
  210. package/scripts/argo_paths.py +728 -0
  211. package/scripts/article.py +136 -0
  212. package/scripts/arxiv_source.py +201 -0
  213. package/scripts/badcase_ledger.py +252 -0
  214. package/scripts/batch_probe.py +241 -0
  215. package/scripts/benchmark.py +19 -0
  216. package/scripts/bounded_run.py +144 -0
  217. package/scripts/browser_auth.py +323 -0
  218. package/scripts/burstiness_detector.py +147 -0
  219. package/scripts/cache.py +1078 -67
  220. package/scripts/cache_guard.py +271 -0
  221. package/scripts/cache_key_vdom.py +56 -0
  222. package/scripts/cache_similarity.py +170 -0
  223. package/scripts/caliber_isolation.py +195 -0
  224. package/scripts/calibrate_lowquality.py +210 -0
  225. package/scripts/candidate_envelope.py +366 -0
  226. package/scripts/chrome_cdp.py +871 -0
  227. package/scripts/circuit_breaker.py +389 -0
  228. package/scripts/citations.py +334 -0
  229. package/scripts/cite_cli.py +59 -0
  230. package/scripts/cjk_tokens.py +95 -0
  231. package/scripts/clarify.py +47 -24
  232. package/scripts/cli_io.py +240 -0
  233. package/scripts/config.py +683 -45
  234. package/scripts/config_cache.py +402 -0
  235. package/scripts/conn_pool.py +220 -0
  236. package/scripts/content_security.py +685 -0
  237. package/scripts/content_signals.py +730 -0
  238. package/scripts/crawl.py +74 -35
  239. package/scripts/daemon.py +866 -0
  240. package/scripts/digup_bridge.py +214 -0
  241. package/scripts/engine_admission.py +366 -0
  242. package/scripts/engine_dispatch.py +1019 -0
  243. package/scripts/engine_env.py +534 -0
  244. package/scripts/engine_failure.py +411 -0
  245. package/scripts/engine_families.py +732 -0
  246. package/scripts/engine_policy.py +218 -0
  247. package/scripts/engine_requires.py +190 -0
  248. package/scripts/engine_status.py +322 -0
  249. package/scripts/engine_validate.py +638 -0
  250. package/scripts/engines.py +778 -294
  251. package/scripts/engines_base.py +1521 -0
  252. package/scripts/engines_builders.py +298 -0
  253. package/scripts/engines_builders_batch10.py +159 -0
  254. package/scripts/engines_builders_batch11.py +174 -0
  255. package/scripts/engines_builders_batch12.py +103 -0
  256. package/scripts/engines_builders_batch13.py +340 -0
  257. package/scripts/engines_builders_batch9.py +1468 -0
  258. package/scripts/engines_builders_cn.py +2283 -0
  259. package/scripts/engines_builders_data.py +2923 -0
  260. package/scripts/engines_builders_data_macro.py +775 -0
  261. package/scripts/engines_builders_feeds.py +255 -0
  262. package/scripts/engines_builders_intl.py +1239 -0
  263. package/scripts/engines_builders_search.py +440 -0
  264. package/scripts/engines_builders_tech.py +1360 -0
  265. package/scripts/evidence.py +588 -162
  266. package/scripts/evidence_loop.py +592 -0
  267. package/scripts/evidence_tier.py +298 -0
  268. package/scripts/except_sets.py +25 -0
  269. package/scripts/extract.py +55 -16
  270. package/scripts/fact_align.py +207 -0
  271. package/scripts/fact_crosscheck.py +230 -0
  272. package/scripts/fetch.py +25 -12
  273. package/scripts/fetch_browser.py +87 -0
  274. package/scripts/fetch_jsrun.py +122 -0
  275. package/scripts/fetch_quality.py +225 -0
  276. package/scripts/fetch_render_tinyfish.py +117 -0
  277. package/scripts/fetch_v3.py +1779 -0
  278. package/scripts/focus_extract.py +350 -0
  279. package/scripts/fulltext_store.py +169 -0
  280. package/scripts/fxtwitter_api.py +325 -0
  281. package/scripts/gen_engine_catalog.py +364 -0
  282. package/scripts/gen_native_tools.py +91 -0
  283. package/scripts/gen_routing_snapshot.py +184 -0
  284. package/scripts/health_check.py +140 -402
  285. package/scripts/health_probe.py +23 -8
  286. package/scripts/hot_state.py +134 -0
  287. package/scripts/http_client.py +1053 -0
  288. package/scripts/image_ops.py +430 -0
  289. package/scripts/image_vision/vision_probe +0 -0
  290. package/scripts/image_vision/vision_probe.swift +126 -0
  291. package/scripts/install.ps1 +148 -0
  292. package/scripts/install.sh +206 -0
  293. package/scripts/job.py +1186 -0
  294. package/scripts/jump_resolver.py +235 -0
  295. package/scripts/lang_capability.py +150 -0
  296. package/scripts/lang_detect.py +289 -0
  297. package/scripts/lang_matrix_probe.py +329 -0
  298. package/scripts/lang_pref.py +311 -0
  299. package/scripts/link_source.py +340 -0
  300. package/scripts/local_engines.py +321 -0
  301. package/scripts/local_image.py +970 -0
  302. package/scripts/local_seek.py +366 -0
  303. package/scripts/macro_countries.py +82 -0
  304. package/scripts/matrix_search_eval.py +1105 -0
  305. package/scripts/mcp_diag.py +8 -3
  306. package/scripts/mcp_handlers.py +1074 -0
  307. package/scripts/mcp_handlers_surface.py +157 -0
  308. package/scripts/mcp_launch.sh +122 -0
  309. package/scripts/mcp_server.py +118 -417
  310. package/scripts/mcp_setup.py +407 -0
  311. package/scripts/mcp_tools.py +330 -0
  312. package/scripts/mcp_transport.py +191 -0
  313. package/scripts/min_version_scan.py +335 -0
  314. package/scripts/net_proxy.py +258 -0
  315. package/scripts/network_aware.py +152 -0
  316. package/scripts/paper.py +274 -0
  317. package/scripts/pdf_extract.py +518 -0
  318. package/scripts/phrase_filter.py +66 -0
  319. package/scripts/plan.py +422 -0
  320. package/scripts/postprocess_signals.py +150 -0
  321. package/scripts/pre-commit +48 -0
  322. package/scripts/query_enhance.py +157 -0
  323. package/scripts/query_operators.py +196 -0
  324. package/scripts/query_rewriter.py +116 -7
  325. package/scripts/query_signals.py +300 -0
  326. package/scripts/query_templates.py +167 -0
  327. package/scripts/query_understanding.py +411 -0
  328. package/scripts/query_variants.py +298 -0
  329. package/scripts/quota.py +488 -76
  330. package/scripts/rank_signals.py +775 -0
  331. package/scripts/ranking_eval.py +319 -0
  332. package/scripts/readability_extract.py +433 -0
  333. package/scripts/recompute.py +415 -0
  334. package/scripts/recovery.py +616 -0
  335. package/scripts/recovery_l0.py +160 -0
  336. package/scripts/redskill/redskill_engine.py +332 -0
  337. package/scripts/regression_p0p1.py +318 -0
  338. package/scripts/replay_eval.py +640 -0
  339. package/scripts/rerank_tfidf.py +225 -0
  340. package/scripts/research.py +591 -403
  341. package/scripts/research_cli.py +361 -0
  342. package/scripts/research_dossier.py +492 -0
  343. package/scripts/research_expand.py +146 -0
  344. package/scripts/research_gates.py +272 -0
  345. package/scripts/research_report.py +209 -0
  346. package/scripts/research_strategy.py +37 -0
  347. package/scripts/research_work_packages.py +195 -0
  348. package/scripts/result_lang.py +311 -0
  349. package/scripts/robots_guard.py +259 -0
  350. package/scripts/route.py +921 -254
  351. package/scripts/route_bangs.py +40 -0
  352. package/scripts/route_cache.py +225 -0
  353. package/scripts/route_combo.py +910 -0
  354. package/scripts/route_domains.py +328 -0
  355. package/scripts/route_lang.py +605 -0
  356. package/scripts/route_log.py +63 -0
  357. package/scripts/route_policy.py +250 -0
  358. package/scripts/rrf_classic.py +66 -0
  359. package/scripts/screenshot.py +73 -0
  360. package/scripts/search.py +897 -405
  361. package/scripts/search_attribution.py +88 -0
  362. package/scripts/search_benchmark.py +407 -0
  363. package/scripts/search_cli.py +627 -0
  364. package/scripts/search_entry.py +389 -0
  365. package/scripts/search_output.py +606 -0
  366. package/scripts/search_pipeline.py +1077 -0
  367. package/scripts/search_rank.py +991 -0
  368. package/scripts/search_types.py +27 -12
  369. package/scripts/seek_locator.py +68 -0
  370. package/scripts/semantic_evidence.py +215 -0
  371. package/scripts/serp_guard.py +228 -0
  372. package/scripts/serp_kill_log.py +51 -0
  373. package/scripts/single_flight.py +120 -0
  374. package/scripts/social_engines/__init__.py +10 -0
  375. package/scripts/social_engines/bilibili_engine.py +131 -0
  376. package/scripts/social_engines/hackernews_engine.py +113 -0
  377. package/scripts/social_engines/reddit_engine.py +172 -0
  378. package/scripts/social_engines/twitter_engine.py +397 -0
  379. package/scripts/social_engines/v2ex_engine.py +101 -0
  380. package/scripts/social_engines/weibo_engine.py +128 -0
  381. package/scripts/social_engines/xiaohongshu_engine.py +137 -0
  382. package/scripts/social_engines/zhihu_engine.py +139 -0
  383. package/scripts/social_research.py +201 -0
  384. package/scripts/sogou_weixin_browser.py +318 -0
  385. package/scripts/source_ledger.py +249 -0
  386. package/scripts/stage_timing.py +58 -0
  387. package/scripts/stats_cli.py +95 -0
  388. package/scripts/straggler_policy.py +60 -0
  389. package/scripts/stylometry_detector.py +231 -0
  390. package/scripts/sync_backends.py +353 -0
  391. package/scripts/sync_installs.py +29 -0
  392. package/scripts/template_cli.py +127 -0
  393. package/scripts/text_lexicon.py +72 -0
  394. package/scripts/tfidf_router.py +109 -37
  395. package/scripts/time_utils.py +233 -0
  396. package/scripts/topic_research_profiles.py +643 -0
  397. package/scripts/train.py +737 -0
  398. package/scripts/tweet.py +596 -0
  399. package/scripts/url_canon.py +217 -0
  400. package/scripts/url_optimize.py +56 -0
  401. package/scripts/url_safety.py +236 -0
  402. package/scripts/usage_log.py +116 -0
  403. package/scripts/usage_log_clean.py +113 -0
  404. package/scripts/v2ex_nodes.py +311 -0
  405. package/scripts/variant_recall.py +188 -0
  406. package/scripts/watch.py +205 -0
  407. package/scripts/wechat_article.py +258 -0
  408. package/scripts/wide_research.py +402 -0
  409. package/scripts/wx.py +349 -0
  410. package/scripts/x_article_fetch.py +141 -0
  411. package/scripts/x_search.py +234 -0
  412. package/scripts/x_trending.py +189 -0
  413. package/scripts/yaml_load.py +67 -0
  414. package/sub-skills/ego-search/SKILL.md +253 -0
  415. package/sub-skills/ego-search/references/browser-runtime.md +43 -0
  416. package/sub-skills/ego-search/references/install.md +67 -0
  417. package/sub-skills/ego-search/references/original-ego-upgrade.md +51 -0
  418. package/sub-skills/ego-search/scripts/ego_search.py +800 -0
  419. package/sub-skills/ego-search/scripts/merge.py +226 -0
  420. package/sub-skills/ego-search/scripts/quality.py +64 -0
  421. package/sub-skills/ego-search/scripts/runtime.py +172 -0
  422. package/sub-skills/ego-search/scripts/safety.py +109 -0
  423. package/sub-skills/ego-search/scripts/serp_spec.py +109 -0
  424. package/sub-skills/ego-search/scripts/webbridge_adapter.py +374 -0
  425. package/sub-skills/ego-search/tests/test_query_normalize.py +68 -0
  426. package/sub-skills/js-run/SKILL.md +67 -0
  427. package/sub-skills/js-run/scripts/jsrun.py +112 -0
  428. package/sub-skills/js-run/scripts/shim.js +188 -0
  429. package/sub-skills/js-run/tests/fixtures/challenge_like.js +27 -0
  430. package/sub-skills/js-run/tests/test_jsrun.py +165 -0
  431. package/sub-skills/local-search/SKILL.md +50 -53
  432. package/sub-skills/local-search/bm25.py +162 -0
  433. package/sub-skills/local-search/config.yaml +152 -55
  434. package/sub-skills/local-search/engine_registry.py +91 -26
  435. package/sub-skills/local-search/{health_check.py → local_health_check.py} +140 -10
  436. package/sub-skills/local-search/local_search_adapter.py +13 -2
  437. package/sub-skills/local-search/parse_maps.yaml +40 -42
  438. package/sub-skills/local-search/search_v3.py +1182 -65
  439. package/sub-skills/local-search/smart_router.py +47 -7
  440. package/sub-skills/local-seek/SKILL.md +110 -0
  441. package/sub-skills/local-seek/config/domains.yaml +20 -0
  442. package/sub-skills/local-seek/references/auto-behaviors.md +24 -0
  443. package/sub-skills/local-seek/references/fd-mdfind-recipes.md +62 -0
  444. package/sub-skills/local-seek/references/git-integration.md +54 -0
  445. package/sub-skills/local-seek/references/remote-handoff.md +46 -0
  446. package/sub-skills/local-seek/references/rg-recipes.md +96 -0
  447. package/sub-skills/local-seek/references/strategies.md +52 -0
  448. package/sub-skills/local-seek/references/structural-search.md +59 -0
  449. package/sub-skills/local-seek/scripts/eval_rank.py +216 -0
  450. package/sub-skills/local-seek/scripts/eval_seek.py +224 -0
  451. package/sub-skills/local-seek/scripts/seek.py +1465 -0
  452. package/sub-skills/local-seek/scripts/seek_frecency.py +126 -0
  453. package/sub-skills/local-seek/scripts/seek_fts.py +430 -0
  454. package/sub-skills/local-seek/scripts/seek_rank.py +193 -0
  455. package/sub-skills/local-seek/tests/test_content_rank.py +222 -0
  456. package/sub-skills/local-seek/tests/test_determinism_20261006.py +145 -0
  457. package/sub-skills/local-seek/tests/test_noise_tier.py +101 -0
  458. package/sub-skills/local-seek/tests/test_pcre2_probe.py +136 -0
  459. package/sub-skills/local-seek/tests/test_rank_pinyin.py +77 -0
  460. package/sub-skills/local-seek/tests/test_regression_20261003.py +169 -0
  461. package/sub-skills/local-seek/tests/test_regression_20261006.py +146 -0
  462. package/sub-skills/local-seek/tests/test_seek_degradation.py +111 -0
  463. package/sub-skills/local-seek/tests/test_seek_fts.py +313 -0
package/scripts/job.py ADDED
@@ -0,0 +1,1186 @@
1
+ #!/usr/bin/env python3
2
+ """job.py — 求职岗位搜索 v2:职位 + 地区 → 该地区在招岗位。
3
+
4
+ 用法:
5
+ argo job "工艺工程师" --city 成都 # 严格:只留成都相关岗位
6
+ argo job "工艺工程师" --city 新加坡 --all # 海外(国家/城市词展开 + 自动启用免 key 国际源)
7
+ argo job "会计" --city 苏州 --loose # 宽松:异地保留但命中置顶
8
+ argo job "焊工" --city 上海 --json
9
+ argo job "remote engineer" --engine free # 仅免 key 国际源(远程岗位)
10
+
11
+ 第一性原理:求职搜索 = 检索(招聘平台) + 判定(地区) + 呈现(去重排序+时效标记)。
12
+ 本脚本只做这三件事,其余一律不做。
13
+
14
+ v2 相对 v1 的修复与扩展(2026-08-11,全部经实测验证):
15
+ 1. 判定升级:三级判定(标题/URL 强命中 → 摘要头部 → 摘要尾部),
16
+ —— 修复 v1「snippet 任意位置含地区词即命中」被公司简介/福利文本污染的假阳性
17
+ (实测 byted 昆山查询 dropped=0 但混入四川大学/南通大学/企查查异地岗位)
18
+ 2. 白名单扩展:核心 6 平台 + 扩展域(卓博/鱼泡/中华英才/智通/58/国聘/24365/
19
+ 校园就业联盟/应届生求职网/北京高校毕业生/JobsDB/JobStreet)+ 人社局源
20
+ 3. URL 后置校验:非白名单 URL 一律剔除(修复 byted site: 混入非白名单域名)
21
+ 4. 时效补强:tavily days=90(实测生效)、日期字段提取、>365 天标记过期
22
+ 5. 免 key 国际源:remotive / himalayas / jobicy / arbeitnow / greenhouse / ashby
23
+ 6. 国内后端 bocha / octen 全量启用
24
+
25
+ v3(2026-08-11):
26
+ 7. 结构化字段:snippet 提取薪资/学历/经验/公司(零成本),--fetch N 可选详情页补全
27
+ 8. 增量监控:--watch 存快照至 data/jobs/,二次运行对比输出新上线/已下架
28
+ 9. 指纹去重:标题+公司指纹替代纯 URL 去重(多平台同岗位合并)
29
+ 10. 白名单加北京高校毕业生就业信息网(bysjy.com.cn,实测 200)
30
+
31
+ v4(2026-08-11):数据源扩容(一/二/四类,全部实测可用)
32
+ 11. 免 key ATS 直连源:Lever / Recruitee / RemoteOK(实测 200 可用)。
33
+ SmartRecruiters、WorkingNomads 匿名接口 2026-08 实测已失效
34
+ (返回空 content / 403 需鉴权),归入付费档不接入
35
+ 12. 聚合源:SimplifyJobs(GitHub 每日更新 HTML 表格,SWE/PM/DS 岗);
36
+ JobSpy 可选后端(LinkedIn/Indeed/ZipRecruiter,Python 3.10+,
37
+ pip install 'git+https://github.com/speedyapply/JobSpy.git',PyPI 同名是假包);
38
+ mcp-jobs 可选后端(猎聘/BOSS/智联,首次自动 npm install 到
39
+ ~/.cache/argo-mcpjobs 并 node dist/mcp.js 启动,MCP stdio 协议;
40
+ npm 包无 bin 入口,README 的 npx 方式不可用)
41
+ 13. 白名单扩展:军队人才网(81rc.81.cn)、高校人才网(gaoxiaojob.com)、
42
+ 教育部直属单位公招(jybzp.chsi.com.cn)、人社部事业单位(mohrss.gov.cn)
43
+ 14. trusted 机制:直连源 URL 跳过白名单后置校验(LinkedIn/Simplify 等
44
+ 域名不放行全局白名单,仅放行直连结果)
45
+ """
46
+ import argparse, json, os, re, subprocess, sys, threading, time, urllib.request
47
+ from datetime import date, datetime, timedelta, timezone
48
+ from pathlib import Path
49
+ from urllib.parse import quote
50
+ from typing import Optional
51
+ from engine_env import get_env # 密钥别名唯一真源(漏 import 曾令全部后端 NameError)
52
+ from net_proxy import open_url # 出口调度唯一入口(issue #13 同类修复)
53
+ from cli_io import dumps
54
+
55
+ # ── 平台白名单 ──────────────────────────────────────────────────────────
56
+ # 核心平台(逐平台 site: 查询用,带标签)
57
+ PLATFORMS = [("zhipin.com", "BOSS直聘"), ("liepin.com", "猎聘"),
58
+ ("zhaopin.com", "智联招聘"), ("51job.com", "前程无忧"),
59
+ ("597.com", "597直聘"), ("jrzp.com", "今日招聘")]
60
+ # 扩展域(includeDomains 聚合查询用,bocha/octen 宽查询校验用)
61
+ EXTRA = [("jobcn.com", "卓博人才网"), ("yupao.com", "鱼泡直聘"), ("chinahr.com", "中华英才网"),
62
+ ("job5156.com", "智通人才网"), ("58.com", "58同城"), ("iguopin.com", "国聘"),
63
+ ("ncss.cn", "新职业24365"), ("91job.org.cn", "校园就业联盟"),
64
+ ("yingjiesheng.com", "应届生求职网"), ("bysjy.com.cn", "北京高校毕业生就业信息网"),
65
+ ("jobsdb.com", "JobsDB"), ("jobstreet.com", "JobStreet"),
66
+ ("hrss.suzhou.gov.cn", "苏州人社局"),
67
+ ("81rc.81.cn", "军队人才网"), ("gaoxiaojob.com", "高校人才网"),
68
+ ("jybzp.chsi.com.cn", "教育部直属单位公招"), ("mohrss.gov.cn", "人社部事业单位招聘")]
69
+ DOMAINS = [d for d, _ in PLATFORMS] + [d for d, _ in EXTRA]
70
+ ALL_LABELS = dict(PLATFORMS + EXTRA)
71
+ # 免 key 国际源域名(自返回 URL,天然可信)
72
+ FREE_DOMAINS = [("remotive.com", "Remotive"), ("remote.co", "Remote.co"),
73
+ ("himalayas.app", "Himalayas"), ("jobicy.com", "Jobicy"),
74
+ ("arbeitnow.com", "Arbeitnow"),
75
+ ("boards-api.greenhouse.io", "Greenhouse"),
76
+ ("boards.greenhouse.io", "Greenhouse"),
77
+ ("jobs.ashbyhq.com", "Ashby"),
78
+ ("jobs.lever.co", "Lever"), ("api.lever.co", "Lever"),
79
+ ("remoteok.com", "RemoteOK")]
80
+ # 政府人社局域名(地区判定强信任:政府站岗位无异地混淆问题)
81
+ GOV_RE = re.compile(r"(hrss|rsj|rlsbj|srs)\.[\w-]+\.gov\.cn")
82
+ GOV_LABEL = "人社局/政府"
83
+
84
+ DATA_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.realpath(__file__))), "data")
85
+ STALE_DAYS = 365 # 距今超过该天数的岗位标记过期
86
+
87
+ # ── 地区索引:查询词 → 展开词集(一次性构建,O(1) 查表)────────────────
88
+
89
+ _INDEX = None
90
+ _COUNTRIES = None
91
+
92
+
93
+ def _clean(name: str) -> str:
94
+ """去行政后缀:北京市→北京、内蒙古自治区→内蒙古、金平苗族瑶族傣族自治县→金平。"""
95
+ name = re.sub(r"(维吾尔自治区|壮族自治区|回族自治区|藏族自治区|蒙古族自治区"
96
+ r"|特别行政区|自治区|自治州|自治县|自治旗|地区|盟|省|市|区|县|旗|州)$",
97
+ "", name)
98
+ m = re.search(r"(?:蒙古|维吾尔|哈萨克|柯尔克孜|塔吉克|乌孜别克|俄罗斯|鄂温克|"
99
+ r"达斡尔|鄂伦春|赫哲|裕固|东乡|保安|撒拉|土族|仡佬|毛南|仫佬|"
100
+ r"布依|侗|瑶|苗|傣|彝|壮|回|藏|满|白|土家|哈尼|黎|傈僳|佤|畲|"
101
+ r"高山|拉祜|水|纳西|景颇|羌|布朗|锡伯|阿昌|普米|怒|德昂|京|"
102
+ r"塔塔尔|独龙|门巴|珞巴|基诺|朝鲜)族", name)
103
+ if m:
104
+ name = name[:m.start()]
105
+ return name
106
+
107
+
108
+ def _build_index() -> dict:
109
+ """省→全省词集;市→该市+区县+省;区县→本身+市+省;海外国家→城市、城市→国家。"""
110
+ idx = {}
111
+ with open(os.path.join(DATA_DIR, "regions_cn.json"), encoding="utf-8") as f:
112
+ regions = json.load(f)
113
+ for prov in regions:
114
+ p = _clean(prov["name"])
115
+ pwords = {p}
116
+ for c in prov.get("children", []):
117
+ c2 = _clean(c["name"])
118
+ cwords = {c2, p} | {_clean(d["name"]) for d in c.get("children", [])}
119
+ idx.setdefault(c2, set()).update(cwords)
120
+ pwords.update(cwords)
121
+ for d in c.get("children", []):
122
+ d2 = _clean(d["name"])
123
+ idx.setdefault(d2, set()).update({d2, c2, p})
124
+ idx[p] = pwords # 省级覆盖全省
125
+ with open(os.path.join(DATA_DIR, "countries.json"), encoding="utf-8") as f:
126
+ countries = json.load(f)
127
+ for cname, info in countries.items():
128
+ for cty in info["cities"]:
129
+ idx.setdefault(cty, set()).update({cty, cname})
130
+ idx.setdefault(cname, set()).update(info["cities"])
131
+ return idx
132
+
133
+
134
+ def _load_countries() -> dict:
135
+ global _COUNTRIES
136
+ if _COUNTRIES is None:
137
+ with open(os.path.join(DATA_DIR, "countries.json"), encoding="utf-8") as f:
138
+ _COUNTRIES = json.load(f)
139
+ return _COUNTRIES
140
+
141
+
142
+ def region_words(city: str) -> list:
143
+ global _INDEX
144
+ if _INDEX is None:
145
+ _INDEX = _build_index()
146
+ words = set()
147
+ for c in city.split():
148
+ words.update(_INDEX.get(c, {c}))
149
+ # 单字词(如「东区」→「东」)易误判其他地名,过滤
150
+ return [w for w in words if len(w) >= 2]
151
+
152
+
153
+ def is_overseas(city: str) -> bool:
154
+ """city 是否命中海外国家/城市词表(用于自动启用免 key 国际源)。"""
155
+ cdata = _load_countries()
156
+ for c in city.split():
157
+ if c in cdata or any(c in info["cities"] for info in cdata.values()):
158
+ return True
159
+ return False
160
+
161
+
162
+ # 资讯/攻略页特征(非岗位页,strict 模式剔除)
163
+ INFO_RE = re.compile(r"工资待遇|就业前景|如何入行|怎么样|薪资待遇|工资多少|工资高吗|"
164
+ r"(多少钱|什么水平|行情|趋势|新闻|资讯|百科|怎么样\?|盘点|一览|指南)")
165
+ # 岗位已关闭/已下线特征(BOSS/智联等平台在页面内标注)
166
+ CLOSED_RE = re.compile(r"职位已关闭|已下线|已停招|招聘已结束|暂停招聘|已下架|停止招聘")
167
+
168
+ # ── 判定:三级命中 ──────────────────────────────────────────────────────
169
+ # L1 强命中:标题/URL 含地区词,或政府源域名
170
+ # L2 中命中:摘要前 120 字符含地区词(摘要头部一般是职位信息块)
171
+ # L3 弱命中:摘要其余位置含地区词(可能是公司简介/福利文本,strict 剔除)
172
+ # 0 未命中
173
+
174
+
175
+ def judge(item: dict, words: list) -> tuple:
176
+ """返回 (level, reason)。words 为空时全部 L1(无地区约束)。
177
+
178
+ 政府源(hrss/rsj 人社局):title 含词 → L1;否则按常规 title/snippet 判定,
179
+ 但 snippet 命中只给 L2 不降 L3(政府站信息价值高,避免被严格模式误剔)。
180
+ """
181
+ if not words:
182
+ return 1, "无地区约束"
183
+ url = item.get("url", "")
184
+ title = (item.get("title", "") or "").lower()
185
+ snippet = (item.get("snippet", "") or "").lower()
186
+ words = [w.lower() for w in words]
187
+ is_gov = bool(GOV_RE.search(url))
188
+ tl = any(w in title for w in words)
189
+ sh = any(w in snippet[:120] for w in words)
190
+ st = any(w in snippet[120:] for w in words)
191
+ if tl:
192
+ return 1, "标题命中"
193
+ if sh:
194
+ return 2, "摘要头部命中"
195
+ if st and not is_gov:
196
+ return 3, "摘要尾部命中"
197
+ if is_gov:
198
+ return 2, "政府源"
199
+ return 0, "未命中"
200
+
201
+
202
+ # ── 时效:日期提取 + 过期标记 ───────────────────────────────────────────
203
+
204
+ _DATE_RE = re.compile(r"(20\d{2})[-年./](\d{1,2})[-月./](\d{1,2})")
205
+
206
+
207
+ def extract_date(item: dict) -> str:
208
+ """从 date 字段 / snippet 提取 YYYY-MM-DD;无则空串。"""
209
+ for key in ("date", "publishedDate", "published_date", "publication_date",
210
+ "created_at", "dateLastCrawled", "datePublished"):
211
+ v = item.get(key)
212
+ m = _DATE_RE.search(str(v or ""))
213
+ if m:
214
+ return f"{m.group(1)}-{int(m.group(2)):02d}-{int(m.group(3)):02d}"
215
+ m = _DATE_RE.search((item.get("snippet") or "")[:200])
216
+ if m:
217
+ return f"{m.group(1)}-{int(m.group(2)):02d}-{int(m.group(3)):02d}"
218
+ return ""
219
+
220
+
221
+ def is_stale(dstr: str) -> bool:
222
+ if not dstr:
223
+ return False
224
+ try:
225
+ d = date.fromisoformat(dstr)
226
+ return (date.today() - d).days > STALE_DAYS
227
+ except ValueError:
228
+ return False
229
+
230
+
231
+ # ── 结构化字段:薪资/学历/经验/公司(snippet 提取,零成本)──────────────
232
+
233
+ _SALARY_RE = re.compile(
234
+ r"\d+(?:\.\d+)?\s*[-~至到]\s*\d+(?:\.\d+)?\s*(?:[kK万]|元)" # 区间优先
235
+ r"|\d+(?:\.\d+)?\s*[kK万]"
236
+ r"|\d{4,6}\s*元"
237
+ r"|\d+\s*[-~至到]\s*\d+\s*元")
238
+ _EDU_RE = re.compile(r"(硕士|博士|本科|大专|中专|高中|初中|学历不限)")
239
+ _EXP_RE = re.compile(r"(经验不限|应届生|\d+\s*[-~至到]\s*\d+\s*年|\d+\s*年(?:以上|及)?经验|\d+\s*年经验)")
240
+ _COMPANY_RE = re.compile(
241
+ r"([\u4e00-\u9fa5A-Za-z0-9()()]+?(?:股份有限公司|有限责任公司|有限公司|集团|股份|科技|电子|"
242
+ r"精密|机械|实业|咨询|事务所|工作室|医院|学校|大学|研究院|中心|厂|科技股份|半导体))")
243
+
244
+
245
+ def parse_fields(item: dict) -> dict:
246
+ """从 title + snippet 提取结构化字段(详情页不可达时的零成本方案)。"""
247
+ hay = (item.get("title") or "") + " " + (item.get("snippet") or "")[:300]
248
+ fields = {}
249
+ m = _SALARY_RE.search(hay)
250
+ if m:
251
+ fields["salary"] = m.group(0).strip()
252
+ m = _EDU_RE.search(hay)
253
+ if m:
254
+ fields["education"] = m.group(1)
255
+ m = _EXP_RE.search(hay)
256
+ if m:
257
+ fields["experience"] = m.group(1).strip()
258
+ m = _COMPANY_RE.search(item.get("title") or "")
259
+ if m:
260
+ fields["company"] = m.group(1).strip()
261
+ return fields
262
+
263
+
264
+ # ── 指纹去重:标题+公司(多平台同岗位合并)─────────────────────────────
265
+
266
+ _FP_SALARY = re.compile(
267
+ r"\d+(?:\.\d+)?\s*[kK万元¥¥]|\d{4,6}\s*元|\d+\s*[-~至到]\s*\d+\s*(?:[kK万元¥¥]|元)")
268
+ _FP_SPACE = re.compile(r"\s+")
269
+
270
+
271
+ def fingerprint(item: dict) -> str:
272
+ """规范化标题(去薪资/空白/平台标注)+ 公司名 → 指纹。"""
273
+ t = item.get("title") or ""
274
+ t = _FP_SALARY.sub("", t)
275
+ t = _FP_SPACE.sub("", t)
276
+ t = re.sub(r"[//]月", "", t) # 去「9000-14000元/月」残留
277
+ t = re.sub(r"【[^】]*】|「[^」]*」", "", t) # 去平台标注
278
+ company = item.get("company") or item.get("fields", {}).get("company", "") or ""
279
+ return f"{t}|{company}"
280
+
281
+
282
+ # ── 增量监控快照(data/jobs/)───────────────────────────────────────────
283
+
284
+ JOBS_DIR = os.path.join(DATA_DIR, "jobs")
285
+
286
+
287
+ def snapshot_path(query: str, city: str) -> str:
288
+ import hashlib
289
+ h = hashlib.md5(f"{query}|{city}".encode()).hexdigest()[:12]
290
+ return os.path.join(JOBS_DIR, f"{h}.json")
291
+
292
+
293
+ def load_snapshot(path: str) -> Optional[dict]:
294
+ if not os.path.exists(path):
295
+ return None
296
+ try:
297
+ with open(path, encoding="utf-8") as f:
298
+ return json.load(f)
299
+ except Exception:
300
+ return None
301
+
302
+
303
+ def save_snapshot(path: str, payload: dict) -> None:
304
+ """写快照(原子写走 argo_paths 唯一来源)。
305
+
306
+ 旧实现用固定的 `<path>.tmp`:两个 `argo job --watch` 并行时,
307
+ 先完成的进程会把后者的 tmp 一并 replace 走,后者再 replace 抛
308
+ FileNotFoundError,快照直接丢失。
309
+ """
310
+ os.makedirs(JOBS_DIR, exist_ok=True)
311
+ import argo_paths
312
+ argo_paths.atomic_write_json(Path(path), payload, indent=1)
313
+
314
+
315
+ # ── HTTP 基础 ───────────────────────────────────────────────────────────
316
+
317
+
318
+ def _post(url: str, body: dict, headers: Optional[dict] = None,
319
+ timeout: int = 20) -> dict:
320
+ req = urllib.request.Request(url, data=json.dumps(body).encode("utf-8"),
321
+ headers={"Content-Type": "application/json",
322
+ **(headers or {})})
323
+ with open_url(req, timeout=timeout) as r:
324
+ return json.loads(r.read().decode("utf-8"))
325
+
326
+
327
+ def _get(url: str, timeout: int = 20) -> dict:
328
+ req = urllib.request.Request(url, headers={
329
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
330
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36"})
331
+ with open_url(req, timeout=timeout) as r:
332
+ return json.loads(r.read().decode("utf-8"))
333
+
334
+
335
+ def _norm(item: dict) -> dict:
336
+ """统一结果项字段,并做白名单后置校验(platform 空 → 剔除)。
337
+
338
+ trusted=True:直连源(JobSpy/SimplifyJobs/mcp-jobs)自返回 URL,
339
+ 跳过白名单校验,platform 用后端自带 _platform 标签。
340
+ """
341
+ url = item.get("url", "")
342
+ if item.get("trusted"):
343
+ item["platform"] = item.get("_platform") or "直连源"
344
+ item["date"] = extract_date(item)
345
+ return item
346
+ platform = ""
347
+ url_l = url.lower() # 域名大小写不敏感(remoteOK.com 实测)
348
+ for d, label in ALL_LABELS.items():
349
+ if d in url_l:
350
+ platform = label
351
+ break
352
+ if not platform:
353
+ for d, label in FREE_DOMAINS:
354
+ if d in url_l:
355
+ platform = label
356
+ break
357
+ if not platform and not GOV_RE.search(url_l):
358
+ return None # 非白名单 URL:剔除
359
+ if not platform:
360
+ platform = GOV_LABEL
361
+ item["platform"] = platform
362
+ item["date"] = extract_date(item)
363
+ return item
364
+
365
+
366
+ # ── 检索后端 ────────────────────────────────────────────────────────────
367
+
368
+
369
+ def _search_exa(q: str, n: int) -> list:
370
+ d = _post("https://api.exa.ai/search",
371
+ {"query": q, "includeDomains": DOMAINS, "numResults": n * 3,
372
+ "type": "auto"},
373
+ {"x-api-key": get_env(["ARGO_EXA_API_KEY", "EXA_API_KEY"])})
374
+ return [{"title": r.get("title", ""), "url": r.get("url", ""),
375
+ "snippet": (r.get("text") or "")[:200],
376
+ "date": r.get("publishedDate", "")}
377
+ for r in d.get("results", [])]
378
+
379
+
380
+ def _search_tavily(q: str, n: int) -> list:
381
+ d = _post("https://api.tavily.com/search",
382
+ {"api_key": get_env(["ARGO_TAVILY_API_KEY", "TAVILY_API_KEY"]), "query": q,
383
+ "include_domains": DOMAINS, "max_results": n * 3,
384
+ "search_depth": "basic", "days": 90})
385
+ return [{"title": r.get("title", ""), "url": r.get("url", ""),
386
+ "snippet": (r.get("content") or "")[:200], "date": ""}
387
+ for r in d.get("results", [])]
388
+
389
+
390
+ def _search_byted(q: str, n: int) -> list:
391
+ out = []
392
+ # 先取出密钥再拼头:同一表达式里套引号要求 3.12+ 的 PEP 701,而 argo 支持 3.9。
393
+ token = get_env(["ARGO_BYTED_API_KEY", "ARGO_WEB_SEARCH_API_KEY", "WEB_SEARCH_API_KEY"])
394
+ for domain, label in PLATFORMS:
395
+ try:
396
+ d = _post("https://open.feedcoopapi.com/search_api/web_search",
397
+ {"Query": f"site:{domain} {q}", "Count": n, "SearchType": "web"},
398
+ {"Authorization": f"Bearer {token}"})
399
+ except Exception:
400
+ continue # 单平台失败不拖累整体
401
+ if not d or not isinstance(d, dict):
402
+ continue # 上游结构异常(实测 None)防御
403
+ for r in (d.get("Result") or {}).get("WebResults", [])[:n] or []:
404
+ out.append({"title": r.get("Title", ""), "url": r.get("Url", ""),
405
+ "snippet": r.get("Snippet", ""), "date": ""})
406
+ return out
407
+
408
+
409
+ def _search_bocha(q: str, n: int) -> list:
410
+ """博查 Web Search:中文宽查询 + 白名单校验(site: 与宽查询均实测严格)。"""
411
+ # 同 _search_byted:先取密钥再拼头,保持 3.9 可解析。
412
+ token = get_env(["ARGO_BOCHA_API_KEY", "BOCHA_API_KEY"])
413
+ d = _post("https://api.bochaai.com/v1/web-search",
414
+ {"query": q, "summary": True, "freshness": "oneYear",
415
+ "count": min(n * 3, 50)},
416
+ {"Authorization": f"Bearer {token}"})
417
+ pages = (d.get("data") or {}).get("webPages") or {}
418
+ return [{"title": i.get("name", "") or i.get("title", ""),
419
+ "url": i.get("url", ""),
420
+ "snippet": (i.get("summary") or i.get("snippet") or "")[:200],
421
+ "date": i.get("datePublished", "") or i.get("dateLastCrawled", "")}
422
+ for i in (pages.get("value") or [])]
423
+
424
+
425
+ def _search_octen(q: str, n: int) -> list:
426
+ """Octen AI 搜索:宽查询 + 白名单校验。"""
427
+ d = _post("https://api.octen.ai/search",
428
+ {"query": q, "count": min(n * 3, 10), "topic": "general",
429
+ "safesearch": "off",
430
+ "highlight": {"enable": True, "max_tokens": 512},
431
+ "full_content": {"enable": False}, "include_images": False},
432
+ {"X-Api-Key": get_env(["ARGO_OCTEN_API_KEY", "OCTEN_API_KEY"])})
433
+ items = (d.get("data") or {}).get("results") or []
434
+ return [{"title": r.get("title", ""), "url": r.get("url", ""),
435
+ "snippet": (r.get("highlight") or "")[:200], "date": ""}
436
+ for r in items]
437
+
438
+
439
+ # ── 免 key 国际源(远程/海外岗位,公开 JSON API)───────────────────────
440
+
441
+ GREENHOUSE_BOARDS = ["asana", "stripe", "airbnb", "reddit", "shopify", "duolingo",
442
+ "figma", "cloudflare", "datadog", "mongodb", "notion",
443
+ "instacart", "pinterest", "doordash", "pagerduty", "box",
444
+ "adobe", "godaddy", "docusign", "dropbox"]
445
+
446
+ ASHBY_BOARDS = ["notion", "openai", "figma", "linear", "vercel", "airtable",
447
+ "plaid", "ramp", "rippling", "paddle", "perplexity", "midjourney"]
448
+
449
+
450
+ def _search_remotive(q: str, n: int) -> list:
451
+ d = _get(f"https://remotive.com/api/remote-jobs?limit={n * 3}&search={quote(q)}")
452
+ return [{"title": j.get("title", ""), "url": j.get("url", ""),
453
+ "snippet": (j.get("candidate_required_location", "") or "")[:200],
454
+ "location": j.get("candidate_required_location", ""),
455
+ "date": j.get("publication_date", "")}
456
+ for j in d.get("jobs", [])]
457
+
458
+
459
+ def _search_himalayas(q: str, n: int) -> list:
460
+ d = _get(f"https://himalayas.app/jobs/api?limit={n * 3}&query={quote(q)}")
461
+ return [{"title": j.get("title", ""), "url": j.get("url", ""),
462
+ "snippet": (j.get("excerpt") or "")[:200],
463
+ "location": j.get("location", "") or j.get("country", ""),
464
+ "date": ""}
465
+ for j in d.get("jobs", [])]
466
+
467
+
468
+ def _search_jobicy(q: str, n: int) -> list:
469
+ d = _get(f"https://jobicy.com/api/v2/remote-jobs?count={n * 3}&tag={quote(q)}")
470
+ return [{"title": j.get("jobTitle", ""), "url": j.get("url", ""),
471
+ "snippet": (j.get("jobGeo", "") or "")[:200],
472
+ "location": j.get("jobGeo", ""), "date": ""}
473
+ for j in d.get("jobs", [])]
474
+
475
+
476
+ def _search_arbeitnow(q: str, n: int) -> list:
477
+ d = _get(f"https://www.arbeitnow.com/api/job-board-api?search={quote(q)}")
478
+ return [{"title": j.get("title", ""), "url": j.get("url", ""),
479
+ "snippet": (j.get("location", "") or "")[:200],
480
+ "location": j.get("location", ""),
481
+ "date": j.get("created_at", "")}
482
+ for j in d.get("data", [])[:n * 3]]
483
+
484
+
485
+ def _search_greenhouse(q: str, n: int) -> list:
486
+ """Greenhouse ATS 公司直招:boards 并行请求,标题命中即收。"""
487
+ out, lock = [], threading.Lock()
488
+ ql = q.lower()
489
+ qw = [w.lower() for w in ql.split()]
490
+
491
+ def _one(board: str):
492
+ try:
493
+ d = _get(
494
+ f"https://boards-api.greenhouse.io/v1/boards/{board}/jobs?per_page=30&content=true",
495
+ timeout=8)
496
+ except Exception:
497
+ return
498
+ for j in d.get("jobs", []):
499
+ loc = ""
500
+ lo = j.get("location") or {}
501
+ if isinstance(lo, dict):
502
+ loc = lo.get("name", "")
503
+ elif isinstance(lo, str):
504
+ loc = lo
505
+ hay = (j.get("title", "") + " " + loc).lower()
506
+ if ql in hay or any(w in hay for w in qw):
507
+ with lock:
508
+ out.append({"title": j.get("title", ""), "url": j.get("absolute_url", ""),
509
+ "snippet": loc[:200], "location": loc, "date": ""})
510
+
511
+ threads = [threading.Thread(target=_one, args=(b,)) for b in GREENHOUSE_BOARDS]
512
+ for t in threads:
513
+ t.start()
514
+ for t in threads:
515
+ t.join(timeout=12)
516
+ return out[: n * 3]
517
+
518
+
519
+ def _search_ashby(q: str, n: int) -> list:
520
+ """Ashby ATS 公司直招(免 key,岗位含 compensation 字段):boards 并行请求。"""
521
+ out, lock = [], threading.Lock()
522
+ ql = q.lower()
523
+ qw = [w.lower() for w in ql.split()]
524
+
525
+ def _one(board: str):
526
+ try:
527
+ d = _get(
528
+ f"https://api.ashbyhq.com/posting-api/job-board/{board}?includeCompensation=true",
529
+ timeout=8)
530
+ except Exception:
531
+ return
532
+ for j in d.get("jobs", []) or []:
533
+ loc = j.get("location") or ""
534
+ hay = (j.get("title", "") + " " + str(loc)).lower()
535
+ if ql in hay or any(w in hay for w in qw):
536
+ with lock:
537
+ out.append({"title": j.get("title", ""), "url": j.get("jobUrl", "") or j.get("applyUrl", ""),
538
+ "snippet": f"{loc} | {'远程' if j.get('isRemote') else '坐班'} | {j.get('employmentType', '')}"[:200],
539
+ "location": str(loc), "date": (j.get("publishedAt") or "")[:10]})
540
+
541
+ threads = [threading.Thread(target=_one, args=(b,)) for b in ASHBY_BOARDS]
542
+ for t in threads:
543
+ t.start()
544
+ for t in threads:
545
+ t.join(timeout=12)
546
+ return out[: n * 3]
547
+
548
+
549
+ # ── v4 免 key ATS 直连源(2026-08-11 实测)────────────────────────────
550
+
551
+ # v4 免 key ATS 直连源(2026-08-11 实测:recruitee 全线无数据已移除)
552
+ LEVER_BOARDS = ["kraken", "outreach"]
553
+
554
+
555
+ def _ts2date(v) -> str:
556
+ """unix 秒时间戳 → YYYY-MM-DD;非时间戳原样截断。"""
557
+ try:
558
+ return datetime.fromtimestamp(int(v), timezone.utc).date().isoformat()
559
+ except Exception:
560
+ return str(v or "")[:10]
561
+
562
+
563
+ def _search_lever(q: str, n: int) -> list:
564
+ """Lever ATS:api.lever.co 免 key,boards 并行,标题命中即收。"""
565
+ out, lock = [], threading.Lock()
566
+ ql = q.lower()
567
+ qw = [w.lower() for w in ql.split()]
568
+
569
+ def _one(board: str):
570
+ try:
571
+ d = _get(f"https://api.lever.co/v0/postings/{board}?mode=json", timeout=6)
572
+ except Exception:
573
+ return
574
+ for j in d:
575
+ cats = j.get("categories") or {}
576
+ loc = cats.get("location", "") if isinstance(cats, dict) else ""
577
+ hay = ((j.get("text") or "") + " " + str(loc)).lower()
578
+ if ql in hay or any(w in hay for w in qw):
579
+ with lock:
580
+ out.append({"title": j.get("text", ""),
581
+ "url": j.get("hostedUrl", ""),
582
+ "snippet": str(loc)[:200], "location": str(loc),
583
+ "date": _ts2date(j.get("createdAt"))})
584
+
585
+ threads = [threading.Thread(target=_one, args=(b,)) for b in LEVER_BOARDS]
586
+ for t in threads:
587
+ t.start()
588
+ for t in threads:
589
+ t.join(timeout=12)
590
+ return out[: n * 3]
591
+
592
+
593
+ def _search_remoteok(q: str, n: int) -> list:
594
+ """RemoteOK:全量远程岗 JSON,本地过滤。"""
595
+ d = _get("https://remoteok.com/api", timeout=15)
596
+ out = []
597
+ ql = q.lower()
598
+ qw = [w.lower() for w in ql.split()]
599
+ for j in d[1:]: # 首元素为公告信息
600
+ loc = j.get("location") or ""
601
+ hay = ((j.get("position") or "") + " " + j.get("company", "") + " " + str(loc)).lower()
602
+ if ql in hay or any(w in hay for w in qw):
603
+ out.append({"title": f"{j.get('position', '')} @ {j.get('company', '')}"[:120],
604
+ "url": j.get("url") or j.get("apply_url") or "",
605
+ "snippet": f"{loc} | {j.get('tags', '')}"[:200],
606
+ "location": str(loc), "date": (j.get("date") or "")[:10]})
607
+ if len(out) >= n * 3:
608
+ break
609
+ return out
610
+
611
+
612
+ # ── v4 聚合源(SimplifyJobs / JobSpy / mcp-jobs)───────────────────────
613
+
614
+ _SIMPLIFY_MONTH = re.compile(
615
+ r"(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\s+(\d{1,2}),?\s*(\d{4})")
616
+ _SIMPLIFY_REL = re.compile(r"^(\d+)d$")
617
+
618
+
619
+ def _simplify_date(s: str) -> str:
620
+ """SimplifyJobs 日期列:绝对日期(Sep 01, 2025)或相对(0d/1d/30d)。"""
621
+ s = (s or "").strip()
622
+ m = _SIMPLIFY_MONTH.search(s)
623
+ if m:
624
+ months = {"Jan": 1, "Feb": 2, "Mar": 3, "Apr": 4, "May": 5, "Jun": 6,
625
+ "Jul": 7, "Aug": 8, "Sep": 9, "Oct": 10, "Nov": 11, "Dec": 12}
626
+ return f"{m.group(3)}-{months[m.group(1)[:3]]:02d}-{int(m.group(2)):02d}"
627
+ m = _SIMPLIFY_REL.match(s)
628
+ if m:
629
+ return (date.today() - timedelta(days=int(m.group(1)))).isoformat()
630
+ return ""
631
+
632
+
633
+ # SimplifyJobs README 现为 HTML <table>(2026-08 实测:约 2000+ 行岗位)
634
+ _SIMPLIFY_TR = re.compile(r"<tr[^>]*>(.*?)</tr>", re.S)
635
+ _SIMPLIFY_TD = re.compile(r"<td[^>]*>(.*?)</td>", re.S)
636
+ _SIMPLIFY_TAG = re.compile(r"<[^>]+>")
637
+ _SIMPLIFY_HREF = re.compile(r'<a[^>]+href="(https?://[^"]+)"')
638
+
639
+
640
+ def _simplify_row(tds: list) -> Optional[dict]:
641
+ if len(tds) < 5:
642
+ return None
643
+ cells = []
644
+ for td in tds:
645
+ txt = _SIMPLIFY_TAG.sub(" ", td)
646
+ import html as _html
647
+ cells.append(_html.unescape(txt).strip())
648
+ if not any(cells) or all(set(c) <= set("-: ") for c in cells):
649
+ return None
650
+ company, role, loc = cells[0], cells[1], cells[2]
651
+ if not company or not role:
652
+ return None
653
+ a = _SIMPLIFY_HREF.search(tds[3]) or _SIMPLIFY_HREF.search(tds[1])
654
+ return {"company": company, "role": role, "loc": loc,
655
+ "url": a.group(1) if a else "", "date": _simplify_date(cells[4])}
656
+
657
+
658
+ def _search_simplify(q: str, n: int) -> list:
659
+ """SimplifyJobs:GitHub 每日更新岗位表(HTML table,SWE/PM/DS 为主)。"""
660
+ req = urllib.request.Request(
661
+ "https://raw.githubusercontent.com/SimplifyJobs/New-Grad-Positions/dev/README.md",
662
+ headers={"User-Agent": "Mozilla/5.0"})
663
+ with open_url(req, timeout=30) as r:
664
+ chunks, total = [], 0
665
+ while total < 4_000_000: # 分块读,防服务器断流(IncompleteRead)
666
+ try:
667
+ b = r.read(262144)
668
+ except Exception:
669
+ break
670
+ if not b:
671
+ break
672
+ chunks.append(b)
673
+ total += len(b)
674
+ text = b"".join(chunks).decode("utf-8", "ignore")
675
+ out = []
676
+ ql = q.lower()
677
+ qw = [w.lower() for w in ql.split()]
678
+ for tr in _SIMPLIFY_TR.findall(text):
679
+ row = _simplify_row(_SIMPLIFY_TD.findall(tr))
680
+ if not row:
681
+ continue
682
+ hay = (row["company"] + " " + row["role"] + " " + row["loc"]).lower()
683
+ if ql not in hay and not any(w in hay for w in qw):
684
+ continue
685
+ out.append({"title": f"{row['role']} @ {row['company']}"[:120],
686
+ "url": row["url"], "snippet": row["loc"][:200],
687
+ "location": row["loc"], "date": row["date"],
688
+ "trusted": True, "_platform": "SimplifyJobs"})
689
+ if len(out) >= n * 3:
690
+ break
691
+ return out
692
+
693
+
694
+ def _search_jobspy(q: str, n: int) -> list:
695
+ """JobSpy 聚合爬虫(可选依赖):LinkedIn/Indeed/ZipRecruiter。
696
+
697
+ 注意:PyPI 上的 jobspy 是同名假包(redis 工具),真包须从 GitHub 安装,
698
+ 且要求 Python 3.10+(本机 3.9 无法安装时会给出此提示)。
699
+ """
700
+ try:
701
+ from jobspy import scrape_jobs
702
+ except ImportError:
703
+ raise RuntimeError(
704
+ "jobspy 未安装或不可用。真包需 Python 3.10+ 且从 GitHub 安装:\n"
705
+ " pip3 install --user 'git+https://github.com/speedyapply/JobSpy.git'\n"
706
+ "警告:PyPI 的 jobspy(redis 工具)是假包,勿装。")
707
+ out = []
708
+ for site in ("linkedin", "indeed", "ziprecruiter"):
709
+ try:
710
+ kw = dict(search_term=q, results_wanted=max(n, 5), hours_old=168)
711
+ try:
712
+ df = scrape_jobs(site_name=[site], **kw)
713
+ except TypeError:
714
+ df = scrape_jobs(site=[site], **kw)
715
+ except Exception:
716
+ continue
717
+ for _, r in df.iterrows():
718
+ out.append({"title": str(r.get("title") or "")[:120],
719
+ "url": str(r.get("job_url") or ""),
720
+ "snippet": str(r.get("description") or "")[:200],
721
+ "location": str(r.get("location") or ""),
722
+ "date": str(r.get("date_posted") or "")[:10],
723
+ "trusted": True, "_platform": f"JobSpy-{site}"})
724
+ if not out:
725
+ raise RuntimeError("jobspy 抓取失败(反爬或网络),可重试或换 --engine free")
726
+ return out
727
+
728
+
729
+ # ── mcp-jobs(猎聘/BOSS/智联,MCP stdio 协议)─────────────────────────
730
+ # npm 包 mcp-jobs 无 bin 入口(README 的 npx 启动方式不可用),
731
+ # 固定安装到平台缓存目录后直接 node dist/mcp.js 启动。
732
+ # 2026-09-28 起路径随平台惯例(argo_paths;旧 ~/.cache/argo-mcpjobs 可删)。
733
+
734
+ _MCPJ = {"proc": None, "id": 0}
735
+ from argo_paths import platform_cache_default # noqa: E402
736
+
737
+ MCPJOBS_DIR = os.environ.get("ARGO_MCPJOBS_DIR") or str(
738
+ platform_cache_default() / "argo-mcpjobs")
739
+ MCPJOBS_ENTRY = os.path.join(MCPJOBS_DIR, "node_modules", "mcp-jobs", "dist", "mcp.js")
740
+
741
+
742
+ def _mcpjobs_send(method: str, params: dict, timeout: int = 60) -> dict:
743
+ p = _MCPJ["proc"]
744
+ _MCPJ["id"] += 1
745
+ req_id = _MCPJ["id"]
746
+ p.stdin.write(json.dumps(
747
+ {"jsonrpc": "2.0", "id": req_id, "method": method, "params": params}) + "\n")
748
+ p.stdin.flush()
749
+ deadline = time.time() + timeout
750
+ while time.time() < deadline:
751
+ line = p.stdout.readline()
752
+ if not line:
753
+ raise RuntimeError("mcp-jobs 进程已退出")
754
+ try:
755
+ d = json.loads(line)
756
+ except ValueError:
757
+ continue
758
+ if isinstance(d, dict) and d.get("id") == req_id:
759
+ return d
760
+ raise RuntimeError(f"mcp-jobs 响应超时({method})")
761
+
762
+
763
+ def _mcpjobs_ensure() -> bool:
764
+ if _MCPJ["proc"] and _MCPJ["proc"].poll() is None:
765
+ return True
766
+ try:
767
+ if not os.path.exists(MCPJOBS_ENTRY):
768
+ r = subprocess.run(
769
+ ["npm", "install", "--prefix", MCPJOBS_DIR, "--no-audit", "--no-fund",
770
+ "mcp-jobs"], capture_output=True, text=True, timeout=240,
771
+ encoding="utf-8", errors="replace") # Windows GBK 控制台读 stderr 需显式 UTF-8
772
+ if r.returncode != 0:
773
+ raise RuntimeError(f"npm install mcp-jobs 失败:{(r.stderr or r.stdout)[:120]}")
774
+ p = subprocess.Popen(["node", MCPJOBS_ENTRY],
775
+ stdin=subprocess.PIPE, stdout=subprocess.PIPE,
776
+ stderr=subprocess.DEVNULL, text=True, bufsize=1,
777
+ encoding="utf-8", errors="replace") # node JSON-RPC 恒 UTF-8,Windows 默认 GBK 会对不上
778
+ _MCPJ["proc"] = p
779
+ _mcpjobs_send("initialize", {
780
+ "protocolVersion": "2024-11-05", "capabilities": {},
781
+ "clientInfo": {"name": "argo", "version": "2.7.3"}}, timeout=30)
782
+ _mcpjobs_send("notifications/initialized", {}, timeout=10)
783
+ return True
784
+ except Exception as e:
785
+ _MCPJ["proc"] = None
786
+ raise RuntimeError(f"mcp-jobs 启动失败(需 node/npm):{e}")
787
+
788
+
789
+ def _search_mcpjobs(q: str, n: int) -> list:
790
+ """mcp-jobs:猎聘/BOSS/智联零配置 MCP 服务。慢源,仅显式 --engine mcpjobs。"""
791
+ if not _mcpjobs_ensure():
792
+ raise RuntimeError("mcp-jobs 不可用")
793
+ parts = q.split()
794
+ if len(parts) > 1:
795
+ city, q = parts[-1], " ".join(parts[:-1])
796
+ d = _mcpjobs_send("tools/call", {
797
+ "name": "mcp_search_job",
798
+ "arguments": {"keyword": q, "city": city}}, timeout=60)
799
+ texts = []
800
+ for c in (d.get("result") or {}).get("content") or []:
801
+ if c.get("type") == "text":
802
+ texts.append(c.get("text", ""))
803
+ out = []
804
+ for t in texts:
805
+ try:
806
+ data = json.loads(t)
807
+ except ValueError:
808
+ continue
809
+ if isinstance(data, dict):
810
+ items = data.get("jobs") or data.get("results") or []
811
+ if isinstance(items, dict):
812
+ items = [items]
813
+ elif isinstance(data, list):
814
+ items = data
815
+ else:
816
+ continue
817
+ for it in items if isinstance(items, list) else []:
818
+ if not isinstance(it, dict):
819
+ continue
820
+ url = (it.get("url") or it.get("link") or it.get("职位链接")
821
+ or it.get("job_url") or "")
822
+ title = (it.get("title") or it.get("职位名称") or it.get("name") or "")[:120]
823
+ if not title:
824
+ continue
825
+ loc = (it.get("location") or it.get("工作地点") or it.get("city") or "")
826
+ sal = (it.get("salary") or it.get("薪资") or "")
827
+ out.append({"title": title, "url": url,
828
+ "snippet": f"{loc} {sal}".strip()[:200],
829
+ "location": str(loc), "date": "",
830
+ "trusted": True, "_platform": "mcp-jobs"})
831
+ if not out:
832
+ raise RuntimeError("mcp-jobs 无结果(爬虫源可能被反爬拦截)")
833
+ return out
834
+
835
+
836
+ BACKENDS = {"exa": _search_exa, "tavily": _search_tavily, "byted": _search_byted,
837
+ "bocha": _search_bocha, "octen": _search_octen}
838
+ FREE_BACKENDS = {"remotive": _search_remotive, "himalayas": _search_himalayas,
839
+ "jobicy": _search_jobicy, "arbeitnow": _search_arbeitnow,
840
+ "greenhouse": _search_greenhouse, "ashby": _search_ashby,
841
+ "lever": _search_lever, "remoteok": _search_remoteok}
842
+ # 慢源/聚合源:仅显式 --engine 调用,不自动启用(simplify 下载 4MB 表格)
843
+ SLOW_BACKENDS = {"simplify": _search_simplify,
844
+ "jobspy": _search_jobspy, "mcpjobs": _search_mcpjobs}
845
+ ALL_BACKENDS = dict(BACKENDS, **FREE_BACKENDS, **SLOW_BACKENDS)
846
+ DEFAULT_ENGINES = ["exa", "tavily", "byted", "bocha", "octen"]
847
+
848
+
849
+ def _backend_down(name: str) -> bool:
850
+ """该后端是否已知暂时不可用(配额耗尽 / 熔断打开)——跳过它,让别的源顶上。
851
+
852
+ 为什么必须查:本模块有自己的后端表,直接调各家 API,**不经过 search.py 的
853
+ 路由**,因此也绕过了路由层那两处「已知停用就不发请求」的判断。后果实测:
854
+ bocha 余额不足(403 `You do not have enough money or package quota`)时,
855
+ 它每次调用都真发一次请求、拿回一条 403,并作为**硬错误**写进输出的 errors
856
+ 字段——调用方据此判成「整体失败」,而实际上另外十来家源都正常返回。
857
+
858
+ 判据复用既有状态,不另造机制:配额管理器(配额耗尽 → 排除到周期边界,
859
+ 自动恢复)与熔断器(失败后冷却、半开探测)。两者都自带「定期再试一次」,
860
+ 所以跳过是暂时的,不需要人工介入。
861
+
862
+ 招聘这件事本来就不依赖单一数据源——少两家,其余照常。
863
+ """
864
+ try:
865
+ from quota import get_quota_manager
866
+ if get_quota_manager().is_hard_down(name):
867
+ return True
868
+ except Exception:
869
+ pass
870
+ # 熔断态查询走 breaker.status(),它是只读的;**不能用 allow()**——allow()
871
+ # 在 open/disabled 冷却到期时会把状态推进到 half_open 并 _save() 落盘,
872
+ # 而这里在每个引擎的循环里被当纯判据调用。后果有两层:每次检查写一次状态
873
+ # 文件;本模块从不调 record_success/record_failure,推进出来的半开探测
874
+ # 永不收敛(半开态没有终结者)。语义与 route._filter_breaker_blocked
875
+ # 逐条对齐:disabled / open 且冷却未过 → 不跑,half_open 保留探测资格。
876
+ try:
877
+ from circuit_breaker import get_breaker
878
+ st = get_breaker().status(name)
879
+ except Exception:
880
+ return False
881
+ state = st.get("state")
882
+ if state == "disabled":
883
+ return True
884
+ return state == "open" and int(st.get("cooldown_remain") or 0) > 0
885
+
886
+
887
+ def _is_quota_exhausted(exc: Exception) -> bool:
888
+ """这次失败是不是「源端配额耗尽」——只有这一种才交给配额状态机。
889
+
890
+ **只认配额耗尽,不认 401/403/429 这类泛化的「源这会儿用不了」**。泛化判据
891
+ 的代价实打实:`_mark_source_down` 按 `backends/quota_profiles.json` 的
892
+ period 排除该源,bocha/exa/tavily 是 month,而 route 用同一个状态筛路由
893
+ combo——于是一次 Cloudflare 拦截(403 + `Sorry, you have been blocked`,
894
+ 与欠费同码不同因)会让全仓 `argo search` 30 天不再用这个源,且在招聘之外
895
+ 的模块静默生效。403/429 各有归属:engine_failure.classify 分 auth /
896
+ blocked / rate_limited,健康问题交熔断器自愈,本函数不接手。
897
+
898
+ 关键词表复用 engine_dispatch 的 `_QUOTA_ERROR_KEYWORDS`(该表自述为
899
+ 「唯一来源」,配额分类与自适应跳过共用),不另起一份。
900
+ 真欠费的文案(`You do not have enough money or package quota`)含
901
+ `quota`,仍然照常命中。
902
+ """
903
+ try:
904
+ from engine_dispatch import _QUOTA_ERROR_KEYWORDS
905
+ except ImportError:
906
+ return False
907
+ text = str(exc).lower()
908
+ return any(k in text for k in _QUOTA_ERROR_KEYWORDS)
909
+
910
+
911
+ def _mark_source_down(name: str, detail: str) -> None:
912
+ """把源标为「配额耗尽」——交给配额状态机,到周期边界自动恢复。
913
+
914
+ 复用既有机制,不另造:`mark_remote_exhausted` 的语义正是「远端明说用不了
915
+ → 全模式排除 → 到期自愈」。所以「定期检测」这件事本来就有,不必人工介入。
916
+
917
+ 只在 `_is_quota_exhausted` 为真时调用:这个状态是跨模块的(route 用同一
918
+ 状态筛 combo),误标的代价远大于漏标。
919
+ """
920
+ try:
921
+ from quota import get_quota_manager
922
+ get_quota_manager().mark_remote_exhausted(name, reason=detail[:160])
923
+ except Exception:
924
+ pass
925
+
926
+
927
+ # ── 详情页结构化补全(--fetch N,走 argo fetch_v3 降级链)──────────────
928
+
929
+
930
+ def _fetch_detail(url: str, timeout: int = 10) -> Optional[dict]:
931
+ """抓详情页提取结构化字段;失败返回 None(静默降级 snippet 字段)。"""
932
+ try:
933
+ from fetch_v3 import fetch_v3
934
+ r = fetch_v3(url, max_chars=3000, timeout=timeout, use_browser_fallback=False)
935
+ if not r or not r.get("success"):
936
+ return None
937
+ content = (r.get("content") or "")[:3000]
938
+ fields = {}
939
+ m = _SALARY_RE.search(content)
940
+ if m:
941
+ fields["salary"] = m.group(0).strip()
942
+ m = _EDU_RE.search(content)
943
+ if m:
944
+ fields["education"] = m.group(1)
945
+ m = _EXP_RE.search(content)
946
+ if m:
947
+ fields["experience"] = m.group(1).strip()
948
+ m = _COMPANY_RE.search((r.get("title") or "") + content[:500])
949
+ if m:
950
+ fields["company"] = m.group(1).strip()
951
+ return fields if fields else None
952
+ except Exception:
953
+ return None
954
+
955
+
956
+ # ── 主流程 ─────────────────────────────────────────────────────────────
957
+
958
+
959
+ def sort_jobs(items: list[dict]) -> list[dict]:
960
+ """岗位排序(唯一来源):级别 L1 > L2 > L3 > 0 为主键(稳定排序先按
961
+ 日期降序,级别内日期新→旧),过期垫底,空日期排最后。
962
+
963
+ CLI 与 MCP argo_job 共用,防止两处排序计算方式漂移。
964
+ """
965
+ out = list(items)
966
+ out.sort(key=lambda r: r.get("date", ""), reverse=True)
967
+ out.sort(key=lambda r: (r.get("hit_level", 0), r.get("stale", False)))
968
+ return out
969
+
970
+
971
+ def search(query: str, city: str = "", num: int = 5, engine: str = "all",
972
+ platforms: str = "", loose: bool = False, fetch_n: int = 0) -> dict:
973
+ """聚合搜索主链(CLI 与 MCP 共用)。
974
+
975
+ 多后端并发 → 白名单校验 → 三级地区判定 → snippet 结构化 → 指纹去重 →
976
+ 排序。不含 --watch 增量对比(CLI 专属,main 基于返回结果做)。
977
+ platforms 为逗号分隔平台 id(zhipin,liepin,zhaopin,51job,597,jrzp),
978
+ 空 = 全平台;未识别抛 ValueError。
979
+
980
+ 返回与 CLI --json 相同结构的 dict:
981
+ query/backends/total/dropped_url/dropped_region/strict/errors/skipped/results。
982
+ errors = 本次真失败的源;skipped = 出发前就已知不可用而跳过的源
983
+ (配额耗尽 / 熔断打开)。两者分开:跳过不是失败,但也不能让调用方看见
984
+ 「total=0 且 errors=[]」时误以为「确实没岗位」。
985
+ """
986
+ plat_list = PLATFORMS
987
+ if platforms:
988
+ wanted = {p.strip() for p in platforms.split(",")}
989
+ plat_list = [(d, l) for d, l in PLATFORMS if d.split(".")[0] in wanted]
990
+ if not plat_list:
991
+ raise ValueError("未识别的平台,可用: zhipin,liepin,zhaopin,51job,597,jrzp")
992
+
993
+ engines = []
994
+ if engine == "all":
995
+ engines = list(DEFAULT_ENGINES)
996
+ if is_overseas(city):
997
+ engines += list(FREE_BACKENDS) # 海外自动启用免 key 源
998
+ elif engine == "free":
999
+ engines = list(FREE_BACKENDS)
1000
+ else:
1001
+ engines = [engine]
1002
+
1003
+ full_query = f"{query} {city}".strip()
1004
+ words = region_words(city) if city else []
1005
+
1006
+ results, errors = [], []
1007
+ skipped: list[str] = []
1008
+
1009
+ def run(name: str):
1010
+ # 已知暂时不可用的后端直接跳过:它「这会儿用不了」不是「本次调用失败」。
1011
+ # 让它继续挨个重试既拿不到结果,又会把 403 记成硬错误写进 errors,
1012
+ # 调用方据此判成整体失败(实测 bocha 余额不足时就是这样)。其余源照常。
1013
+ if _backend_down(name):
1014
+ # 跳过既不是失败(不写 errors:别家源可能已经交付,调用方见 errors
1015
+ # 非空会判成整体失败),也不能静默——全都跳过时 errors 为空、total
1016
+ # 为 0,调用方分不清「确实没岗位」与「源全挂了」。单列 skipped。
1017
+ skipped.append(name)
1018
+ return
1019
+ try:
1020
+ fn = ALL_BACKENDS[name]
1021
+ for r in fn(full_query, num):
1022
+ r["backend"] = name
1023
+ results.append(r)
1024
+ except Exception as e:
1025
+ # 只有配额耗尽才记账跳过:该状态按 period 排除到周期边界(月级),
1026
+ # 且 route 用同一状态筛 combo,误标的代价是 30 天少一个源。其余
1027
+ # 失败(403 反爬、429 限流、5xx)如实进 errors,让调用方看得见。
1028
+ if _is_quota_exhausted(e):
1029
+ _mark_source_down(name, str(e))
1030
+ return
1031
+ errors.append(f"{name}: {e}")
1032
+
1033
+ threads = [threading.Thread(target=run, args=(e,)) for e in engines]
1034
+ for t in threads:
1035
+ t.start()
1036
+ for t in threads:
1037
+ t.join(timeout=12)
1038
+
1039
+ # 统一字段 + 白名单后置校验 + 三级判定 + 时效 + 结构化字段
1040
+ kept, dropped_url, dropped_region = [], 0, 0
1041
+ for r in results:
1042
+ nr = _norm(r)
1043
+ if nr is None:
1044
+ dropped_url += 1 # 非白名单 URL 剔除(修复 byted site: 混入)
1045
+ continue
1046
+ level, reason = judge(nr, words)
1047
+ nr["hit_level"] = level
1048
+ nr["hit_reason"] = reason
1049
+ nr["stale"] = is_stale(nr["date"])
1050
+ nr["fields"] = parse_fields(nr) # snippet 级结构化(零成本)
1051
+ nr["fingerprint"] = fingerprint(nr)
1052
+ if level == 0:
1053
+ dropped_region += 1
1054
+ kept.append(nr)
1055
+
1056
+ strict = bool(words) and not loose
1057
+ if strict:
1058
+ kept = [r for r in kept if r["hit_level"] in (1, 2)]
1059
+ # 资讯/攻略页(工资待遇/就业前景/新闻百科)非岗位,剔除
1060
+ kept = [r for r in kept if not INFO_RE.search(r["title"])]
1061
+ # 平台标注「职位已关闭/已下线」的岗位,剔除
1062
+ kept = [r for r in kept if not CLOSED_RE.search(r.get("snippet", ""))]
1063
+
1064
+ # 详情页结构化补全(前 N 条 L1)
1065
+ if fetch_n > 0:
1066
+ fetched = 0
1067
+ for r in sorted(kept, key=lambda x: (x["hit_level"], x.get("date", "")), reverse=True):
1068
+ if fetched >= fetch_n:
1069
+ break
1070
+ if r["hit_level"] != 1:
1071
+ continue
1072
+ detail = _fetch_detail(r["url"])
1073
+ if detail:
1074
+ r["fields"].update(detail)
1075
+ fetched += 1
1076
+
1077
+ # 指纹去重:多平台同岗位合并(保留级别更高、有日期的)
1078
+ seen_fp = {}
1079
+ for r in kept:
1080
+ fp = r.get("fingerprint") or r["url"]
1081
+ prev = seen_fp.get(fp)
1082
+ if prev is None:
1083
+ seen_fp[fp] = r
1084
+ continue
1085
+ # 保留 hit_level 高者;同级保留有日期者;再同级保留先到者(后端顺序靠前)
1086
+ if (r["hit_level"], r.get("date", "")) > (prev["hit_level"], prev.get("date", "")):
1087
+ seen_fp[fp] = r
1088
+ unique = list(seen_fp.values())
1089
+
1090
+ # 排序:级别 L1 > L2 > L3 > 0 为主键(稳定排序先按日期降序,级别内日期新→旧),
1091
+ # 过期垫底。空日期排最后(byted/tavily 无日期字段)。
1092
+ # 单来源 sort_jobs():CLI --json 与 MCP argo_job 共用同一排序。
1093
+ unique = sort_jobs(unique)
1094
+
1095
+ return {"query": full_query, "backends": engines,
1096
+ "total": len(unique),
1097
+ "dropped_url": dropped_url,
1098
+ "dropped_region": dropped_region,
1099
+ "strict": strict,
1100
+ "errors": errors, "skipped": skipped, "results": unique}
1101
+
1102
+
1103
+ def main():
1104
+ ap = argparse.ArgumentParser(description="求职岗位搜索:职位 + 地区 → 该地区在招岗位")
1105
+ ap.add_argument("query", help="职位关键词,如:工艺工程师、会计、电工")
1106
+ ap.add_argument("--city", default="",
1107
+ help="地区:任意省/市/县(四川、成都、昆山…)或海外国家/城市"
1108
+ "(新加坡、东京、Sydney…),多个用空格分隔")
1109
+ ap.add_argument("-n", "--num", type=int, default=5, help="每后端条数")
1110
+ ap.add_argument("--engine", default="all",
1111
+ choices=["all", "free"] + sorted(ALL_BACKENDS),
1112
+ help="搜索后端:all = exa+tavily+byted+bocha+octen"
1113
+ "(海外城市自动加 free 免 key 源);free = 仅免 key 国际源")
1114
+ ap.add_argument("--platforms", default="",
1115
+ help="逗号分隔平台: zhipin,liepin,zhaopin,51job,597,jrzp")
1116
+ ap.add_argument("--loose", action="store_true",
1117
+ help="宽松:异地岗位也保留(命中置顶);默认严格过滤")
1118
+ ap.add_argument("--json", action="store_true", help="输出 JSON")
1119
+ ap.add_argument("--fetch", type=int, default=0, metavar="N",
1120
+ help="对前 N 条 L1 结果抓详情页补全结构化字段(默认 0 不抓,"
1121
+ "仅用 snippet 提取)")
1122
+ ap.add_argument("--watch", action="store_true",
1123
+ help="增量监控:存快照至 data/jobs/,二次运行对比输出新上线/已下架")
1124
+ args = ap.parse_args()
1125
+
1126
+ try:
1127
+ out = search(args.query, city=args.city, num=args.num,
1128
+ engine=args.engine, platforms=args.platforms,
1129
+ loose=args.loose, fetch_n=args.fetch)
1130
+ except ValueError as e:
1131
+ sys.exit(str(e))
1132
+
1133
+ query = out["query"]
1134
+ engines = out["backends"]
1135
+ unique = out["results"]
1136
+ strict = out["strict"]
1137
+
1138
+ # watch 增量对比(快照含全部去重后保留项;排序不影响集合,先做无妨)
1139
+ new_jobs, gone_jobs = [], []
1140
+ if args.watch:
1141
+ path = snapshot_path(args.query, args.city)
1142
+ old = load_snapshot(path)
1143
+ old_keys = {j["fingerprint"]: j for j in (old or {}).get("jobs", [])}
1144
+ new_keys = {j["fingerprint"]: j for j in unique}
1145
+ new_jobs = [j for fp, j in new_keys.items() if fp not in old_keys]
1146
+ gone_jobs = [j for fp, j in old_keys.items() if fp not in new_keys]
1147
+ save_snapshot(path, {"query": args.query, "city": args.city,
1148
+ "saved_at": date.today().isoformat(),
1149
+ "jobs": [{"fingerprint": j["fingerprint"],
1150
+ "title": j["title"], "url": j["url"],
1151
+ "platform": j["platform"],
1152
+ "date": j.get("date", "")} for j in unique]})
1153
+ print(f"\n── 增量对比({path})──")
1154
+ if old is None:
1155
+ print(f"基线建立:{len(unique)} 条岗位已存快照,下次运行输出变化")
1156
+ else:
1157
+ print(f"新上线 {len(new_jobs)} 条 / 已下架 {len(gone_jobs)} 条(共 {len(unique)} 条在架)")
1158
+ for j in new_jobs[:10]:
1159
+ print(f" +[{j['platform']}] {j['title'][:48]}")
1160
+ for j in gone_jobs[:10]:
1161
+ print(f" -[{j['platform']}] {j['title'][:48]}")
1162
+ if len(new_jobs) > 10 or len(gone_jobs) > 10:
1163
+ print(f" … 其余变化请查看快照 {path}")
1164
+
1165
+ # 排序已在 search() 内完成(sort_jobs),此处不再重排
1166
+ if args.json:
1167
+ if args.watch:
1168
+ out["watch"] = {"new": len(new_jobs), "gone": len(gone_jobs)}
1169
+ print(dumps(out))
1170
+ else:
1171
+ mode = "严格" if strict else "宽松" if args.loose else "全部"
1172
+ print(f"查询: {query} | 后端: {','.join(engines)} | {mode} | {len(unique)} 条")
1173
+ marks = {1: "●", 2: "◐", 3: "○"}
1174
+ for r in unique:
1175
+ stale = " [过期]" if r.get("stale") else ""
1176
+ mark = marks.get(r.get("hit_level", 0), "○")
1177
+ f = r.get("fields", {})
1178
+ extra = " | ".join(x for x in (f.get("salary"), f.get("education"),
1179
+ f.get("experience")) if x)
1180
+ print(f"{mark}[{r['platform']}]{stale} {r['title'][:48]}"
1181
+ + (f" {extra}" if extra else ""))
1182
+ print(f" {r['url'][:88]}")
1183
+
1184
+
1185
+ if __name__ == "__main__":
1186
+ main()