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.
- package/LICENSE +201 -21
- package/README.en.md +822 -0
- package/README.es.md +864 -0
- package/README.ja.md +864 -0
- package/README.ko.md +864 -0
- package/README.md +867 -320
- package/SKILL.md +135 -0
- package/assets/readme/hero.svg +121 -0
- package/assets/readme/made-with-beautify.svg +10 -0
- package/assets/readme/proof-routes.svg +73 -0
- package/assets/readme/settings-keys.png +0 -0
- package/assets/readme/settings-tools.png +0 -0
- package/assets/readme/why-better.svg +104 -0
- package/assets/readme/workflow.svg +83 -0
- package/backends/domain_profiles.json +2308 -96
- package/backends/engine_registry.yaml +3314 -503
- package/backends/query_synonyms_cn.json +35 -0
- package/backends/quota_profiles.json +2409 -161
- package/backends/source_types_cn.json +83 -0
- package/backends/text_lexicon.json +44 -0
- package/bin/argo +703 -0
- package/bin/argo.js +71 -39
- package/config.yaml +4976 -363
- package/cordis.patch.yml +21 -0
- package/data/countries.json +40 -0
- package/data/lang_matrix/lang_capability.json +842 -0
- package/data/lang_matrix/matrix_2026-09-10.json +11921 -0
- package/data/regions_cn.json +1 -0
- package/docs/ADDING_NEW_ENGINE.md +184 -0
- package/docs/ARGO_INTRO.md +124 -0
- package/docs/DATA_SOURCE_GAPS.md +81 -0
- package/docs/DESIGN_LOCAL_DATA_FUSION.md +90 -0
- package/docs/ENGINE_CATALOG.md +564 -0
- package/docs/MCP_SETUP.md +106 -0
- package/docs/PLATFORM_PATHS.md +111 -0
- package/docs/RELEASE_NOTES_v2.8.0.md +71 -0
- package/docs/RELEASE_NOTES_v2.8.2.md +63 -0
- package/docs/RELEASE_NOTES_v2.8.3.md +55 -0
- package/docs/RELEASE_NOTES_v2.8.4.md +79 -0
- package/docs/RELEASE_NOTES_v2.8.5.md +102 -0
- package/docs/RELEASE_NOTES_v2.8.6.md +66 -0
- package/docs/RELEASE_NOTES_v2.8.7.md +65 -0
- package/docs/RELEASE_NOTES_v2.8.8.md +67 -0
- package/docs/RELEASE_NOTES_v2.8.9.md +67 -0
- package/docs/RELEASE_NOTES_v2.9.0.md +64 -0
- package/docs/SEARCH_ARCHIVE.md +115 -0
- package/docs/assets/architecture.svg +104 -0
- package/docs/assets/hero.svg +98 -0
- package/docs/engines/bangumi.md +27 -0
- package/docs/engines/biorxiv.md +27 -0
- package/docs/engines/clawhub.md +27 -0
- package/docs/engines/crt_sh.md +27 -0
- package/docs/engines/datacite.md +27 -0
- package/docs/engines/deps_dev.md +27 -0
- package/docs/engines/douban_movie.md +27 -0
- package/docs/engines/endoflife.md +27 -0
- package/docs/engines/flk_law.md +27 -0
- package/docs/engines/fxtwitter.md +27 -0
- package/docs/engines/google_news.md +27 -0
- package/docs/engines/gov_regulations.md +27 -0
- package/docs/engines/guqian.md +27 -0
- package/docs/engines/hackernews.md +27 -0
- package/docs/engines/iplant.md +27 -0
- package/docs/engines/keenable.md +27 -0
- package/docs/engines/met_museum.md +27 -0
- package/docs/engines/nasa_images.md +27 -0
- package/docs/engines/netease_music.md +27 -0
- package/docs/engines/npm.md +27 -0
- package/docs/engines/nvd.md +27 -0
- package/docs/engines/openreview.md +27 -0
- package/docs/engines/openstd.md +27 -0
- package/docs/engines/parallel_free.md +27 -0
- package/docs/engines/people_daily.md +27 -0
- package/docs/engines/realtime_index.md +27 -0
- package/docs/engines/sec_edgar.md +27 -0
- package/docs/engines/seltz.md +27 -0
- package/docs/engines/skillsmp.md +27 -0
- package/docs/engines/sspai.md +27 -0
- package/docs/engines/std_samr.md +27 -0
- package/docs/engines/train.md +27 -0
- package/docs/engines/un_comtrade.md +27 -0
- package/docs/engines/wikisource.md +27 -0
- package/docs/engines/zdic.md +27 -0
- package/docs/engines/zenodo.md +27 -0
- package/docs/engines/zhihu_hot_app.md +27 -0
- package/docs//344/270/272/344/273/200/344/271/210/351/200/211/346/213/251argo.md +176 -0
- package/engines/README.md +10 -0
- package/engines/_template_cli.yaml +39 -0
- package/engines/_template_http.yaml +37 -0
- package/engines/plugins/_template_plugin.py +50 -0
- package/engines/specs/_cisa_kev.yaml +54 -0
- package/engines/specs/_osv.yaml +63 -0
- package/engines/specs/aljazeera.yaml +43 -0
- package/engines/specs/antara.yaml +43 -0
- package/engines/specs/artic.yaml +20 -0
- package/engines/specs/aviation_weather.yaml +39 -0
- package/engines/specs/bangumi.yaml +21 -0
- package/engines/specs/biorxiv.yaml +24 -0
- package/engines/specs/carbon_intensity.yaml +32 -0
- package/engines/specs/cinii.yaml +44 -0
- package/engines/specs/clawhub.yaml +49 -0
- package/engines/specs/cleveland.yaml +19 -0
- package/engines/specs/cn_ai_news.yaml +43 -0
- package/engines/specs/core.yaml +35 -0
- package/engines/specs/crt_sh.yaml +38 -0
- package/engines/specs/ct24.yaml +43 -0
- package/engines/specs/datacite.yaml +27 -0
- package/engines/specs/datagov_uk.yaml +41 -0
- package/engines/specs/ddgs_images.yaml +45 -0
- package/engines/specs/deepseek_search.yaml +74 -0
- package/engines/specs/deezer.yaml +20 -0
- package/engines/specs/deps_dev.yaml +21 -0
- package/engines/specs/digup.yaml +36 -0
- package/engines/specs/doi.yaml +39 -0
- package/engines/specs/douban_movie.yaml +22 -0
- package/engines/specs/dw_news.yaml +47 -0
- package/engines/specs/egov_law.yaml +21 -0
- package/engines/specs/elpais.yaml +43 -0
- package/engines/specs/endoflife.yaml +21 -0
- package/engines/specs/energy_charts.yaml +21 -0
- package/engines/specs/factcheck_org.yaml +52 -0
- package/engines/specs/faz.yaml +43 -0
- package/engines/specs/federal_register.yaml +57 -0
- package/engines/specs/firecrawl.yaml +53 -0
- package/engines/specs/flk_law.yaml +24 -0
- package/engines/specs/folha.yaml +43 -0
- package/engines/specs/france24.yaml +44 -0
- package/engines/specs/full_fact.yaml +47 -0
- package/engines/specs/fxtwitter.yaml +31 -0
- package/engines/specs/gbfs_nyc.yaml +32 -0
- package/engines/specs/gdacs.yaml +21 -0
- package/engines/specs/gdelt.yaml +64 -0
- package/engines/specs/google_news.yaml +22 -0
- package/engines/specs/gov_regulations.yaml +38 -0
- package/engines/specs/guardian_rss.yaml +59 -0
- package/engines/specs/hurriyet.yaml +43 -0
- package/engines/specs/iplant.yaml +25 -0
- package/engines/specs/jikan.yaml +24 -0
- package/engines/specs/jolpica.yaml +20 -0
- package/engines/specs/k10plus.yaml +20 -0
- package/engines/specs/keenable.yaml +44 -0
- package/engines/specs/lefigaro.yaml +43 -0
- package/engines/specs/listenbrainz.yaml +19 -0
- package/engines/specs/matichon.yaml +43 -0
- package/engines/specs/met_museum.yaml +21 -0
- package/engines/specs/nasa_images.yaml +45 -0
- package/engines/specs/netease_music.yaml +38 -0
- package/engines/specs/nhk.yaml +43 -0
- package/engines/specs/nhtsa_vpic.yaml +20 -0
- package/engines/specs/noaa_swpc.yaml +20 -0
- package/engines/specs/nvd.yaml +38 -0
- package/engines/specs/obis.yaml +20 -0
- package/engines/specs/opencitations.yaml +52 -0
- package/engines/specs/openf1.yaml +21 -0
- package/engines/specs/openfoodfacts.yaml +45 -0
- package/engines/specs/openligadb.yaml +20 -0
- package/engines/specs/openreview.yaml +37 -0
- package/engines/specs/openstd.yaml +21 -0
- package/engines/specs/people_daily.yaml +22 -0
- package/engines/specs/realtime_index.yaml +34 -0
- package/engines/specs/ror.yaml +20 -0
- package/engines/specs/satnogs.yaml +20 -0
- package/engines/specs/sec_edgar.yaml +46 -0
- package/engines/specs/skillsmp.yaml +50 -0
- package/engines/specs/sogou_weixin_browser.yaml +30 -0
- package/engines/specs/soilgrids.yaml +25 -0
- package/engines/specs/sspai.yaml +36 -0
- package/engines/specs/stackexchange.yaml +46 -0
- package/engines/specs/std_samr.yaml +21 -0
- package/engines/specs/tass.yaml +43 -0
- package/engines/specs/tle_mirror.yaml +21 -0
- package/engines/specs/train.yaml +37 -0
- package/engines/specs/tvmaze.yaml +20 -0
- package/engines/specs/twitter_syndication.yaml +31 -0
- package/engines/specs/un_comtrade.yaml +23 -0
- package/engines/specs/unpaywall.yaml +54 -0
- package/engines/specs/vnpress.yaml +43 -0
- package/engines/specs/weather.yaml +32 -0
- package/engines/specs/who_don.yaml +21 -0
- package/engines/specs/who_gho.yaml +20 -0
- package/engines/specs/wikimedia_commons.yaml +34 -0
- package/engines/specs/wikisource.yaml +22 -0
- package/engines/specs/worms.yaml +19 -0
- package/engines/specs/x_trending.yaml +34 -0
- package/engines/specs/yna.yaml +43 -0
- package/engines/specs/ynet.yaml +43 -0
- package/engines/specs/zbmath.yaml +40 -0
- package/engines/specs/zdic.yaml +22 -0
- package/engines/specs/zenodo.yaml +27 -0
- package/engines/specs/zhihu_hot_app.yaml +35 -0
- package/mcp/clients.yaml +74 -0
- package/package.json +51 -12
- package/packages/dsh-plugin/README.md +134 -0
- package/packages/dsh-plugin/cordis.patch.yml +48 -0
- package/packages/dsh-plugin/dsh/client.js +1244 -0
- package/packages/dsh-plugin/dsh/index.js +2290 -0
- package/packages/dsh-plugin/dsh/native-tools.mjs +743 -0
- package/packages/dsh-plugin/package.json +38 -0
- package/packages/dsh-plugin/test/client.test.js +232 -0
- package/packages/dsh-plugin/test/config.test.js +30 -0
- package/packages/dsh-plugin/test/index.test.js +1338 -0
- package/references/query-templates.md +96 -0
- package/references/query-templates.yaml +118 -0
- package/scripts/ab_eval.py +219 -0
- package/scripts/ab_eval_p0p1.py +266 -0
- package/scripts/adaptive.py +271 -70
- package/scripts/answer.py +154 -0
- package/scripts/archive_run.py +591 -0
- package/scripts/argo_engine_registry.py +172 -0
- package/scripts/argo_paths.py +728 -0
- package/scripts/article.py +136 -0
- package/scripts/arxiv_source.py +201 -0
- package/scripts/badcase_ledger.py +252 -0
- package/scripts/batch_probe.py +241 -0
- package/scripts/benchmark.py +19 -0
- package/scripts/bounded_run.py +144 -0
- package/scripts/browser_auth.py +323 -0
- package/scripts/burstiness_detector.py +147 -0
- package/scripts/cache.py +1078 -67
- package/scripts/cache_guard.py +271 -0
- package/scripts/cache_key_vdom.py +56 -0
- package/scripts/cache_similarity.py +170 -0
- package/scripts/caliber_isolation.py +195 -0
- package/scripts/calibrate_lowquality.py +210 -0
- package/scripts/candidate_envelope.py +366 -0
- package/scripts/chrome_cdp.py +871 -0
- package/scripts/circuit_breaker.py +389 -0
- package/scripts/citations.py +334 -0
- package/scripts/cite_cli.py +59 -0
- package/scripts/cjk_tokens.py +95 -0
- package/scripts/clarify.py +47 -24
- package/scripts/cli_io.py +240 -0
- package/scripts/config.py +683 -45
- package/scripts/config_cache.py +402 -0
- package/scripts/conn_pool.py +220 -0
- package/scripts/content_security.py +685 -0
- package/scripts/content_signals.py +730 -0
- package/scripts/crawl.py +74 -35
- package/scripts/daemon.py +866 -0
- package/scripts/digup_bridge.py +214 -0
- package/scripts/engine_admission.py +366 -0
- package/scripts/engine_dispatch.py +1019 -0
- package/scripts/engine_env.py +534 -0
- package/scripts/engine_failure.py +411 -0
- package/scripts/engine_families.py +732 -0
- package/scripts/engine_policy.py +218 -0
- package/scripts/engine_requires.py +190 -0
- package/scripts/engine_status.py +322 -0
- package/scripts/engine_validate.py +638 -0
- package/scripts/engines.py +778 -294
- package/scripts/engines_base.py +1521 -0
- package/scripts/engines_builders.py +298 -0
- package/scripts/engines_builders_batch10.py +159 -0
- package/scripts/engines_builders_batch11.py +174 -0
- package/scripts/engines_builders_batch12.py +103 -0
- package/scripts/engines_builders_batch13.py +340 -0
- package/scripts/engines_builders_batch9.py +1468 -0
- package/scripts/engines_builders_cn.py +2283 -0
- package/scripts/engines_builders_data.py +2923 -0
- package/scripts/engines_builders_data_macro.py +775 -0
- package/scripts/engines_builders_feeds.py +255 -0
- package/scripts/engines_builders_intl.py +1239 -0
- package/scripts/engines_builders_search.py +440 -0
- package/scripts/engines_builders_tech.py +1360 -0
- package/scripts/evidence.py +588 -162
- package/scripts/evidence_loop.py +592 -0
- package/scripts/evidence_tier.py +298 -0
- package/scripts/except_sets.py +25 -0
- package/scripts/extract.py +55 -16
- package/scripts/fact_align.py +207 -0
- package/scripts/fact_crosscheck.py +230 -0
- package/scripts/fetch.py +25 -12
- package/scripts/fetch_browser.py +87 -0
- package/scripts/fetch_jsrun.py +122 -0
- package/scripts/fetch_quality.py +225 -0
- package/scripts/fetch_render_tinyfish.py +117 -0
- package/scripts/fetch_v3.py +1779 -0
- package/scripts/focus_extract.py +350 -0
- package/scripts/fulltext_store.py +169 -0
- package/scripts/fxtwitter_api.py +325 -0
- package/scripts/gen_engine_catalog.py +364 -0
- package/scripts/gen_native_tools.py +91 -0
- package/scripts/gen_routing_snapshot.py +184 -0
- package/scripts/health_check.py +140 -402
- package/scripts/health_probe.py +23 -8
- package/scripts/hot_state.py +134 -0
- package/scripts/http_client.py +1053 -0
- package/scripts/image_ops.py +430 -0
- package/scripts/image_vision/vision_probe +0 -0
- package/scripts/image_vision/vision_probe.swift +126 -0
- package/scripts/install.ps1 +148 -0
- package/scripts/install.sh +206 -0
- package/scripts/job.py +1186 -0
- package/scripts/jump_resolver.py +235 -0
- package/scripts/lang_capability.py +150 -0
- package/scripts/lang_detect.py +289 -0
- package/scripts/lang_matrix_probe.py +329 -0
- package/scripts/lang_pref.py +311 -0
- package/scripts/link_source.py +340 -0
- package/scripts/local_engines.py +321 -0
- package/scripts/local_image.py +970 -0
- package/scripts/local_seek.py +366 -0
- package/scripts/macro_countries.py +82 -0
- package/scripts/matrix_search_eval.py +1105 -0
- package/scripts/mcp_diag.py +8 -3
- package/scripts/mcp_handlers.py +1074 -0
- package/scripts/mcp_handlers_surface.py +157 -0
- package/scripts/mcp_launch.sh +122 -0
- package/scripts/mcp_server.py +118 -417
- package/scripts/mcp_setup.py +407 -0
- package/scripts/mcp_tools.py +330 -0
- package/scripts/mcp_transport.py +191 -0
- package/scripts/min_version_scan.py +335 -0
- package/scripts/net_proxy.py +258 -0
- package/scripts/network_aware.py +152 -0
- package/scripts/paper.py +274 -0
- package/scripts/pdf_extract.py +518 -0
- package/scripts/phrase_filter.py +66 -0
- package/scripts/plan.py +422 -0
- package/scripts/postprocess_signals.py +150 -0
- package/scripts/pre-commit +48 -0
- package/scripts/query_enhance.py +157 -0
- package/scripts/query_operators.py +196 -0
- package/scripts/query_rewriter.py +116 -7
- package/scripts/query_signals.py +300 -0
- package/scripts/query_templates.py +167 -0
- package/scripts/query_understanding.py +411 -0
- package/scripts/query_variants.py +298 -0
- package/scripts/quota.py +488 -76
- package/scripts/rank_signals.py +775 -0
- package/scripts/ranking_eval.py +319 -0
- package/scripts/readability_extract.py +433 -0
- package/scripts/recompute.py +415 -0
- package/scripts/recovery.py +616 -0
- package/scripts/recovery_l0.py +160 -0
- package/scripts/redskill/redskill_engine.py +332 -0
- package/scripts/regression_p0p1.py +318 -0
- package/scripts/replay_eval.py +640 -0
- package/scripts/rerank_tfidf.py +225 -0
- package/scripts/research.py +591 -403
- package/scripts/research_cli.py +361 -0
- package/scripts/research_dossier.py +492 -0
- package/scripts/research_expand.py +146 -0
- package/scripts/research_gates.py +272 -0
- package/scripts/research_report.py +209 -0
- package/scripts/research_strategy.py +37 -0
- package/scripts/research_work_packages.py +195 -0
- package/scripts/result_lang.py +311 -0
- package/scripts/robots_guard.py +259 -0
- package/scripts/route.py +921 -254
- package/scripts/route_bangs.py +40 -0
- package/scripts/route_cache.py +225 -0
- package/scripts/route_combo.py +910 -0
- package/scripts/route_domains.py +328 -0
- package/scripts/route_lang.py +605 -0
- package/scripts/route_log.py +63 -0
- package/scripts/route_policy.py +250 -0
- package/scripts/rrf_classic.py +66 -0
- package/scripts/screenshot.py +73 -0
- package/scripts/search.py +897 -405
- package/scripts/search_attribution.py +88 -0
- package/scripts/search_benchmark.py +407 -0
- package/scripts/search_cli.py +627 -0
- package/scripts/search_entry.py +389 -0
- package/scripts/search_output.py +606 -0
- package/scripts/search_pipeline.py +1077 -0
- package/scripts/search_rank.py +991 -0
- package/scripts/search_types.py +27 -12
- package/scripts/seek_locator.py +68 -0
- package/scripts/semantic_evidence.py +215 -0
- package/scripts/serp_guard.py +228 -0
- package/scripts/serp_kill_log.py +51 -0
- package/scripts/single_flight.py +120 -0
- package/scripts/social_engines/__init__.py +10 -0
- package/scripts/social_engines/bilibili_engine.py +131 -0
- package/scripts/social_engines/hackernews_engine.py +113 -0
- package/scripts/social_engines/reddit_engine.py +172 -0
- package/scripts/social_engines/twitter_engine.py +397 -0
- package/scripts/social_engines/v2ex_engine.py +101 -0
- package/scripts/social_engines/weibo_engine.py +128 -0
- package/scripts/social_engines/xiaohongshu_engine.py +137 -0
- package/scripts/social_engines/zhihu_engine.py +139 -0
- package/scripts/social_research.py +201 -0
- package/scripts/sogou_weixin_browser.py +318 -0
- package/scripts/source_ledger.py +249 -0
- package/scripts/stage_timing.py +58 -0
- package/scripts/stats_cli.py +95 -0
- package/scripts/straggler_policy.py +60 -0
- package/scripts/stylometry_detector.py +231 -0
- package/scripts/sync_backends.py +353 -0
- package/scripts/sync_installs.py +29 -0
- package/scripts/template_cli.py +127 -0
- package/scripts/text_lexicon.py +72 -0
- package/scripts/tfidf_router.py +109 -37
- package/scripts/time_utils.py +233 -0
- package/scripts/topic_research_profiles.py +643 -0
- package/scripts/train.py +737 -0
- package/scripts/tweet.py +596 -0
- package/scripts/url_canon.py +217 -0
- package/scripts/url_optimize.py +56 -0
- package/scripts/url_safety.py +236 -0
- package/scripts/usage_log.py +116 -0
- package/scripts/usage_log_clean.py +113 -0
- package/scripts/v2ex_nodes.py +311 -0
- package/scripts/variant_recall.py +188 -0
- package/scripts/watch.py +205 -0
- package/scripts/wechat_article.py +258 -0
- package/scripts/wide_research.py +402 -0
- package/scripts/wx.py +349 -0
- package/scripts/x_article_fetch.py +141 -0
- package/scripts/x_search.py +234 -0
- package/scripts/x_trending.py +189 -0
- package/scripts/yaml_load.py +67 -0
- package/sub-skills/ego-search/SKILL.md +253 -0
- package/sub-skills/ego-search/references/browser-runtime.md +43 -0
- package/sub-skills/ego-search/references/install.md +67 -0
- package/sub-skills/ego-search/references/original-ego-upgrade.md +51 -0
- package/sub-skills/ego-search/scripts/ego_search.py +800 -0
- package/sub-skills/ego-search/scripts/merge.py +226 -0
- package/sub-skills/ego-search/scripts/quality.py +64 -0
- package/sub-skills/ego-search/scripts/runtime.py +172 -0
- package/sub-skills/ego-search/scripts/safety.py +109 -0
- package/sub-skills/ego-search/scripts/serp_spec.py +109 -0
- package/sub-skills/ego-search/scripts/webbridge_adapter.py +374 -0
- package/sub-skills/ego-search/tests/test_query_normalize.py +68 -0
- package/sub-skills/js-run/SKILL.md +67 -0
- package/sub-skills/js-run/scripts/jsrun.py +112 -0
- package/sub-skills/js-run/scripts/shim.js +188 -0
- package/sub-skills/js-run/tests/fixtures/challenge_like.js +27 -0
- package/sub-skills/js-run/tests/test_jsrun.py +165 -0
- package/sub-skills/local-search/SKILL.md +50 -53
- package/sub-skills/local-search/bm25.py +162 -0
- package/sub-skills/local-search/config.yaml +152 -55
- package/sub-skills/local-search/engine_registry.py +91 -26
- package/sub-skills/local-search/{health_check.py → local_health_check.py} +140 -10
- package/sub-skills/local-search/local_search_adapter.py +13 -2
- package/sub-skills/local-search/parse_maps.yaml +40 -42
- package/sub-skills/local-search/search_v3.py +1182 -65
- package/sub-skills/local-search/smart_router.py +47 -7
- package/sub-skills/local-seek/SKILL.md +110 -0
- package/sub-skills/local-seek/config/domains.yaml +20 -0
- package/sub-skills/local-seek/references/auto-behaviors.md +24 -0
- package/sub-skills/local-seek/references/fd-mdfind-recipes.md +62 -0
- package/sub-skills/local-seek/references/git-integration.md +54 -0
- package/sub-skills/local-seek/references/remote-handoff.md +46 -0
- package/sub-skills/local-seek/references/rg-recipes.md +96 -0
- package/sub-skills/local-seek/references/strategies.md +52 -0
- package/sub-skills/local-seek/references/structural-search.md +59 -0
- package/sub-skills/local-seek/scripts/eval_rank.py +216 -0
- package/sub-skills/local-seek/scripts/eval_seek.py +224 -0
- package/sub-skills/local-seek/scripts/seek.py +1465 -0
- package/sub-skills/local-seek/scripts/seek_frecency.py +126 -0
- package/sub-skills/local-seek/scripts/seek_fts.py +430 -0
- package/sub-skills/local-seek/scripts/seek_rank.py +193 -0
- package/sub-skills/local-seek/tests/test_content_rank.py +222 -0
- package/sub-skills/local-seek/tests/test_determinism_20261006.py +145 -0
- package/sub-skills/local-seek/tests/test_noise_tier.py +101 -0
- package/sub-skills/local-seek/tests/test_pcre2_probe.py +136 -0
- package/sub-skills/local-seek/tests/test_rank_pinyin.py +77 -0
- package/sub-skills/local-seek/tests/test_regression_20261003.py +169 -0
- package/sub-skills/local-seek/tests/test_regression_20261006.py +146 -0
- package/sub-skills/local-seek/tests/test_seek_degradation.py +111 -0
- package/sub-skills/local-seek/tests/test_seek_fts.py +313 -0
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""image_ops.py — 图片结果的后处理:去重、可用性判据、拼图。
|
|
3
|
+
|
|
4
|
+
## 守的是什么
|
|
5
|
+
|
|
6
|
+
搜索结果里的图片此前没有任何图片维度处理:`search_rank` 有 URL 去重
|
|
7
|
+
(`deduplicate_by_url`)和文本近重复去重(`minhash_dedupe`),但两者都只看
|
|
8
|
+
**页面**的标题/正文/URL,对图本身一无所知。带来的实际损失有两类:
|
|
9
|
+
|
|
10
|
+
1. **同一张图占多个位**。同一素材在不同 CDN 上的 URL 不同(`p0.so.qhimg.com`
|
|
11
|
+
与 `p0.ssl.qhimgs1.com`、带 `?x-oss-process=style/watermark` 与不带),
|
|
12
|
+
URL 去重和文本去重都判不出是同一张,于是一次搜索的 6 条结果里 3 条是
|
|
13
|
+
同一张图。
|
|
14
|
+
2. **不可用的图进了结果**。没有分辨率下限、没有授权判据,用户手动翻完才
|
|
15
|
+
发现有坑——而这两件事在字段层面就能判定,不需要看图。
|
|
16
|
+
|
|
17
|
+
## 为什么不用感知哈希(dHash/pHash)
|
|
18
|
+
|
|
19
|
+
做图片去重最直觉的选择是感知哈希,但那要求**先把图片下载下来**才能算哈希。
|
|
20
|
+
放在搜索路径上是灾难性的:一次搜索 20 条结果就是 20 次图片下载(首字节 +
|
|
21
|
+
全量传输),把亚秒级查询变成十几秒。
|
|
22
|
+
|
|
23
|
+
搜索场景其实不需要感知哈希——重复的图**在字段层面就有强信号**:
|
|
24
|
+
- 同一个图片直链(去掉 CDN 的尺寸/水印变换参数后相同)
|
|
25
|
+
- 不同主机上的同一路径(同一文件被多 CDN 分发)
|
|
26
|
+
- 相同文件名 + 相同像素尺寸(同图重传)
|
|
27
|
+
这三类都是纯字符串比较。真正需要感知哈希的场景是「本地相册去重」(图已在
|
|
28
|
+
本地,无 URL 可比),那是另一个功能,见 local_image.py。
|
|
29
|
+
|
|
30
|
+
## 许可判据从哪来
|
|
31
|
+
|
|
32
|
+
`engines_base._license_allows_commercial` 在引擎输出层算好了
|
|
33
|
+
`image_commercial_ok`(True/False/None 三值)。本模块只做**消费**,
|
|
34
|
+
不重新判定——两处各判一次必然漂移。
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import re
|
|
40
|
+
from typing import Any
|
|
41
|
+
from urllib.parse import urlsplit
|
|
42
|
+
|
|
43
|
+
# CDN 的图片变换参数:去掉后同一文件的不同尺寸/水印变体才归一到同一个键。
|
|
44
|
+
# 清单来源是实测遇到的(360 图的 qhimg、阿里 OSS 的 x-oss-process、
|
|
45
|
+
# 新浪的 resize、Cloudinary 的 w_/h_ 前缀)。
|
|
46
|
+
_IMG_TRANSFORM_PARAMS = re.compile(
|
|
47
|
+
r"^(?:x-oss-process|imageview|image_view|resize|w|h|width|height|quality|q|"
|
|
48
|
+
r"format|fit|crop|thumbnail|thumb|size|scale|dpr|auto|ixlib|s)$",
|
|
49
|
+
re.I,
|
|
50
|
+
)
|
|
51
|
+
# 路径里的尺寸段:/480x320/、/w_800/、/_800x/、/thumb/、/preview/
|
|
52
|
+
_IMG_SIZE_PATH_SEG = re.compile(
|
|
53
|
+
r"^(?:\d{2,5}x\d{2,5}|w_\d+|h_\d+|_\d+x\d*|\d+x\d*_|thumb(?:s|nail)?|"
|
|
54
|
+
r"preview|small|medium|large|orig(?:inal)?|full)$",
|
|
55
|
+
re.I,
|
|
56
|
+
)
|
|
57
|
+
# 图片扩展名(判断一个 URL 是不是图片直链)
|
|
58
|
+
IMAGE_EXTS = frozenset(
|
|
59
|
+
(".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tif", ".tiff",
|
|
60
|
+
".heic", ".heif", ".avif", ".svg", ".jfif")
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# 可用性阈值。定的是「素材能不能用」的下限而不是审美标准:
|
|
64
|
+
# 800x600 是 PPT/公众号配图的常见可用下限,低于这个尺寸放大就糊;
|
|
65
|
+
# 16:1 以上(或 1:16 以下)通常是横幅/通栏素材,作为普通配图会裁得很难看。
|
|
66
|
+
# 两个阈值都可通过环境变量覆盖——不同用途(图标 vs 印刷)标准不同,
|
|
67
|
+
# 写死会让一类用途永远拿不到结果。
|
|
68
|
+
MIN_USABLE_EDGE = 300 # 最短边的绝对下限(再小基本是图标/表情)
|
|
69
|
+
MIN_USABLE_AREA = 800 * 600 # 面积下限
|
|
70
|
+
MAX_ASPECT_RATIO = 16.0 # 长宽比上限
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def image_key(image_url: str) -> str:
|
|
74
|
+
"""图片直链 → 跨 CDN 可比的归一键。
|
|
75
|
+
|
|
76
|
+
归一三件事:主机去 `www.`/`ssl.`/`img.` 这类纯技术前缀、路径里的尺寸段
|
|
77
|
+
删掉、变换类查询参数删掉。剩下的主体(`/t040f7e0e65116a8110.jpg`)才是
|
|
78
|
+
标识「哪张图」的部分。
|
|
79
|
+
"""
|
|
80
|
+
raw = str(image_url or "").strip()
|
|
81
|
+
if not raw:
|
|
82
|
+
return ""
|
|
83
|
+
try:
|
|
84
|
+
parts = urlsplit(raw)
|
|
85
|
+
except ValueError:
|
|
86
|
+
return raw.lower()
|
|
87
|
+
host = (parts.hostname or "").lower()
|
|
88
|
+
for prefix in ("www.", "ssl.", "img.", "cdn.", "static.", "s."):
|
|
89
|
+
if host.startswith(prefix):
|
|
90
|
+
host = host[len(prefix):]
|
|
91
|
+
break
|
|
92
|
+
segs = []
|
|
93
|
+
for seg in (parts.path or "").split("/"):
|
|
94
|
+
if not seg or _IMG_SIZE_PATH_SEG.match(seg):
|
|
95
|
+
continue
|
|
96
|
+
segs.append(seg.lower())
|
|
97
|
+
# 查询串只保留非变换参数并排序,避免参数顺序造成两个键
|
|
98
|
+
kept = []
|
|
99
|
+
for pair in (parts.query or "").split("&"):
|
|
100
|
+
if not pair:
|
|
101
|
+
continue
|
|
102
|
+
k = pair.split("=", 1)[0]
|
|
103
|
+
if k and not _IMG_TRANSFORM_PARAMS.match(k):
|
|
104
|
+
kept.append(pair.lower())
|
|
105
|
+
key = f"{host}/{'/'.join(segs)}"
|
|
106
|
+
return f"{key}?{'&'.join(sorted(kept))}" if kept else key
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def looks_like_image_url(url: str) -> bool:
|
|
110
|
+
"""URL 是否像图片直链(扩展名判定,不发起请求)。"""
|
|
111
|
+
raw = str(url or "").strip()
|
|
112
|
+
if not raw:
|
|
113
|
+
return False
|
|
114
|
+
try:
|
|
115
|
+
path = (urlsplit(raw).path or "").lower()
|
|
116
|
+
except ValueError:
|
|
117
|
+
path = raw.lower()
|
|
118
|
+
dot = path.rfind(".")
|
|
119
|
+
return dot > 0 and path[dot:] in IMAGE_EXTS
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def deduplicate_images(results: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], int]:
|
|
123
|
+
"""图片级去重:同一张图的多个 URL 只留首条。
|
|
124
|
+
|
|
125
|
+
在 URL 去重与文本去重之后跑(那是页面粒度,这是图片粒度)。判定顺序
|
|
126
|
+
从强到弱:
|
|
127
|
+
1. image_key 相同 —— 同一文件(含变换参数变体)
|
|
128
|
+
2. 文件名 + 像素尺寸都相同 —— 同文件重传,主机不同
|
|
129
|
+
|
|
130
|
+
返回 (deduped, removed)。被移除条目标 `_image_dup=True`,与
|
|
131
|
+
`_near_dup`(文本近重复)区分开——两者成因不同,混用一个标记会让
|
|
132
|
+
「为什么少了一条」无法归因。
|
|
133
|
+
"""
|
|
134
|
+
kept: list[dict[str, Any]] = []
|
|
135
|
+
seen_keys: set[str] = set()
|
|
136
|
+
seen_name_dims: set[tuple[str, str]] = set()
|
|
137
|
+
removed = 0
|
|
138
|
+
for r in results:
|
|
139
|
+
if not isinstance(r, dict):
|
|
140
|
+
continue
|
|
141
|
+
img = str(r.get("image_url") or "").strip()
|
|
142
|
+
if not img:
|
|
143
|
+
# 无图条目直接通过:本函数只负责图片重复,不带图的结果是另一类
|
|
144
|
+
# 问题(无图),由可用性过滤处理,不在这里丢弃。
|
|
145
|
+
kept.append(r)
|
|
146
|
+
continue
|
|
147
|
+
key = image_key(img)
|
|
148
|
+
name = key.rsplit("/", 1)[-1].split("?")[0]
|
|
149
|
+
dims = f"{r.get('image_width', '')}x{r.get('image_height', '')}"
|
|
150
|
+
nd = (name, dims) if name and "x" in dims and dims != "x" else None
|
|
151
|
+
if key and key in seen_keys:
|
|
152
|
+
removed += 1
|
|
153
|
+
r["_image_dup"] = True
|
|
154
|
+
continue
|
|
155
|
+
# 文件名+尺寸判定只在文件名足够长时启用:`1.jpg`、`image.png` 这类
|
|
156
|
+
# 通用名会误杀不同图,大量 CDN 用这种命名。
|
|
157
|
+
if nd and len(name) >= 8 and nd in seen_name_dims:
|
|
158
|
+
removed += 1
|
|
159
|
+
r["_image_dup"] = True
|
|
160
|
+
continue
|
|
161
|
+
kept.append(r)
|
|
162
|
+
if key:
|
|
163
|
+
seen_keys.add(key)
|
|
164
|
+
if nd and len(name) >= 8:
|
|
165
|
+
seen_name_dims.add(nd)
|
|
166
|
+
return kept, removed
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def image_usability(r: dict[str, Any]) -> tuple[bool, str]:
|
|
170
|
+
"""单条结果的图片可用性判定 → (可用?, 原因码)。
|
|
171
|
+
|
|
172
|
+
只做**字段层面**能确定的事,不下审美判断:
|
|
173
|
+
- `no_image`:没有图片直链
|
|
174
|
+
- `too_small`:尺寸已知且低于下限
|
|
175
|
+
- `extreme_aspect`:长宽比极端(横幅/通栏素材)
|
|
176
|
+
- `not_commercial`:授权明确为非商用
|
|
177
|
+
- `ok`:通过
|
|
178
|
+
|
|
179
|
+
尺寸未知(上游没给)**不判为不可用**:拿不到尺寸不等于图小,判成不可用
|
|
180
|
+
会把一整批没提供尺寸字段的源误杀。这类条目按「未知」放行,让模型在
|
|
181
|
+
候选阶段自行判断。
|
|
182
|
+
"""
|
|
183
|
+
if not str(r.get("image_url") or "").strip():
|
|
184
|
+
return False, "no_image"
|
|
185
|
+
w, h = r.get("image_width"), r.get("image_height")
|
|
186
|
+
if isinstance(w, int) and isinstance(h, int) and w > 0 and h > 0:
|
|
187
|
+
# 长宽比先判:4000x150 的横幅同时满足「某边过小」和「比例极端」,
|
|
188
|
+
# 但两者给用户的行动建议不同(前者去找更大的图,后者说明这是通栏
|
|
189
|
+
# 素材、不适合当普通配图)。报更具体的那一个,否则诊断信息误导人。
|
|
190
|
+
ratio = max(w / h, h / w)
|
|
191
|
+
if ratio > MAX_ASPECT_RATIO:
|
|
192
|
+
return False, "extreme_aspect"
|
|
193
|
+
if min(w, h) < MIN_USABLE_EDGE:
|
|
194
|
+
return False, "too_small"
|
|
195
|
+
if w * h < MIN_USABLE_AREA:
|
|
196
|
+
return False, "too_small"
|
|
197
|
+
if r.get("image_commercial_ok") is False:
|
|
198
|
+
return False, "not_commercial"
|
|
199
|
+
return True, "ok"
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def filter_usable_images(
|
|
203
|
+
results: list[dict[str, Any]], *, require_commercial: bool = False,
|
|
204
|
+
) -> tuple[list[dict[str, Any]], dict[str, int]]:
|
|
205
|
+
"""筛掉字段层面即可判定不可用的图片结果。
|
|
206
|
+
|
|
207
|
+
`require_commercial=True` 时才把非商用图剔掉——默认只标注不删除。原因:
|
|
208
|
+
「搜图」和「找能发布的素材」是两个需求,前者不该被授权过滤误杀(用户
|
|
209
|
+
可能只是要看看某张图长什么样)。默认放行、按需收紧。
|
|
210
|
+
|
|
211
|
+
返回 (kept, 原因计数)。原因计数用于解释「为什么少了 N 条」——静默丢弃
|
|
212
|
+
是搜索类工具最招人恨的行为。
|
|
213
|
+
"""
|
|
214
|
+
kept: list[dict[str, Any]] = []
|
|
215
|
+
dropped: dict[str, int] = {}
|
|
216
|
+
for r in results:
|
|
217
|
+
if not isinstance(r, dict):
|
|
218
|
+
continue
|
|
219
|
+
ok, reason = image_usability(r)
|
|
220
|
+
if ok or (reason == "not_commercial" and not require_commercial):
|
|
221
|
+
kept.append(r)
|
|
222
|
+
continue
|
|
223
|
+
dropped[reason] = dropped.get(reason, 0) + 1
|
|
224
|
+
return kept, dropped
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def has_image_result(results: list[dict[str, Any]]) -> bool:
|
|
228
|
+
"""结果集里是否含图片条目(用于判断这是不是一次图片检索)。"""
|
|
229
|
+
return any(str(r.get("image_url") or "").strip()
|
|
230
|
+
for r in results if isinstance(r, dict))
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# 声明式 output_map 的图片字段映射。新增一个图片元数据字段时只改这里,
|
|
234
|
+
# 不让「契约有哪些键」散在解析器内部;`_license_version` 前缀下划线表示
|
|
235
|
+
# 它是归一化的拼接原料,不是交付字段(见 finalize_image_fields)。
|
|
236
|
+
def image_output_map_fields(output_map: dict[str, Any]) -> dict[str, str]:
|
|
237
|
+
"""output_map → 解析器字段表(图片相关部分)。"""
|
|
238
|
+
return {
|
|
239
|
+
"image_url": output_map.get("item_image", ""),
|
|
240
|
+
"image_license": output_map.get("item_image_license", ""),
|
|
241
|
+
"image_license_url": output_map.get("item_image_license_url", ""),
|
|
242
|
+
"_license_version": output_map.get("item_image_license_version", ""),
|
|
243
|
+
"image_width": output_map.get("item_image_width", ""),
|
|
244
|
+
"image_height": output_map.get("item_image_height", ""),
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# CLI 引擎结果的透传字段白名单。
|
|
249
|
+
#
|
|
250
|
+
# 通用文本解析此前只放行 title/url/snippet/source/published_at,其余一律丢弃。
|
|
251
|
+
# 对纯网页检索够了,但**图片类子引擎的产出恰好全在白名单外**:search_v3 的
|
|
252
|
+
# ddgs images 已把 image/thumbnail/width/height 解析出来(见
|
|
253
|
+
# sub-skills/local-search/search_v3.py 的 `_parse_cli_json`),到这一层又被
|
|
254
|
+
# 无声删掉——实测 `argo search --engine local_search` 的 image 与 image_url
|
|
255
|
+
# 双为 null。
|
|
256
|
+
#
|
|
257
|
+
# 白名单而非全透传:CLI 能吐任意字段(含 `_elapsed` 这类内部计时),全透传会
|
|
258
|
+
# 把内部状态混进交付结果。这里列的是**已确立的对外契约字段**。
|
|
259
|
+
CLI_PASSTHROUGH_FIELDS = (
|
|
260
|
+
"image", "thumbnail", "width", "height", "duration", "provider",
|
|
261
|
+
"image_url", "image_license", "image_license_url",
|
|
262
|
+
"image_width", "image_height", "image_commercial_ok",
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _cli_result_row(item: dict[str, Any], engine_name: str) -> dict[str, Any]:
|
|
267
|
+
"""CLI 引擎单条结果 → argo 结果契约(含图片字段透传与别名收敛)。"""
|
|
268
|
+
r: dict[str, Any] = {
|
|
269
|
+
"title": item.get("title", ""),
|
|
270
|
+
"url": item.get("url", ""),
|
|
271
|
+
# `dict.get(k, default)` 在键存在但值为 None 时**返回 None**(不吃
|
|
272
|
+
# default)——CLI 引擎显式输出 "snippet": null 时,旧写法会渲染出
|
|
273
|
+
# 字面量字符串 "None" 进交付结果。用 or 链吃 None 与空串两种。
|
|
274
|
+
"snippet": str(item.get("snippet") or item.get("content") or "")[:300],
|
|
275
|
+
"source": engine_name,
|
|
276
|
+
}
|
|
277
|
+
if item.get("published_at"):
|
|
278
|
+
r["published_at"] = str(item["published_at"])[:64]
|
|
279
|
+
for k in CLI_PASSTHROUGH_FIELDS:
|
|
280
|
+
v = item.get(k)
|
|
281
|
+
if v not in (None, ""):
|
|
282
|
+
r[k] = v
|
|
283
|
+
# 别名收敛:子技能用 `image`(ddgs 原生键名),argo 主契约用 `image_url`。
|
|
284
|
+
# 宽高同理——ddgs 给裸名 `width`/`height`,而可用性判据读 `image_width`。
|
|
285
|
+
# 不收敛的话过滤拿到 None,判定为「尺寸未知」一律放行,且是静默的。
|
|
286
|
+
if r.get("image") and not r.get("image_url"):
|
|
287
|
+
r["image_url"] = r["image"]
|
|
288
|
+
if r.get("width") and not r.get("image_width"):
|
|
289
|
+
r["image_width"] = r["width"]
|
|
290
|
+
if r.get("height") and not r.get("image_height"):
|
|
291
|
+
r["image_height"] = r["height"]
|
|
292
|
+
return r
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# 授权标注 → 商用许可判定。
|
|
297
|
+
#
|
|
298
|
+
# 为什么要在这一层判而不是让用户自己看标注:上游给的是六七种互不相同的写法
|
|
299
|
+
# (Openverse 的 `by-nc-sa`、Cleveland 的 `CC0`、Wikimedia 的 `CC BY-SA 4.0`、
|
|
300
|
+
# NASA 的中文「公有领域」),用户真正要回答的问题只有一个——「这张图我能不能
|
|
301
|
+
# 用在要发布的稿子里」。让人逐条读协议名再自己判断,等于把最容易出错的一步
|
|
302
|
+
# 留给用户。
|
|
303
|
+
#
|
|
304
|
+
# 三值而非布尔:**无法判定时必须说不知道**。上游许可字段缺失、写成自有术语
|
|
305
|
+
# (Artic 的「权利状态见作品页」)时,猜成「可商用」会闯版权祸,猜成「不可」
|
|
306
|
+
# 会误杀可用的图。None 让下游按「未判定」处理(不排除、不担保)。
|
|
307
|
+
#
|
|
308
|
+
# 规则顺序敏感:非商用是最强的否定约束,必须先判——`by-nc-sa` 里含有 `by`,
|
|
309
|
+
# 先匹配自由许可就会把「不可商用」误判成「可商用」,这是本函数唯一会酿成
|
|
310
|
+
# 真事故的方向。
|
|
311
|
+
_LICENSE_NC_RE = re.compile(
|
|
312
|
+
r"(?:^|[^a-z])nc(?:[^a-z]|$)" # 短码 by-nc-sa / cc-by-nc-nd
|
|
313
|
+
r"|non[\s-]?commercial"
|
|
314
|
+
r"|非商业|非商用",
|
|
315
|
+
re.I,
|
|
316
|
+
)
|
|
317
|
+
_LICENSE_RESTRICTED_RE = re.compile(
|
|
318
|
+
r"all\s+rights\s+reserved|copyright(?:ed)?(?!\s*(?:status\s+)?(?:see|见|unknown))"
|
|
319
|
+
r"|保留所有权利|版权所有",
|
|
320
|
+
re.I,
|
|
321
|
+
)
|
|
322
|
+
_LICENSE_FREE_RE = re.compile(
|
|
323
|
+
r"cc0|cc[\s-]?0(?:\.\d+)?"
|
|
324
|
+
r"|public[\s-]?domain"
|
|
325
|
+
r"|公有领域|公共领域|无版权|不受版权保护"
|
|
326
|
+
r"|no\s+rights\s+reserved"
|
|
327
|
+
r"|(?:^|[^a-z])pd(?:[^a-z]|$)"
|
|
328
|
+
r"|cc[\s-]?by(?:[\s-]?sa)?(?:[\s-]?\d(?:\.\d+)?)?"
|
|
329
|
+
r"|(?:^|[^a-z])by(?:[\s-]?sa)?(?:[^a-z]|$)"
|
|
330
|
+
r"|apache[\s-]?2|(?:^|[^a-z])mit(?:[^a-z]|$)|bsd",
|
|
331
|
+
re.I,
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _license_allows_commercial(*candidates: str) -> bool | None:
|
|
336
|
+
"""从若干许可标注推断可否商用。返回 True / False / None(无法判定)。
|
|
337
|
+
|
|
338
|
+
传入多个候选(如 license 短码 + license_url + 人工标注)时任一命中即可
|
|
339
|
+
——图源给哪一路都不一定,但结论应当一致。
|
|
340
|
+
"""
|
|
341
|
+
texts = [str(c).strip() for c in candidates if c and str(c).strip()]
|
|
342
|
+
if not texts:
|
|
343
|
+
return None
|
|
344
|
+
joined = "\n".join(texts)
|
|
345
|
+
if _LICENSE_NC_RE.search(joined):
|
|
346
|
+
return False
|
|
347
|
+
if _LICENSE_RESTRICTED_RE.search(joined):
|
|
348
|
+
return False
|
|
349
|
+
for t in texts:
|
|
350
|
+
if _LICENSE_FREE_RE.search(t):
|
|
351
|
+
return True
|
|
352
|
+
return None
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
# CC 短码 → 标准标注。Openverse 给 `by-sa` + `license_version: 2.0` 两个字段,
|
|
356
|
+
# 拼出来才是人们认得的 `CC BY-SA 2.0`;单独给 `by-sa` 让用户对着猜是哪种协议
|
|
357
|
+
# 不合适,而 `CC BY-SA 2.0` 是能直接搜到条款原文的写法。
|
|
358
|
+
_CC_SHORTCODE_RE = re.compile(r"^(?:cc[\s-]?)?(by|by-sa|by-nc|by-nc-sa|by-nd|by-nc-nd|cc0|zero)$", re.I)
|
|
359
|
+
_CC_LABELS = {
|
|
360
|
+
"cc0": "CC0", "zero": "CC0",
|
|
361
|
+
"by": "CC BY", "by-sa": "CC BY-SA", "by-nd": "CC BY-ND",
|
|
362
|
+
"by-nc": "CC BY-NC", "by-nc-sa": "CC BY-NC-SA", "by-nc-nd": "CC BY-NC-ND",
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _normalize_license_label(*candidates: str) -> str:
|
|
367
|
+
"""把上游许可标注归一成标准写法;认不出来时原样保留首个候选。
|
|
368
|
+
|
|
369
|
+
保守原则:宁可不认,不可错认。把别的协议写成 `CC BY` 比不改更糟——用户
|
|
370
|
+
会据此做出发布决定。因此只在完全匹配已知短码或已是标准写法时才改写。
|
|
371
|
+
"""
|
|
372
|
+
texts = [str(c).strip() for c in candidates if c and str(c).strip()]
|
|
373
|
+
if not texts:
|
|
374
|
+
return ""
|
|
375
|
+
raw = texts[0]
|
|
376
|
+
# 短码优先于「已是标准写法」的放行分支。顺序反了会漏掉一个真实场景:
|
|
377
|
+
# Openverse 给的 `cc0` 是小写,宽松匹配会把它当成「已经是 CC0 标准写法」
|
|
378
|
+
# 原样放行,交付出去的标注与别处的 `CC0` 大小写不一致(同一协议两种写法,
|
|
379
|
+
# 用户以为不是一回事)。短码分支统一产出规范大小写。
|
|
380
|
+
m = _CC_SHORTCODE_RE.match(raw)
|
|
381
|
+
if m:
|
|
382
|
+
key = (m.group(1) or "").lower().replace(" ", "-")
|
|
383
|
+
label = _CC_LABELS.get(key)
|
|
384
|
+
if label:
|
|
385
|
+
ver = next((str(v).strip() for v in texts[1:]
|
|
386
|
+
if re.match(r"^\d+(\.\d+)?$", str(v).strip())), "")
|
|
387
|
+
return f"{label} {ver}".strip()
|
|
388
|
+
# 其余一律原样返回。这里刻意不猜:已带版本号的标准写法(Wikimedia 的
|
|
389
|
+
# `CC BY-SA 4.0`)和自有术语(Artic 的「权利状态见作品页」)都比我们能
|
|
390
|
+
# 生成的任何东西更准确,改写它们只会把正确信息改错。
|
|
391
|
+
return raw
|
|
392
|
+
|
|
393
|
+
def finalize_image_fields(
|
|
394
|
+
results: list[dict[str, Any]], spec_license: str = ""
|
|
395
|
+
) -> list[dict[str, Any]]:
|
|
396
|
+
"""收尾图片元数据:尺寸转 int、许可标注归一、算商用判据。
|
|
397
|
+
|
|
398
|
+
放在引擎输出的**共同出口**上调用,而不是各自的 parser 里——图源有三条产出
|
|
399
|
+
路径(声明式 output_map、自定义 builder 如 artic/cleveland/met、通用兜底),
|
|
400
|
+
只在其中一条上做,另外两条就会产出「有 image_url 但没有可用性判据」的条目,
|
|
401
|
+
下游拿不到统一契约。契约不统一的表现是静默的:过滤条件对一半结果生效。
|
|
402
|
+
|
|
403
|
+
幂等:重复调用不改变结果。
|
|
404
|
+
"""
|
|
405
|
+
for r in results:
|
|
406
|
+
if not isinstance(r, dict) or "error" in r:
|
|
407
|
+
continue
|
|
408
|
+
# 版本号只在拼接许可标注时用,先摘出来——它由 output_map 的
|
|
409
|
+
# item_image_license_version 产出,是原料不是结果字段,留在 r 里会
|
|
410
|
+
# 随结果一起交付出去(下划线前缀在本模块是「内部字段」约定)。
|
|
411
|
+
_ver = r.pop("_license_version", "")
|
|
412
|
+
for k in ("image_width", "image_height"):
|
|
413
|
+
if k in r and not isinstance(r[k], int):
|
|
414
|
+
try:
|
|
415
|
+
r[k] = int(float(str(r[k]).strip()))
|
|
416
|
+
except (TypeError, ValueError):
|
|
417
|
+
r.pop(k, None)
|
|
418
|
+
if not r.get("image_url"):
|
|
419
|
+
# 无图条目不该带授权结论:下游按 image_commercial_ok 过滤素材时,
|
|
420
|
+
# 会把「没图的条目」当成「有图的可用素材」,凭空多出一批假候选。
|
|
421
|
+
continue
|
|
422
|
+
_label = _normalize_license_label(r.get("image_license", ""), spec_license, _ver)
|
|
423
|
+
if _label:
|
|
424
|
+
r["image_license"] = _label
|
|
425
|
+
_ok = _license_allows_commercial(
|
|
426
|
+
r.get("image_license", ""), r.get("image_license_url", ""), spec_license
|
|
427
|
+
)
|
|
428
|
+
if _ok is not None:
|
|
429
|
+
r["image_commercial_ok"] = _ok
|
|
430
|
+
return results
|
|
Binary file
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// vision_probe.swift — 批次一次调用同时产出图像分类标签、OCR 文本与特征指纹。
|
|
2
|
+
//
|
|
3
|
+
// 为什么用 macOS 内置 Vision 而不是 CLIP:
|
|
4
|
+
// CLIP 路线(Chinese-CLIP + torch + FAISS)要装 ~2GB 依赖并从 HF 下 ~400MB
|
|
5
|
+
// 模型,建库以小时计。而本机目标只有一件事——**把 7 万张图收敛到几十张候选,
|
|
6
|
+
// 再交给多模态模型判断**。Vision 一次调用就给三样东西(分类标签、OCR 文本、
|
|
7
|
+
// 768 维特征指纹),零依赖、约 0.4 秒/张,足够完成收敛。
|
|
8
|
+
//
|
|
9
|
+
// 输出协议:每行一个 JSON 对象(JSONL),stdout。字段:
|
|
10
|
+
// {"path": "...", "ok": true,
|
|
11
|
+
// "labels": [{"id": "stairs", "conf": 0.92}, ...],
|
|
12
|
+
// "ocr": ["文本行1", "文本行2"],
|
|
13
|
+
// "fp": "<base64 的 float32 数组>"}
|
|
14
|
+
// 或 {"path": "...", "ok": false, "error": "..."}
|
|
15
|
+
//
|
|
16
|
+
// 用 JSONL 而不是单个 JSON 数组:逐行 flush 让调用方能流式读取并显示进度,
|
|
17
|
+
// 6 万张图的批处理里「有没有在动」是可用性问题。
|
|
18
|
+
//
|
|
19
|
+
// 特征指纹用 base64 编码 float32 数组而非直接输出 768 个数字:后者每行
|
|
20
|
+
// ~15KB,6 万行就是 900MB 输出。base64 后 ~4KB/行,且解码是 numpy 一行。
|
|
21
|
+
|
|
22
|
+
import Foundation
|
|
23
|
+
import Vision
|
|
24
|
+
import AppKit
|
|
25
|
+
|
|
26
|
+
let args = Array(CommandLine.arguments.dropFirst())
|
|
27
|
+
// --no-fp 跳过特征指纹(只要标签和 OCR 时能省一半时间)
|
|
28
|
+
let wantFP = !args.contains("--no-fp")
|
|
29
|
+
let wantOCR = !args.contains("--no-ocr")
|
|
30
|
+
let paths = args.filter { !$0.hasPrefix("--") }
|
|
31
|
+
|
|
32
|
+
func emit(_ obj: [String: Any]) {
|
|
33
|
+
guard let data = try? JSONSerialization.data(withJSONObject: obj),
|
|
34
|
+
let line = String(data: data, encoding: .utf8) else { return }
|
|
35
|
+
print(line)
|
|
36
|
+
// 行缓冲:调用方按行读时不被块缓冲卡住
|
|
37
|
+
fflush(stdout)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
func probe(_ path: String) {
|
|
41
|
+
guard let img = NSImage(contentsOfFile: path),
|
|
42
|
+
let cg = img.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
|
43
|
+
emit(["path": path, "ok": false, "error": "load_failed"])
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
var requests: [VNRequest] = []
|
|
47
|
+
let clsReq = VNClassifyImageRequest()
|
|
48
|
+
requests.append(clsReq)
|
|
49
|
+
var ocrReq: VNRecognizeTextRequest? = nil
|
|
50
|
+
if wantOCR {
|
|
51
|
+
let o = VNRecognizeTextRequest()
|
|
52
|
+
// 双语:本机素材中英混排(截图带中文界面,图库带英文题注)
|
|
53
|
+
o.recognitionLanguages = ["zh-Hans", "en-US"]
|
|
54
|
+
o.recognitionLevel = .accurate
|
|
55
|
+
// 关闭语言纠错:截图里的代码/路径/型号经不起自动纠正
|
|
56
|
+
o.usesLanguageCorrection = false
|
|
57
|
+
ocrReq = o
|
|
58
|
+
requests.append(o)
|
|
59
|
+
}
|
|
60
|
+
var fpReq: VNGenerateImageFeaturePrintRequest? = nil
|
|
61
|
+
if wantFP {
|
|
62
|
+
let f = VNGenerateImageFeaturePrintRequest()
|
|
63
|
+
fpReq = f
|
|
64
|
+
requests.append(f)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let handler = VNImageRequestHandler(cgImage: cg, options: [:])
|
|
68
|
+
do {
|
|
69
|
+
try handler.perform(requests)
|
|
70
|
+
} catch {
|
|
71
|
+
emit(["path": path, "ok": false, "error": "perform_failed"])
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
var out: [String: Any] = ["path": path, "ok": true]
|
|
76
|
+
|
|
77
|
+
// 分类标签:只留高置信度前若干个。低分标签(<0.1)噪声大,
|
|
78
|
+
// 全量带出去会让本地索引的「标签」维度几乎变成随机噪声。
|
|
79
|
+
if let obs = clsReq.results {
|
|
80
|
+
let labels = obs.prefix(12).compactMap { o -> [String: Any]? in
|
|
81
|
+
guard o.confidence >= 0.10 else { return nil }
|
|
82
|
+
return ["id": o.identifier, "conf": Double(o.confidence)]
|
|
83
|
+
}
|
|
84
|
+
out["labels"] = labels
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// OCR 文本:按 Vision 的置信度过滤,并把同一行的碎片合并。
|
|
88
|
+
if let o = ocrReq, let res = o.results {
|
|
89
|
+
let lines = res.compactMap { $0.topCandidates(1).first }
|
|
90
|
+
.filter { $0.confidence >= 0.3 }
|
|
91
|
+
.map { $0.string }
|
|
92
|
+
// 去重且保序:截图里的重复行(列表项、代码)会占满索引
|
|
93
|
+
var seen = Set<String>()
|
|
94
|
+
out["ocr"] = lines.filter { seen.insert($0).inserted }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 特征指纹:以图搜图与去重的依据。
|
|
98
|
+
//
|
|
99
|
+
// 取数值的唯一稳定路径是 NSKeyedArchiver 归档后从 plist 的 $objects 里
|
|
100
|
+
// 抠出那个 Data 块——实测 768 维对应 3072 字节(float32)。此前的两条路
|
|
101
|
+
// 都不通:`value(forKey: "elementData")` 抛 NSUnknownKeyException
|
|
102
|
+
// (VNSceneObservation 不是 KVC 兼容的),而 `computeDistance` 只给
|
|
103
|
+
// 两两距离、给不出「指纹本身」,没法存下来做索引。
|
|
104
|
+
//
|
|
105
|
+
// 归档格式是私有布局,所以这里**只认字节数**(768×4)而不是靠下标:
|
|
106
|
+
// 布局变了也能凭尺寸认出正确的块。抠不到就退化成无指纹——标签与 OCR
|
|
107
|
+
// 两个维度照常可用,不影响调用方。
|
|
108
|
+
if let f = fpReq, let obs = f.results?.first as? VNFeaturePrintObservation {
|
|
109
|
+
let want = obs.elementCount * 4
|
|
110
|
+
if let arch = try? NSKeyedArchiver.archivedData(
|
|
111
|
+
withRootObject: obs, requiringSecureCoding: false),
|
|
112
|
+
let plist = try? PropertyListSerialization.propertyList(
|
|
113
|
+
from: arch, format: nil) as? [String: Any],
|
|
114
|
+
let objects = plist["$objects"] as? [Any] {
|
|
115
|
+
let blob = objects.compactMap { $0 as? Data }
|
|
116
|
+
.first { $0.count == want }
|
|
117
|
+
if let blob {
|
|
118
|
+
out["fp"] = blob.base64EncodedString()
|
|
119
|
+
out["fp_dim"] = obs.elementCount
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
emit(out)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
for p in paths { probe(p) }
|