gather-engine 2.4.0__tar.gz → 2.6.0__tar.gz
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.
- {gather_engine-2.4.0 → gather_engine-2.6.0}/PKG-INFO +27 -4
- gather_engine-2.4.0/src/gather_engine.egg-info/PKG-INFO → gather_engine-2.6.0/README.md +22 -38
- {gather_engine-2.4.0 → gather_engine-2.6.0}/pyproject.toml +4 -3
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/__init__.py +1 -1
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/cli_ext.py +6 -0
- gather_engine-2.6.0/src/gather/comment_balance.py +234 -0
- gather_engine-2.6.0/src/gather/comment_report.py +78 -0
- gather_engine-2.6.0/src/gather/comment_signals.py +141 -0
- gather_engine-2.6.0/src/gather/decompose_binary.py +127 -0
- gather_engine-2.6.0/src/gather/decompose_cmd.py +80 -0
- gather_engine-2.6.0/src/gather/decompose_content.py +164 -0
- gather_engine-2.6.0/src/gather/decompose_document.py +98 -0
- gather_engine-2.6.0/src/gather/decompose_dsp.py +156 -0
- gather_engine-2.6.0/src/gather/decompose_image.py +129 -0
- gather_engine-2.6.0/src/gather/decompose_pcm.py +166 -0
- gather_engine-2.6.0/src/gather/decompose_rebuild.py +130 -0
- gather_engine-2.6.0/src/gather/decompose_run.py +116 -0
- gather_engine-2.6.0/src/gather/decompose_text.py +150 -0
- gather_engine-2.6.0/src/gather/decompose_tree.py +154 -0
- gather_engine-2.6.0/src/gather/decompose_zip.py +141 -0
- gather_engine-2.6.0/src/gather/frame_sheets.py +121 -0
- gather_engine-2.6.0/src/gather/media_probe.py +110 -0
- gather_engine-2.6.0/src/gather/media_probe_audio.py +118 -0
- gather_engine-2.6.0/src/gather/media_probe_bmff.py +116 -0
- gather_engine-2.6.0/src/gather/media_probe_ebml.py +132 -0
- gather_engine-2.6.0/src/gather/media_probe_image.py +73 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/pacing.py +44 -0
- gather_engine-2.6.0/src/gather/saves_cmd.py +68 -0
- gather_engine-2.6.0/src/gather/saves_entities.py +123 -0
- gather_engine-2.6.0/src/gather/saves_lang.py +106 -0
- gather_engine-2.6.0/src/gather/saves_sidecar.py +197 -0
- gather_engine-2.6.0/src/gather/saves_tables.py +209 -0
- gather_engine-2.6.0/src/gather/saves_write.py +55 -0
- gather_engine-2.6.0/src/gather/storyboard.py +118 -0
- gather_engine-2.6.0/src/gather/video_bundle.py +175 -0
- gather_engine-2.6.0/src/gather/video_bundle_cmd.py +134 -0
- gather_engine-2.6.0/src/gather/video_bundle_run.py +239 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/video_cmd.py +3 -1
- gather_engine-2.6.0/src/gather/video_frames.py +106 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/video_source.py +11 -2
- gather_engine-2.4.0/README.md → gather_engine-2.6.0/src/gather_engine.egg-info/PKG-INFO +61 -3
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather_engine.egg-info/SOURCES.txt +41 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather_engine.egg-info/requires.txt +5 -0
- gather_engine-2.6.0/tests/test_comment_balance.py +143 -0
- gather_engine-2.6.0/tests/test_decompose.py +258 -0
- gather_engine-2.6.0/tests/test_decompose_content.py +236 -0
- gather_engine-2.6.0/tests/test_saves.py +181 -0
- gather_engine-2.6.0/tests/test_saves_entities.py +70 -0
- gather_engine-2.6.0/tests/test_saves_media.py +124 -0
- gather_engine-2.6.0/tests/test_storyboard.py +108 -0
- gather_engine-2.6.0/tests/test_video_bundle.py +166 -0
- gather_engine-2.6.0/tests/test_video_frames.py +246 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/LICENSE +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/NOTICE +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/setup.cfg +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/__main__.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/_vendor/__init__.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/_vendor/safe_spawn.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/api.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/arxiv.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/availability.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/backends.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/backends_browser.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/benchmark.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/browser.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/browser_evidence.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/cache.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/captions.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/channel.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/channel_ledger.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/citecheck.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/cli.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/client_mcp.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/client_network.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/commands.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/context.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/corpus_cmd.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/crawl.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/credentials.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/derive.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/digest.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/docs.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/docs_tool.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/dom.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/export.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/extract.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/fastparse.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/federation.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/federation_cmd.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/federation_policy.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/federation_receipt.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/feed.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/fetch.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/filter_ledger.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/flagship.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/grant_checks.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/grants.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/http_call.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/interop.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/item.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/ledger_cmd.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/ledger_verify.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/local_model.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/localpath.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/mcp.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/method.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/model.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/monitor.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/net.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/ocr.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/payloads.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/pdf.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/pilot.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/pilot_bundle.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/pilot_commands.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/pilot_manifest.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/pilot_report.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/pilot_sources.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/provenance.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/recall.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/reddit.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/reddit_cmd.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/reddit_items.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/report.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/report_cmd.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/route.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/run.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/run_config.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/schema_extract.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/scholar.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/scope.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/search.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/source.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/spawn.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/store.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/stream.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/track.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/transcribe.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/video.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/web.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/web_commands.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/youtube_api.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/youtube_cmd.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/youtube_probe.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/youtube_route.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/ytdlp.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather_engine.egg-info/dependency_links.txt +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather_engine.egg-info/entry_points.txt +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather_engine.egg-info/top_level.txt +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_api.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_arxiv.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_availability.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_backends.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_backends_browser.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_backends_card.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_benchmark.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_browser_evidence.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_cache.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_captions.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_channel.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_channel_ledger.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_citecheck.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_cli.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_cli_web.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_client_disclosure.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_client_network.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_client_package.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_client_profile.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_client_setup.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_client_vendored.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_context.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_context_mountinfo.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_crawl.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_credentials.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_demo.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_derive.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_device_names.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_digest.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_docs.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_dom.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_export.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_extract.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_fastparse.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_federation.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_federation_cli.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_federation_receipt.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_feed.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_fetch.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_filter_ledger.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_flagship_cli.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_grants.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_hard_sources.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_http_call.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_interop.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_item.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_localpath.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_mcp.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_mcp_annotations.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_method.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_model.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_monitor.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_net.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_nonlocal_paths.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_pacing.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_package.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_pdf.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_pilot.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_pilot_bundle.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_pilot_cli.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_pilot_manifest.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_pilot_refresh.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_pilot_report.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_pilot_sample.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_pilot_showcase.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_pilot_sources.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_provenance.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_recall.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_reddit.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_reddit_grants.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_release_workflow.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_repo_art.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_repo_card.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_report.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_report_quotes.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_route.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_run.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_schema_extract.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_scholar.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_scope.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_search.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_spawn_children.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_spawn_working_folder.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_store.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_stream.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_track.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_vendored.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_verify_pilot_cli.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_video.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_video_source.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_video_spawn.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_web.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_web_refine.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_xml_safety.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_youtube_api.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_youtube_grants.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_youtube_probe.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_youtube_route.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_ytdlp.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_ytdlp_real.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gather-engine
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.6.0
|
|
4
4
|
Summary: Collect web pages, papers, APIs, PDFs, scans, audio and video into a local corpus where every item records how it arrived.
|
|
5
5
|
Author-email: Zain Dana Harper <zaindharper@gmail.com>
|
|
6
6
|
License-Expression: AGPL-3.0-only
|
|
@@ -24,13 +24,17 @@ Requires-Dist: pytest>=8; extra == "dev"
|
|
|
24
24
|
Requires-Dist: pytest-cov>=5; extra == "dev"
|
|
25
25
|
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
26
26
|
Requires-Dist: mypy>=1.10; extra == "dev"
|
|
27
|
+
Requires-Dist: Pillow>=10.1; extra == "dev"
|
|
27
28
|
Provides-Extra: fast
|
|
28
29
|
Requires-Dist: lxml>=5; extra == "fast"
|
|
29
30
|
Provides-Extra: browser
|
|
30
31
|
Requires-Dist: playwright>=1.4; extra == "browser"
|
|
32
|
+
Provides-Extra: frames
|
|
33
|
+
Requires-Dist: Pillow>=10.1; extra == "frames"
|
|
31
34
|
Provides-Extra: all
|
|
32
35
|
Requires-Dist: lxml>=5; extra == "all"
|
|
33
36
|
Requires-Dist: playwright>=1.4; extra == "all"
|
|
37
|
+
Requires-Dist: Pillow>=10.1; extra == "all"
|
|
34
38
|
Dynamic: license-file
|
|
35
39
|
|
|
36
40
|
<picture>
|
|
@@ -46,11 +50,11 @@ Gather is for researchers, journalists and developers who feed sources to a pers
|
|
|
46
50
|
|
|
47
51
|
## Install and first run
|
|
48
52
|
|
|
49
|
-
Python 3.11 or newer. Version 2.
|
|
53
|
+
Python 3.11 or newer. Version 2.6.0 is on PyPI as `gather-engine`.
|
|
50
54
|
|
|
51
55
|
```bash
|
|
52
56
|
pip install gather-engine
|
|
53
|
-
gather --version # gather 2.
|
|
57
|
+
gather --version # gather 2.6.0
|
|
54
58
|
```
|
|
55
59
|
|
|
56
60
|
Extract a saved page into hashed blocks, each tied to its place in the page:
|
|
@@ -124,6 +128,7 @@ fabricated field cannot reach the corpus by being plausible.
|
|
|
124
128
|
- **Hard-source adapters behind one shape.** Video with captions and comments (`yt-dlp` first, with the official YouTube Data API as a fallback when you supply your own key; each item records which path served it and how fast), Reddit through its official Data API with your own app credentials, static web, RSS/Atom feeds, local docs, arXiv, PDFs (`pdftotext`), authenticated JSON APIs (token from env, never logged), JS-rendered pages (headless Chromium), scanned images (`tesseract`), and audio (`whisper`). Each external tool is optional and only needed for its own adapter.
|
|
125
129
|
- **Cited reports, checked in code.** `gather report` asks a model on your own machine to answer from fixed excerpts with exact quotes, then checks every quote against the excerpt it cites and marks any it cannot verify. An answer that cites an excerpt without quoting it is sent back for a rewrite, up to twice.
|
|
126
130
|
- **Filter ledgers.** `--scope TERMS --ledger DIR` writes one row per dropped item with its reason code, plus the input, so `gather ledger verify` can recompute every count.
|
|
131
|
+
- **A video as one checkable bundle, with frames.** `gather video-bundle <url> --out DIR` saves the metadata and chapters, the caption track and transcripts, the comment tree, a balanced comment synthesis, and contact sheets of 24 to 96 frames across the whole video with the time printed on each. The frames come from the site's preview storyboard, so no video file is downloaded; they are small (320 by 180 pixels at best on YouTube) and the bundle records their real size. `--fast` reads captions and frames while the comments are still being read and keeps requests 1 second apart: a 234-second video that took 162 seconds with 3-second sleeps took 37 seconds. Frame sheets need the optional `frames` extra (Pillow). See [USAGE.md](https://github.com/HarperZ9/gather/blob/main/USAGE.md).
|
|
127
132
|
- **Channel and playlist intake.** `gather channel <url> --store DIR` lists a channel's videos, shorts, and streams tabs (or one playlist) and gathers every entry with bounded concurrency, resumable from a per-pass ledger. Metadata and comments run as one pass and captions as another (`--no-captions`, `--captions-only`), with polite pacing, bounded backoff on HTTP 429 and session rate limits, a stop at the first bot check (never retried or answered), one caption track per video, and a run summary that counts every outcome by reason.
|
|
128
133
|
- **Scholarly-graph federation.** `gather scholar` queries OpenAlex, Semantic Scholar, and Crossref in one call, dedupes results by normalized DOI (never a fuzzy title match), and can capture citation edges as first-class records with `--edges`.
|
|
129
134
|
- **A durable local corpus.** Any fetch command takes `--store DIR`: bodies are content-addressed and deduped by hash, new writes preserve exact UTF-8 source bytes, and `gather corpus list|verify|digest|runs|search|stats|prune|availability|context` inspects, re-checks, and queries what you stored.
|
|
@@ -220,6 +225,24 @@ gather docs ./research-notes --scope "rubik,group theory"
|
|
|
220
225
|
gather corpus verify ./corpus # re-hash every stored body; non-zero exit on corruption
|
|
221
226
|
```
|
|
222
227
|
|
|
228
|
+
Turn a folder of posts saved by the CMMNS browser extension into tables, offline and by fixed rules:
|
|
229
|
+
|
|
230
|
+
```bash
|
|
231
|
+
gather saves ./saves --out ./saves-tables # items, entities and media as CSV and JSON Lines
|
|
232
|
+
gather saves --probe ./clip.mp4 # container, size, duration and audio fields of one file
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
The same folder gives byte-identical tables on every run. The language column is a rule and the entities are regular expressions; [USAGE.md](https://github.com/HarperZ9/gather/blob/main/USAGE.md#saved-posts-as-tables) lists the columns and the limits.
|
|
236
|
+
|
|
237
|
+
Take any local file apart into a tree of its parts, then rebuild it byte for byte:
|
|
238
|
+
|
|
239
|
+
```bash
|
|
240
|
+
gather decompose ./clip.wav --out ./clip-tree # tree.json, parts/, and measurement tables
|
|
241
|
+
gather rebuild ./clip-tree --out ./clip-again.wav # same SHA-256, every node checked
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
ZIP, PNG, WAV, JSON, text and CMMNS saves get structural handlers; every other file is kept as one byte range and still rebuilds exactly. PCM WAV files also get loudness (BS.1770), tempo, key, spectrum and decay estimates. [USAGE.md](https://github.com/HarperZ9/gather/blob/main/USAGE.md#take-a-file-apart-and-put-it-back) lists the record, the handlers and the limits.
|
|
245
|
+
|
|
223
246
|
Offline demo, no install of extra tools, nothing downloaded:
|
|
224
247
|
|
|
225
248
|
```bash
|
|
@@ -302,7 +325,7 @@ The `web` adapter reads static HTML and does not run JavaScript; a client-render
|
|
|
302
325
|
- [docs/WEB-ENGINE-UPLIFT.md](https://github.com/HarperZ9/gather/blob/main/docs/WEB-ENGINE-UPLIFT.md): the web-data engine roadmap and benchmarks.
|
|
303
326
|
- [docs/ENTERPRISE-READINESS.md](https://github.com/HarperZ9/gather/blob/main/docs/ENTERPRISE-READINESS.md): context envelopes, action receipts, and host-neutral operation for unattended agents.
|
|
304
327
|
- [docs/PILOT.md](https://github.com/HarperZ9/gather/blob/main/docs/PILOT.md): the accountable pilot evidence engine, its manifest boundary, and the private/shared evidence split.
|
|
305
|
-
- [CHANGELOG.md](https://github.com/HarperZ9/gather/blob/main/CHANGELOG.md): version history. Current release: 2.
|
|
328
|
+
- [CHANGELOG.md](https://github.com/HarperZ9/gather/blob/main/CHANGELOG.md): version history. Current release: 2.6.0.
|
|
306
329
|
|
|
307
330
|
Peer projects: [crucible](https://github.com/HarperZ9/crucible) (judgment), [index](https://github.com/HarperZ9/index) (code maps), [forum](https://github.com/HarperZ9/forum) (orchestration), [telos](https://github.com/HarperZ9/telos) (the engine).
|
|
308
331
|
|
|
@@ -1,38 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: gather-engine
|
|
3
|
-
Version: 2.4.0
|
|
4
|
-
Summary: Collect web pages, papers, APIs, PDFs, scans, audio and video into a local corpus where every item records how it arrived.
|
|
5
|
-
Author-email: Zain Dana Harper <zaindharper@gmail.com>
|
|
6
|
-
License-Expression: AGPL-3.0-only
|
|
7
|
-
Project-URL: Homepage, https://harperz9.github.io/gather.html
|
|
8
|
-
Project-URL: Repository, https://github.com/HarperZ9/gather
|
|
9
|
-
Project-URL: Issues, https://github.com/HarperZ9/gather/issues
|
|
10
|
-
Project-URL: Changelog, https://github.com/HarperZ9/gather/blob/main/CHANGELOG.md
|
|
11
|
-
Keywords: research,intake,ingestion,provenance,transcripts,arxiv,accountability,ocr,transcription,headless-browser,synthesis,web-scraping,ssrf-safe
|
|
12
|
-
Classifier: Development Status :: 4 - Beta
|
|
13
|
-
Classifier: Environment :: Console
|
|
14
|
-
Classifier: Intended Audience :: Developers
|
|
15
|
-
Classifier: Programming Language :: Python :: 3
|
|
16
|
-
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
-
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
18
|
-
Requires-Python: >=3.11
|
|
19
|
-
Description-Content-Type: text/markdown
|
|
20
|
-
License-File: LICENSE
|
|
21
|
-
License-File: NOTICE
|
|
22
|
-
Provides-Extra: dev
|
|
23
|
-
Requires-Dist: pytest>=8; extra == "dev"
|
|
24
|
-
Requires-Dist: pytest-cov>=5; extra == "dev"
|
|
25
|
-
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
26
|
-
Requires-Dist: mypy>=1.10; extra == "dev"
|
|
27
|
-
Provides-Extra: fast
|
|
28
|
-
Requires-Dist: lxml>=5; extra == "fast"
|
|
29
|
-
Provides-Extra: browser
|
|
30
|
-
Requires-Dist: playwright>=1.4; extra == "browser"
|
|
31
|
-
Provides-Extra: all
|
|
32
|
-
Requires-Dist: lxml>=5; extra == "all"
|
|
33
|
-
Requires-Dist: playwright>=1.4; extra == "all"
|
|
34
|
-
Dynamic: license-file
|
|
35
|
-
|
|
36
1
|
<picture>
|
|
37
2
|
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/HarperZ9/gather/main/docs/art/hero-dark.svg">
|
|
38
3
|
<img src="https://raw.githubusercontent.com/HarperZ9/gather/main/docs/art/hero-light.svg" alt="gather: Research intake for web pages, papers, scanned PDFs, audio and video. Five bundles of lines, solid, dashed, dotted, wavy and stepped, sweep in from the right and pass through a ring into one bright core. One line stops at the ring with a short cross mark." width="100%">
|
|
@@ -46,11 +11,11 @@ Gather is for researchers, journalists and developers who feed sources to a pers
|
|
|
46
11
|
|
|
47
12
|
## Install and first run
|
|
48
13
|
|
|
49
|
-
Python 3.11 or newer. Version 2.
|
|
14
|
+
Python 3.11 or newer. Version 2.6.0 is on PyPI as `gather-engine`.
|
|
50
15
|
|
|
51
16
|
```bash
|
|
52
17
|
pip install gather-engine
|
|
53
|
-
gather --version # gather 2.
|
|
18
|
+
gather --version # gather 2.6.0
|
|
54
19
|
```
|
|
55
20
|
|
|
56
21
|
Extract a saved page into hashed blocks, each tied to its place in the page:
|
|
@@ -124,6 +89,7 @@ fabricated field cannot reach the corpus by being plausible.
|
|
|
124
89
|
- **Hard-source adapters behind one shape.** Video with captions and comments (`yt-dlp` first, with the official YouTube Data API as a fallback when you supply your own key; each item records which path served it and how fast), Reddit through its official Data API with your own app credentials, static web, RSS/Atom feeds, local docs, arXiv, PDFs (`pdftotext`), authenticated JSON APIs (token from env, never logged), JS-rendered pages (headless Chromium), scanned images (`tesseract`), and audio (`whisper`). Each external tool is optional and only needed for its own adapter.
|
|
125
90
|
- **Cited reports, checked in code.** `gather report` asks a model on your own machine to answer from fixed excerpts with exact quotes, then checks every quote against the excerpt it cites and marks any it cannot verify. An answer that cites an excerpt without quoting it is sent back for a rewrite, up to twice.
|
|
126
91
|
- **Filter ledgers.** `--scope TERMS --ledger DIR` writes one row per dropped item with its reason code, plus the input, so `gather ledger verify` can recompute every count.
|
|
92
|
+
- **A video as one checkable bundle, with frames.** `gather video-bundle <url> --out DIR` saves the metadata and chapters, the caption track and transcripts, the comment tree, a balanced comment synthesis, and contact sheets of 24 to 96 frames across the whole video with the time printed on each. The frames come from the site's preview storyboard, so no video file is downloaded; they are small (320 by 180 pixels at best on YouTube) and the bundle records their real size. `--fast` reads captions and frames while the comments are still being read and keeps requests 1 second apart: a 234-second video that took 162 seconds with 3-second sleeps took 37 seconds. Frame sheets need the optional `frames` extra (Pillow). See [USAGE.md](https://github.com/HarperZ9/gather/blob/main/USAGE.md).
|
|
127
93
|
- **Channel and playlist intake.** `gather channel <url> --store DIR` lists a channel's videos, shorts, and streams tabs (or one playlist) and gathers every entry with bounded concurrency, resumable from a per-pass ledger. Metadata and comments run as one pass and captions as another (`--no-captions`, `--captions-only`), with polite pacing, bounded backoff on HTTP 429 and session rate limits, a stop at the first bot check (never retried or answered), one caption track per video, and a run summary that counts every outcome by reason.
|
|
128
94
|
- **Scholarly-graph federation.** `gather scholar` queries OpenAlex, Semantic Scholar, and Crossref in one call, dedupes results by normalized DOI (never a fuzzy title match), and can capture citation edges as first-class records with `--edges`.
|
|
129
95
|
- **A durable local corpus.** Any fetch command takes `--store DIR`: bodies are content-addressed and deduped by hash, new writes preserve exact UTF-8 source bytes, and `gather corpus list|verify|digest|runs|search|stats|prune|availability|context` inspects, re-checks, and queries what you stored.
|
|
@@ -220,6 +186,24 @@ gather docs ./research-notes --scope "rubik,group theory"
|
|
|
220
186
|
gather corpus verify ./corpus # re-hash every stored body; non-zero exit on corruption
|
|
221
187
|
```
|
|
222
188
|
|
|
189
|
+
Turn a folder of posts saved by the CMMNS browser extension into tables, offline and by fixed rules:
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
gather saves ./saves --out ./saves-tables # items, entities and media as CSV and JSON Lines
|
|
193
|
+
gather saves --probe ./clip.mp4 # container, size, duration and audio fields of one file
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
The same folder gives byte-identical tables on every run. The language column is a rule and the entities are regular expressions; [USAGE.md](https://github.com/HarperZ9/gather/blob/main/USAGE.md#saved-posts-as-tables) lists the columns and the limits.
|
|
197
|
+
|
|
198
|
+
Take any local file apart into a tree of its parts, then rebuild it byte for byte:
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
gather decompose ./clip.wav --out ./clip-tree # tree.json, parts/, and measurement tables
|
|
202
|
+
gather rebuild ./clip-tree --out ./clip-again.wav # same SHA-256, every node checked
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
ZIP, PNG, WAV, JSON, text and CMMNS saves get structural handlers; every other file is kept as one byte range and still rebuilds exactly. PCM WAV files also get loudness (BS.1770), tempo, key, spectrum and decay estimates. [USAGE.md](https://github.com/HarperZ9/gather/blob/main/USAGE.md#take-a-file-apart-and-put-it-back) lists the record, the handlers and the limits.
|
|
206
|
+
|
|
223
207
|
Offline demo, no install of extra tools, nothing downloaded:
|
|
224
208
|
|
|
225
209
|
```bash
|
|
@@ -302,7 +286,7 @@ The `web` adapter reads static HTML and does not run JavaScript; a client-render
|
|
|
302
286
|
- [docs/WEB-ENGINE-UPLIFT.md](https://github.com/HarperZ9/gather/blob/main/docs/WEB-ENGINE-UPLIFT.md): the web-data engine roadmap and benchmarks.
|
|
303
287
|
- [docs/ENTERPRISE-READINESS.md](https://github.com/HarperZ9/gather/blob/main/docs/ENTERPRISE-READINESS.md): context envelopes, action receipts, and host-neutral operation for unattended agents.
|
|
304
288
|
- [docs/PILOT.md](https://github.com/HarperZ9/gather/blob/main/docs/PILOT.md): the accountable pilot evidence engine, its manifest boundary, and the private/shared evidence split.
|
|
305
|
-
- [CHANGELOG.md](https://github.com/HarperZ9/gather/blob/main/CHANGELOG.md): version history. Current release: 2.
|
|
289
|
+
- [CHANGELOG.md](https://github.com/HarperZ9/gather/blob/main/CHANGELOG.md): version history. Current release: 2.6.0.
|
|
306
290
|
|
|
307
291
|
Peer projects: [crucible](https://github.com/HarperZ9/crucible) (judgment), [index](https://github.com/HarperZ9/index) (code maps), [forum](https://github.com/HarperZ9/forum) (orchestration), [telos](https://github.com/HarperZ9/telos) (the engine).
|
|
308
292
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "gather-engine"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.6.0"
|
|
8
8
|
description = "Collect web pages, papers, APIs, PDFs, scans, audio and video into a local corpus where every item records how it arrived."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -26,12 +26,13 @@ classifiers = [
|
|
|
26
26
|
dependencies = []
|
|
27
27
|
|
|
28
28
|
[project.optional-dependencies]
|
|
29
|
-
dev = ["pytest>=8", "pytest-cov>=5", "ruff>=0.6", "mypy>=1.10"]
|
|
29
|
+
dev = ["pytest>=8", "pytest-cov>=5", "ruff>=0.6", "mypy>=1.10", "Pillow>=10.1"]
|
|
30
30
|
# Capability backends (wedge 5 parity layer). The core stays zero-dep; these are
|
|
31
31
|
# opt-in edges. Missing edge => honest UNVERIFIABLE or stdlib fallback, never a fake.
|
|
32
32
|
fast = ["lxml>=5"] # native-speed parsing (2x on large docs); stdlib is the fallback
|
|
33
33
|
browser = ["playwright>=1.4"] # JS render (needs `playwright install chromium`)
|
|
34
|
-
|
|
34
|
+
frames = ["Pillow>=10.1"] # contact sheets of video frames (gather video-bundle); without it the sheets are not cut
|
|
35
|
+
all = ["lxml>=5", "playwright>=1.4", "Pillow>=10.1"]
|
|
35
36
|
|
|
36
37
|
[project.scripts]
|
|
37
38
|
gather = "gather.cli:main"
|
|
@@ -28,7 +28,7 @@ from gather.scope import filter_scope, in_scope
|
|
|
28
28
|
from gather.source import Catalog, Source
|
|
29
29
|
from gather.store import Corpus
|
|
30
30
|
|
|
31
|
-
__version__ = "2.
|
|
31
|
+
__version__ = "2.6.0"
|
|
32
32
|
|
|
33
33
|
__all__ = [
|
|
34
34
|
"Catalog", "Corpus", "CorpusRootDescriptor", "CorpusRootIdentity", "Digest", "Item", "NullProvenanceProvider", "NullSynthesizer",
|
|
@@ -6,14 +6,20 @@ from collections.abc import Callable
|
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
def add_extension_parsers(sub, add_common: Callable) -> None:
|
|
9
|
+
from gather.decompose_cmd import add_decompose_parsers
|
|
9
10
|
from gather.ledger_cmd import add_ledger_parser
|
|
10
11
|
from gather.reddit_cmd import add_reddit_parser
|
|
11
12
|
from gather.report_cmd import add_report_parsers
|
|
13
|
+
from gather.saves_cmd import add_saves_parser
|
|
14
|
+
from gather.video_bundle_cmd import add_bundle_parsers
|
|
12
15
|
from gather.video_cmd import add_channel_parser
|
|
13
16
|
from gather.youtube_cmd import add_video_parsers
|
|
14
17
|
|
|
15
18
|
add_video_parsers(sub, add_common)
|
|
16
19
|
add_channel_parser(sub)
|
|
20
|
+
add_bundle_parsers(sub)
|
|
17
21
|
add_reddit_parser(sub, add_common)
|
|
18
22
|
add_report_parsers(sub, add_common)
|
|
19
23
|
add_ledger_parser(sub)
|
|
24
|
+
add_saves_parser(sub)
|
|
25
|
+
add_decompose_parsers(sub)
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""Balanced comment synthesis: what a comment section adds, with critique and support side by side.
|
|
2
|
+
|
|
3
|
+
``balance(comments)`` reads a video's comments (yt-dlp's records, or a bundle's thread file) and
|
|
4
|
+
returns a ``gather.comment-balance/1`` record:
|
|
5
|
+
|
|
6
|
+
- every comment in exactly one category (``gather.comment_signals``), with its evidence, so
|
|
7
|
+
nothing is dropped without a reason a reader can check;
|
|
8
|
+
- substantive comments clustered into claims by shared vocabulary, ranked by comments and likes,
|
|
9
|
+
each with its critique, support and neutral counts and the comment ids behind it;
|
|
10
|
+
- groups of likely coordinated promotion (two or more promotional comments that share signals or
|
|
11
|
+
wording) and near-duplicate text from several comments;
|
|
12
|
+
- the balance: counts per category and stance, the critique-to-support ratio, and the share of
|
|
13
|
+
likes that went to promotion.
|
|
14
|
+
|
|
15
|
+
A reply keeps its parent and thread ids. A reply that agrees with its parent takes the parent's
|
|
16
|
+
stance toward the video, so agreement with a critique counts as critique.
|
|
17
|
+
|
|
18
|
+
The record names no commenter: author fields are never read, @mentions are removed from excerpts,
|
|
19
|
+
and each excerpt is under 15 words. It is deterministic and uses no model, so the item it becomes
|
|
20
|
+
is ``compiled`` (``gather.derive``), never ``synthesized``. Pure.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import math
|
|
26
|
+
import re
|
|
27
|
+
from collections import Counter
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from gather.comment_signals import AGREE_OPEN, INSULT, MENTION, URL, classify, tokens
|
|
31
|
+
|
|
32
|
+
FORMAT = "gather.comment-balance/1"
|
|
33
|
+
EXCERPT_WORDS = 14
|
|
34
|
+
CLAIM_SIM = 0.2
|
|
35
|
+
NEAR_DUP = 0.6
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _likes(c: dict) -> int:
|
|
39
|
+
v = c.get("like_count", c.get("score"))
|
|
40
|
+
return v if isinstance(v, int) and v >= 0 else 0
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def excerpt(text: str, limit: int = EXCERPT_WORDS) -> str:
|
|
44
|
+
"""The first sentence's opening words, at most ``limit``, with links and @mentions removed."""
|
|
45
|
+
plain = re.sub(r"\s+", " ", MENTION.sub("", URL.sub("", text or ""))).strip()
|
|
46
|
+
first = re.split(r"(?<=[.!?])\s", plain, maxsplit=1)[0]
|
|
47
|
+
words = first.split()
|
|
48
|
+
return " ".join(words[:limit]) + (" ..." if len(words) > limit else "")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def flatten(tree: list[dict]) -> list[dict]:
|
|
52
|
+
"""A thread file's nested comments as a flat list, each reply with its parent id."""
|
|
53
|
+
out: list[dict] = []
|
|
54
|
+
stack = [(c, None) for c in reversed(tree)]
|
|
55
|
+
while stack:
|
|
56
|
+
c, parent = stack.pop()
|
|
57
|
+
out.append({**{k: v for k, v in c.items() if k != "replies"}, "parent": c.get("parent") or parent})
|
|
58
|
+
stack.extend((r, c.get("id")) for r in reversed(c.get("replies") or []))
|
|
59
|
+
return out
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _records(comments: list[dict]) -> list[dict]:
|
|
63
|
+
ids = {str(c.get("id")) for c in comments}
|
|
64
|
+
recs: list[dict[str, Any]] = []
|
|
65
|
+
for c in comments:
|
|
66
|
+
parent = c.get("parent")
|
|
67
|
+
parent = None if parent in (None, "", "root") else str(parent)
|
|
68
|
+
s = classify(str(c.get("text") or ""), from_channel=bool(c.get("author_is_uploader")))
|
|
69
|
+
recs.append({"id": str(c.get("id")), "parent": parent, "parent_missing": bool(parent and parent not in ids),
|
|
70
|
+
"category": s.category, "stance": s.stance, "evidence": s.evidence, "promo": s.promo,
|
|
71
|
+
"likes": _likes(c), "words": s.words, "question": s.question, "pointer": s.pointer,
|
|
72
|
+
"_text": str(c.get("text") or ""), "_tokens": tokens(str(c.get("text") or ""))})
|
|
73
|
+
by = {r["id"]: r for r in recs}
|
|
74
|
+
for r in recs:
|
|
75
|
+
root, seen = r, set()
|
|
76
|
+
while root["parent"] and root["parent"] in by and root["id"] not in seen:
|
|
77
|
+
seen.add(root["id"])
|
|
78
|
+
root = by[root["parent"]]
|
|
79
|
+
r["thread"] = root["id"]
|
|
80
|
+
p = by.get(r["parent"] or "")
|
|
81
|
+
opens = r["stance"] == "support" or bool(AGREE_OPEN.match(r["_text"]))
|
|
82
|
+
agrees = p is not None and opens and p["category"] == "substantive" and p["stance"] in ("critique", "support")
|
|
83
|
+
r["toward_video"] = p["stance"] if agrees and p else r["stance"]
|
|
84
|
+
r["agrees_with_parent"] = agrees
|
|
85
|
+
return recs
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _shingles(toks: list[str]) -> set[tuple[str, ...]]:
|
|
89
|
+
return {tuple(toks[i:i + 3]) for i in range(max(0, len(toks) - 2))}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _jaccard(a: set, b: set) -> float:
|
|
93
|
+
return len(a & b) / len(a | b) if a and b else 0.0
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _groups(recs: list[dict], linked) -> list[list[dict]]:
|
|
97
|
+
parent = list(range(len(recs)))
|
|
98
|
+
|
|
99
|
+
def find(i: int) -> int:
|
|
100
|
+
while parent[i] != i:
|
|
101
|
+
parent[i] = parent[parent[i]]
|
|
102
|
+
i = parent[i]
|
|
103
|
+
return i
|
|
104
|
+
|
|
105
|
+
for i in range(len(recs)):
|
|
106
|
+
for j in range(i + 1, len(recs)):
|
|
107
|
+
if linked(recs[i], recs[j]):
|
|
108
|
+
parent[find(i)] = find(j)
|
|
109
|
+
out: dict[int, list[dict]] = {}
|
|
110
|
+
for i, r in enumerate(recs):
|
|
111
|
+
out.setdefault(find(i), []).append(r)
|
|
112
|
+
return [g for g in out.values() if len(g) > 1]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def mark_coordination(recs: list[dict]) -> list[dict]:
|
|
116
|
+
"""Groups of likely coordinated comments, marked ``coordinated_spam`` in place: promotional
|
|
117
|
+
comments that share two signals or a third of their wording, and three or more comments of
|
|
118
|
+
eight words or more with near-identical text."""
|
|
119
|
+
promo = [r for r in recs if r["category"] == "promotional"]
|
|
120
|
+
groups: list[dict[str, Any]] = [{"kind": "promotion with shared signals", "members": g} for g in _groups(
|
|
121
|
+
promo, lambda a, b: len(set(a["promo"]) & set(b["promo"])) >= 2
|
|
122
|
+
or _jaccard(_shingles(a["_tokens"]), _shingles(b["_tokens"])) >= 0.33)]
|
|
123
|
+
long = [r for r in recs if r["words"] >= 8 and r["category"] not in ("channel", "promotional")]
|
|
124
|
+
for dup in _groups(long, lambda a, b: _jaccard(_shingles(a["_tokens"]), _shingles(b["_tokens"])) >= NEAR_DUP):
|
|
125
|
+
if len(dup) >= 3:
|
|
126
|
+
groups.append({"kind": "near-identical text", "members": dup})
|
|
127
|
+
out = []
|
|
128
|
+
for n, g in enumerate(groups, 1):
|
|
129
|
+
shared = sorted(set.intersection(*(set(m["promo"]) for m in g["members"]))) if g["kind"].startswith("promotion") else []
|
|
130
|
+
for m in g["members"]:
|
|
131
|
+
m["category"], m["group"] = "coordinated_spam", n
|
|
132
|
+
out.append({"group": n, "kind": g["kind"], "comments": [m["id"] for m in g["members"]], "shared_signals": shared,
|
|
133
|
+
"likes": sum(m["likes"] for m in g["members"])})
|
|
134
|
+
return out
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _vectors(recs: list[dict]) -> list[dict[str, float]]:
|
|
138
|
+
"""tf-idf vectors of unit length. A word in more than a third of the comments (the video's own
|
|
139
|
+
topic word) and a word only one comment uses carry no grouping weight."""
|
|
140
|
+
df = Counter(t for r in recs for t in set(r["_tokens"]))
|
|
141
|
+
n = len(recs)
|
|
142
|
+
vecs = []
|
|
143
|
+
for r in recs:
|
|
144
|
+
tf = Counter(t for t in r["_tokens"] if 1 < df[t] <= max(2, n / 3))
|
|
145
|
+
v = {t: (1 + math.log(c)) * math.log(1 + n / df[t]) for t, c in tf.items()}
|
|
146
|
+
norm = math.sqrt(sum(x * x for x in v.values())) or 1.0
|
|
147
|
+
vecs.append({t: x / norm for t, x in v.items()})
|
|
148
|
+
return vecs
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _cos(a: dict[str, float], b: dict[str, float]) -> float:
|
|
152
|
+
if len(a) > len(b):
|
|
153
|
+
a, b = b, a
|
|
154
|
+
return sum(x * b.get(t, 0.0) for t, x in a.items())
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def cluster_claims(recs: list[dict]) -> list[dict]:
|
|
158
|
+
"""Substantive comments grouped into claims. Most-liked first, each comment joins the closest
|
|
159
|
+
claim at or above ``CLAIM_SIM``; a reply below that joins its parent's claim, so a conversation
|
|
160
|
+
stays together. Claims are ranked by comments, then likes."""
|
|
161
|
+
sub = sorted((r for r in recs if r["category"] == "substantive"), key=lambda r: (-r["likes"], r["id"]))
|
|
162
|
+
vecs = dict(zip((r["id"] for r in sub), _vectors(sub)))
|
|
163
|
+
claims: list[dict] = []
|
|
164
|
+
home: dict[str, dict] = {}
|
|
165
|
+
for r in sub:
|
|
166
|
+
v = vecs[r["id"]]
|
|
167
|
+
best, score = None, 0.0
|
|
168
|
+
for c in claims:
|
|
169
|
+
s = _cos(v, c["centroid"]) / (math.sqrt(sum(x * x for x in c["centroid"].values())) or 1.0)
|
|
170
|
+
if s > score:
|
|
171
|
+
best, score = c, s
|
|
172
|
+
if score < CLAIM_SIM:
|
|
173
|
+
best = home.get(r["parent"] or "") or None
|
|
174
|
+
if best is None:
|
|
175
|
+
best = {"centroid": {}, "members": []}
|
|
176
|
+
claims.append(best)
|
|
177
|
+
best["members"].append(r)
|
|
178
|
+
for t, x in v.items():
|
|
179
|
+
best["centroid"][t] = best["centroid"].get(t, 0.0) + x
|
|
180
|
+
home[r["id"]] = best
|
|
181
|
+
return [_claim(c) for c in sorted(claims, key=lambda c: (-len(c["members"]), -sum(m["likes"] for m in c["members"])))]
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _claim(c: dict) -> dict:
|
|
185
|
+
members = c["members"]
|
|
186
|
+
# the excerpt comes from the most-liked member that attacks no one, when there is one
|
|
187
|
+
civil = [m for m in members if not INSULT.search(m["_text"])] or members
|
|
188
|
+
top = max(civil, key=lambda m: (m["likes"], m["words"]))
|
|
189
|
+
stances = Counter(m["toward_video"] for m in members)
|
|
190
|
+
terms = [t for t, _ in sorted(c["centroid"].items(), key=lambda kv: -kv[1])[:4]]
|
|
191
|
+
return {"terms": terms, "comments": [m["id"] for m in members], "top_level": sum(1 for m in members if not m["parent"]),
|
|
192
|
+
"replies": sum(1 for m in members if m["parent"]), "critique": stances["critique"], "support": stances["support"],
|
|
193
|
+
"mixed": stances["mixed"], "neutral": stances["neutral"], "likes": sum(m["likes"] for m in members),
|
|
194
|
+
"excerpt": {"comment": top["id"], "text": excerpt(top["_text"])}, "threads": sorted({m["thread"] for m in members})}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
CATEGORIES = ("substantive", "reaction", "ad_hominem", "dismissal", "promotional", "coordinated_spam", "channel")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _balance_line(crit: int, supp: int) -> str:
|
|
201
|
+
if not crit and not supp:
|
|
202
|
+
return "No substantive comment takes a side, so there is no balance to report."
|
|
203
|
+
ratio = min(crit, supp) / max(crit, supp)
|
|
204
|
+
if ratio >= 0.5:
|
|
205
|
+
return f"Critique and support are both well represented: {crit} and {supp} substantive comments."
|
|
206
|
+
side, other = ("critique", "support") if crit > supp else ("support", "critique")
|
|
207
|
+
return f"Weighted toward {side}: {max(crit, supp)} {side} against {min(crit, supp)} {other} among substantive comments."
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def balance(comments: list[dict], *, title: str = "", url: str = "", max_claims: int = 20) -> dict:
|
|
211
|
+
"""The balanced synthesis of one video's comments. ``comments`` are flat records with ``id``,
|
|
212
|
+
``parent`` (None, ``"root"`` or a comment id), ``text``, ``like_count`` and optionally
|
|
213
|
+
``author_is_uploader``; any author field is ignored. Pure and deterministic."""
|
|
214
|
+
recs = _records(comments)
|
|
215
|
+
groups = mark_coordination(recs)
|
|
216
|
+
claims = cluster_claims(recs)
|
|
217
|
+
cats = Counter(r["category"] for r in recs)
|
|
218
|
+
sub = [r for r in recs if r["category"] == "substantive"]
|
|
219
|
+
toward = Counter(r["toward_video"] for r in sub)
|
|
220
|
+
likes = sum(r["likes"] for r in recs)
|
|
221
|
+
spam_likes = sum(r["likes"] for r in recs if r["category"] in ("promotional", "coordinated_spam"))
|
|
222
|
+
top10 = sorted(recs, key=lambda r: -r["likes"])[:10]
|
|
223
|
+
public = [{k: v for k, v in r.items() if not k.startswith("_") and k != "promo"} for r in recs]
|
|
224
|
+
return {
|
|
225
|
+
"format": FORMAT, "title": title, "url": url, "method": "compiled",
|
|
226
|
+
"counts": {"comments": len(recs), "top_level": sum(1 for r in recs if not r["parent"]), "replies": sum(1 for r in recs if r["parent"]),
|
|
227
|
+
"replies_parent_missing": sum(1 for r in recs if r["parent_missing"]), **{c: cats[c] for c in CATEGORIES}},
|
|
228
|
+
"balance": {"critique": toward["critique"], "support": toward["support"], "mixed": toward["mixed"], "neutral": toward["neutral"],
|
|
229
|
+
"questions": sum(1 for r in sub if r["question"]), "pointers": sum(1 for r in sub if r["pointer"]),
|
|
230
|
+
"sentence": _balance_line(toward["critique"], toward["support"]),
|
|
231
|
+
"likes_total": likes, "likes_to_promotion": spam_likes,
|
|
232
|
+
"top10_by_likes": dict(Counter(r["category"] if r["category"] != "substantive" else f"substantive:{r['toward_video']}" for r in top10))},
|
|
233
|
+
"claims": claims[:max_claims], "claims_total": len(claims), "coordinated": groups, "comments": public,
|
|
234
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""The balanced comment synthesis as a page a person reads, and as a receipted Gather item.
|
|
2
|
+
|
|
3
|
+
``report_markdown`` renders a ``gather.comment-balance/1`` record: the balance first, then the
|
|
4
|
+
substantive claims ranked with their counts and one short excerpt each, then what was set aside and
|
|
5
|
+
why, every group with the comment ids behind it. It names no commenter. ``balance_item`` wraps the
|
|
6
|
+
page as a ``compiled`` derived item over the comment items it read (``gather.derive``), so the
|
|
7
|
+
receipt says it was assembled by rules and points back to the exact comments.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from gather.derive import COMPILED, derive
|
|
13
|
+
from gather.item import Item
|
|
14
|
+
|
|
15
|
+
SET_ASIDE = (
|
|
16
|
+
("coordinated_spam", "Likely coordinated spam"),
|
|
17
|
+
("promotional", "Promotion from a single comment"),
|
|
18
|
+
("ad_hominem", "About the person, not the argument"),
|
|
19
|
+
("dismissal", "Dismissal with no reason given"),
|
|
20
|
+
("reaction", "Short reactions"),
|
|
21
|
+
("channel", "From the channel itself"),
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _ids(ids: list[str], limit: int = 12) -> str:
|
|
26
|
+
shown = ", ".join(ids[:limit])
|
|
27
|
+
return shown + (f" and {len(ids) - limit} more" if len(ids) > limit else "")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _claim_lines(n: int, c: dict) -> list[str]:
|
|
31
|
+
sides = ", ".join(f"{c[k]} {k}" for k in ("critique", "support", "mixed", "neutral") if c[k])
|
|
32
|
+
return [f"{n}. **{', '.join(c['terms']) or 'untitled'}**: {len(c['comments'])} comments ({c['top_level']} top-level, "
|
|
33
|
+
f"{c['replies']} replies; {sides}), {c['likes']} likes.",
|
|
34
|
+
f" Excerpt: \"{c['excerpt']['text']}\" (comment {c['excerpt']['comment']})",
|
|
35
|
+
f" Comments: {_ids(c['comments'])}", ""]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def report_markdown(rec: dict) -> str:
|
|
39
|
+
"""The record as Markdown: balance, ranked claims, what was set aside, and how it was made."""
|
|
40
|
+
k, b = rec["counts"], rec["balance"]
|
|
41
|
+
out = [f"# Comment balance{': ' + rec['title'] if rec.get('title') else ''}", ""]
|
|
42
|
+
if rec.get("url"):
|
|
43
|
+
out += [f"Source: {rec['url']}", ""]
|
|
44
|
+
missing = k["replies_parent_missing"]
|
|
45
|
+
orphans = f", {missing} of them with a parent that was not loaded" if missing else ""
|
|
46
|
+
out += [f"{k['comments']} comments read: {k['top_level']} top-level and {k['replies']} replies{orphans}.",
|
|
47
|
+
"Commenters are not named. Each excerpt is under 15 words.", "", "## Balance", "", f"- {b['sentence']}",
|
|
48
|
+
f"- Substantive comments: {k['substantive']} ({b['critique']} critique, {b['support']} support, {b['mixed']} mixed, "
|
|
49
|
+
f"{b['neutral']} neutral; {b['questions']} ask a question, {b['pointers']} point to a source).",
|
|
50
|
+
f"- Set aside: {k['coordinated_spam']} likely coordinated spam, {k['promotional']} other promotion, {k['ad_hominem']} about the "
|
|
51
|
+
f"person, {k['dismissal']} dismissals, {k['reaction']} short reactions, {k['channel']} from the channel.",
|
|
52
|
+
f"- Likes: {b['likes_total']} in all, {b['likes_to_promotion']} of them on promotion.",
|
|
53
|
+
f"- The 10 most-liked comments: {', '.join(f'{v} {n}' for n, v in sorted(b['top10_by_likes'].items(), key=lambda kv: -kv[1]))}.", "",
|
|
54
|
+
"## Substantive claims, ranked", ""]
|
|
55
|
+
for n, c in enumerate(rec["claims"], 1):
|
|
56
|
+
out += _claim_lines(n, c)
|
|
57
|
+
if rec["claims_total"] > len(rec["claims"]):
|
|
58
|
+
out += [f"{rec['claims_total'] - len(rec['claims'])} smaller claims are in the JSON record.", ""]
|
|
59
|
+
out += ["## Set aside, with the reason", ""]
|
|
60
|
+
for g in rec["coordinated"]:
|
|
61
|
+
why = f"shared signals: {', '.join(g['shared_signals'])}" if g["shared_signals"] else g["kind"]
|
|
62
|
+
out.append(f"- Group {g['group']}, {g['kind']}: {len(g['comments'])} comments, {g['likes']} likes ({why}). Comments: {_ids(g['comments'])}")
|
|
63
|
+
for cat, label in SET_ASIDE[1:]:
|
|
64
|
+
ids = [r["id"] for r in rec["comments"] if r["category"] == cat]
|
|
65
|
+
if ids:
|
|
66
|
+
out.append(f"- {label}: {len(ids)}. Comments: {_ids(ids, 8)}")
|
|
67
|
+
out += ["", "## How this was made", "",
|
|
68
|
+
"Deterministic rules, no model: the method is `compiled`. Categories come from word patterns, and an argument "
|
|
69
|
+
"marker outweighs an insult, so a harsh critique that gives a reason stays substantive. Claims group comments by "
|
|
70
|
+
"shared vocabulary. Sarcasm and irony can be misread. A reply that agrees with its parent counts toward the "
|
|
71
|
+
"parent's side.", ""]
|
|
72
|
+
return "\n".join(out)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def balance_item(rec: dict, inputs: list[Item], *, fetched_at: float, ref: str) -> Item:
|
|
76
|
+
"""The page as a ``compiled`` item derived from the comment items it read."""
|
|
77
|
+
return derive(inputs, report_markdown(rec), fetched_at=fetched_at, ref=ref, method=COMPILED,
|
|
78
|
+
title=f"comment balance: {rec.get('title') or ref}", kind="synthesis", source="synthesis")
|