gather-engine 2.4.0__tar.gz → 2.5.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/src/gather_engine.egg-info → gather_engine-2.5.0}/PKG-INFO +4 -4
- {gather_engine-2.4.0 → gather_engine-2.5.0}/README.md +3 -3
- {gather_engine-2.4.0 → gather_engine-2.5.0}/pyproject.toml +1 -1
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/__init__.py +1 -1
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/cli_ext.py +2 -0
- gather_engine-2.5.0/src/gather/comment_balance.py +234 -0
- gather_engine-2.5.0/src/gather/comment_report.py +78 -0
- gather_engine-2.5.0/src/gather/comment_signals.py +141 -0
- gather_engine-2.5.0/src/gather/video_bundle.py +170 -0
- gather_engine-2.5.0/src/gather/video_bundle_cmd.py +100 -0
- gather_engine-2.5.0/src/gather/video_bundle_run.py +138 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/video_cmd.py +3 -1
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/video_source.py +6 -1
- {gather_engine-2.4.0 → gather_engine-2.5.0/src/gather_engine.egg-info}/PKG-INFO +4 -4
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather_engine.egg-info/SOURCES.txt +8 -0
- gather_engine-2.5.0/tests/test_comment_balance.py +143 -0
- gather_engine-2.5.0/tests/test_video_bundle.py +166 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/LICENSE +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/NOTICE +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/setup.cfg +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/__main__.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/_vendor/__init__.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/_vendor/safe_spawn.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/api.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/arxiv.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/availability.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/backends.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/backends_browser.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/benchmark.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/browser.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/browser_evidence.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/cache.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/captions.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/channel.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/channel_ledger.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/citecheck.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/cli.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/client_mcp.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/client_network.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/commands.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/context.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/corpus_cmd.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/crawl.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/credentials.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/derive.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/digest.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/docs.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/docs_tool.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/dom.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/export.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/extract.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/fastparse.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/federation.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/federation_cmd.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/federation_policy.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/federation_receipt.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/feed.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/fetch.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/filter_ledger.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/flagship.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/grant_checks.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/grants.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/http_call.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/interop.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/item.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/ledger_cmd.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/ledger_verify.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/local_model.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/localpath.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/mcp.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/method.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/model.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/monitor.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/net.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/ocr.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/pacing.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/payloads.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/pdf.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/pilot.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/pilot_bundle.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/pilot_commands.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/pilot_manifest.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/pilot_report.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/pilot_sources.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/provenance.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/recall.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/reddit.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/reddit_cmd.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/reddit_items.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/report.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/report_cmd.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/route.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/run.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/run_config.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/schema_extract.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/scholar.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/scope.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/search.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/source.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/spawn.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/store.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/stream.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/track.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/transcribe.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/video.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/web.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/web_commands.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/youtube_api.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/youtube_cmd.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/youtube_probe.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/youtube_route.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather/ytdlp.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather_engine.egg-info/dependency_links.txt +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather_engine.egg-info/entry_points.txt +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather_engine.egg-info/requires.txt +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/src/gather_engine.egg-info/top_level.txt +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_api.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_arxiv.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_availability.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_backends.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_backends_browser.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_backends_card.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_benchmark.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_browser_evidence.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_cache.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_captions.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_channel.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_channel_ledger.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_citecheck.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_cli.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_cli_web.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_client_disclosure.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_client_network.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_client_package.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_client_profile.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_client_setup.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_client_vendored.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_context.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_context_mountinfo.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_crawl.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_credentials.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_demo.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_derive.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_device_names.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_digest.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_docs.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_dom.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_export.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_extract.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_fastparse.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_federation.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_federation_cli.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_federation_receipt.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_feed.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_fetch.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_filter_ledger.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_flagship_cli.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_grants.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_hard_sources.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_http_call.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_interop.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_item.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_localpath.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_mcp.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_mcp_annotations.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_method.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_model.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_monitor.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_net.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_nonlocal_paths.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_pacing.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_package.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_pdf.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_pilot.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_pilot_bundle.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_pilot_cli.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_pilot_manifest.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_pilot_refresh.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_pilot_report.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_pilot_sample.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_pilot_showcase.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_pilot_sources.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_provenance.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_recall.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_reddit.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_reddit_grants.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_release_workflow.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_repo_art.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_repo_card.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_report.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_report_quotes.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_route.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_run.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_schema_extract.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_scholar.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_scope.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_search.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_spawn_children.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_spawn_working_folder.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_store.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_stream.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_track.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_vendored.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_verify_pilot_cli.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_video.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_video_source.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_video_spawn.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_web.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_web_refine.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_xml_safety.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_youtube_api.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_youtube_grants.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_youtube_probe.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_youtube_route.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.0}/tests/test_ytdlp.py +0 -0
- {gather_engine-2.4.0 → gather_engine-2.5.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.5.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
|
|
@@ -46,11 +46,11 @@ Gather is for researchers, journalists and developers who feed sources to a pers
|
|
|
46
46
|
|
|
47
47
|
## Install and first run
|
|
48
48
|
|
|
49
|
-
Python 3.11 or newer. Version 2.
|
|
49
|
+
Python 3.11 or newer. Version 2.5.0 is on PyPI as `gather-engine`.
|
|
50
50
|
|
|
51
51
|
```bash
|
|
52
52
|
pip install gather-engine
|
|
53
|
-
gather --version # gather 2.
|
|
53
|
+
gather --version # gather 2.5.0
|
|
54
54
|
```
|
|
55
55
|
|
|
56
56
|
Extract a saved page into hashed blocks, each tied to its place in the page:
|
|
@@ -302,7 +302,7 @@ The `web` adapter reads static HTML and does not run JavaScript; a client-render
|
|
|
302
302
|
- [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
303
|
- [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
304
|
- [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.
|
|
305
|
+
- [CHANGELOG.md](https://github.com/HarperZ9/gather/blob/main/CHANGELOG.md): version history. Current release: 2.5.0.
|
|
306
306
|
|
|
307
307
|
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
308
|
|
|
@@ -11,11 +11,11 @@ Gather is for researchers, journalists and developers who feed sources to a pers
|
|
|
11
11
|
|
|
12
12
|
## Install and first run
|
|
13
13
|
|
|
14
|
-
Python 3.11 or newer. Version 2.
|
|
14
|
+
Python 3.11 or newer. Version 2.5.0 is on PyPI as `gather-engine`.
|
|
15
15
|
|
|
16
16
|
```bash
|
|
17
17
|
pip install gather-engine
|
|
18
|
-
gather --version # gather 2.
|
|
18
|
+
gather --version # gather 2.5.0
|
|
19
19
|
```
|
|
20
20
|
|
|
21
21
|
Extract a saved page into hashed blocks, each tied to its place in the page:
|
|
@@ -267,7 +267,7 @@ The `web` adapter reads static HTML and does not run JavaScript; a client-render
|
|
|
267
267
|
- [docs/WEB-ENGINE-UPLIFT.md](https://github.com/HarperZ9/gather/blob/main/docs/WEB-ENGINE-UPLIFT.md): the web-data engine roadmap and benchmarks.
|
|
268
268
|
- [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.
|
|
269
269
|
- [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.
|
|
270
|
-
- [CHANGELOG.md](https://github.com/HarperZ9/gather/blob/main/CHANGELOG.md): version history. Current release: 2.
|
|
270
|
+
- [CHANGELOG.md](https://github.com/HarperZ9/gather/blob/main/CHANGELOG.md): version history. Current release: 2.5.0.
|
|
271
271
|
|
|
272
272
|
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).
|
|
273
273
|
|
|
@@ -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.5.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"
|
|
@@ -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.5.0"
|
|
32
32
|
|
|
33
33
|
__all__ = [
|
|
34
34
|
"Catalog", "Corpus", "CorpusRootDescriptor", "CorpusRootIdentity", "Digest", "Item", "NullProvenanceProvider", "NullSynthesizer",
|
|
@@ -9,11 +9,13 @@ def add_extension_parsers(sub, add_common: Callable) -> None:
|
|
|
9
9
|
from gather.ledger_cmd import add_ledger_parser
|
|
10
10
|
from gather.reddit_cmd import add_reddit_parser
|
|
11
11
|
from gather.report_cmd import add_report_parsers
|
|
12
|
+
from gather.video_bundle_cmd import add_bundle_parsers
|
|
12
13
|
from gather.video_cmd import add_channel_parser
|
|
13
14
|
from gather.youtube_cmd import add_video_parsers
|
|
14
15
|
|
|
15
16
|
add_video_parsers(sub, add_common)
|
|
16
17
|
add_channel_parser(sub)
|
|
18
|
+
add_bundle_parsers(sub)
|
|
17
19
|
add_reddit_parser(sub, add_common)
|
|
18
20
|
add_report_parsers(sub, add_common)
|
|
19
21
|
add_ledger_parser(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")
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Per-comment signals for the balanced comment synthesis (``gather.comment_balance``).
|
|
2
|
+
|
|
3
|
+
Each comment gets one category and the evidence for it: ``substantive`` (an argument, a question
|
|
4
|
+
or a pointer about the topic), ``reaction`` (short, no argument), ``ad_hominem`` (an attack on a
|
|
5
|
+
person, or on a person's associations, with no argument), ``dismissal`` (rejects the work with no
|
|
6
|
+
argument), ``promotional`` (testimony that points to a product), and ``channel`` (the uploader's
|
|
7
|
+
own comment). Substantive comments also get a stance toward the video: ``critique``, ``support``
|
|
8
|
+
or ``neutral``.
|
|
9
|
+
|
|
10
|
+
The rules are lexical and deterministic, and stance-neutral: the same words count the same way
|
|
11
|
+
whoever they are aimed at. An argument marker always wins over an insult, so a harsh critique that
|
|
12
|
+
gives a reason stays substantive. Pure: no network, no model.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
|
|
20
|
+
URL = re.compile(r"https?://\S+|www\.\S+", re.I)
|
|
21
|
+
MENTION = re.compile(r"(?<!\w)@[\w.\-]+")
|
|
22
|
+
WORD = re.compile(r"[a-z][a-z'\-]*")
|
|
23
|
+
|
|
24
|
+
STOPWORDS = frozenset("""
|
|
25
|
+
a an the and or but if then than so to of in on at by for with from into onto about as is are was were be been being am
|
|
26
|
+
do does did done doing have has had having it its it's this that these those there here he she him her his hers they them
|
|
27
|
+
their theirs we us our you your yours i me my mine what which who whom whose when where why how not no nor just only also
|
|
28
|
+
very really more most much many some any all each every both few other such own same can could would should will shall may
|
|
29
|
+
might must let lets let's get got gets like one two way thing things something anything nothing everything even still yet
|
|
30
|
+
because however therefore thus though although while whereas since until up down out over under again further once too
|
|
31
|
+
s t don doesn didn isn aren wasn weren won wouldn couldn shouldn can't don't doesn't isn't i'm i've i'd you're he's she's
|
|
32
|
+
that's what's there's they're we're it'll gonna wanna yeah oh ok okay lol video talk guy guys
|
|
33
|
+
""".split())
|
|
34
|
+
|
|
35
|
+
# An argument: a reason, a contrast, a definition, a test, a mechanism.
|
|
36
|
+
ARGUMENT = re.compile(
|
|
37
|
+
r"\b(because|therefore|thus|hence|so that|which means|implies|if\b.{1,60}\bthen|however|but\b|whereas|although|"
|
|
38
|
+
r"explain\w*|distinguish\w*|define\w*|definition|argu\w+|evidence|assum\w+|claim\w*|mechanism|predict\w*|"
|
|
39
|
+
r"test\w*|falsif\w+|counter\w*|conflat\w+|confus\w+|differen\w+|operation\w*|model\w*|hypothes\w+)\b", re.I)
|
|
40
|
+
|
|
41
|
+
CRITIQUE = re.compile(
|
|
42
|
+
r"\b(however|disagree\w*|wrong|mistak\w+|flaw\w*|fails?\b|failed|misses|problem with|the problem|"
|
|
43
|
+
r"not convinc\w+|unconvinc\w+|doesn'?t (explain|follow|address|answer|work)|does not (explain|follow|address|answer)|"
|
|
44
|
+
r"not the same|isn'?t (true|right|how)|conflat\w+|circular|begs the question|unfalsifiable|no evidence|"
|
|
45
|
+
r"category error|overstat\w+|hand-?wav\w+|ignores?|skeptic\w*|sceptic\w*|doubt\w*|objection\w*|opposite)\b", re.I)
|
|
46
|
+
|
|
47
|
+
SUPPORT = re.compile(
|
|
48
|
+
r"\b(agree\w*|great|brilliant|love[ds]?|loving|fantastic|thank\w*|excellent|insightful|best|amazing|awesome|"
|
|
49
|
+
r"exactly|well said|spot on|makes sense|wonderful|superb|enjoy\w*|fascinating|mind.?blow\w*|right on|beautiful\w*|"
|
|
50
|
+
r"inspir\w+|incredible|favou?rite|genius|legend\w*|good (one|point|interview|episode|talk|conversation)|so good|"
|
|
51
|
+
r"more of this|keep (it|them) coming|fair point|nailed it)\b", re.I)
|
|
52
|
+
|
|
53
|
+
INSULT = re.compile(
|
|
54
|
+
r"\b(grifter\w*|charlatan\w*|fraud\w*|clown\w*|idiot\w*|moron\w*|stupid|dumb|quack\w*|huckster\w*|con ?man|"
|
|
55
|
+
r"snake ?oil|creep\w*|pseudo-?intellectual\w*|hack\b|loser\w*|imbecile\w*|delusional|narcissis\w+|crank\w*|"
|
|
56
|
+
r"is a joke|bullshitter\w*)\b", re.I)
|
|
57
|
+
|
|
58
|
+
DISMISS = re.compile(r"\b(nonsense|pseudoscien\w+|word salad|gibberish|garbage|rubbish|drivel|bs\b|bullshit|waste of time|meaningless|babble)\b", re.I)
|
|
59
|
+
|
|
60
|
+
# An attack on a person's associations instead of their claim (circumstantial ad hominem).
|
|
61
|
+
ASSOCIATION = re.compile(r"\b(epstein|associat\w+ with|friends? with|funded by|ties to|paid by|shill\w*)\b", re.I)
|
|
62
|
+
|
|
63
|
+
PERSON = re.compile(r"\b(he|him|his|she|her|they|them|this guy|the guest|the host|you|your)\b", re.I)
|
|
64
|
+
|
|
65
|
+
# Promotion: testimony, a product named with its author, praise for the product, a call to act.
|
|
66
|
+
PROMO_SIGNALS: dict[str, re.Pattern[str]] = {
|
|
67
|
+
"product named with its author": re.compile(
|
|
68
|
+
r"\b(book|novel|guide|course|program|app|channel)\b[^.!?\n]{0,40}\b(called|titled|named|by|from)\b\s+[A-Z]"
|
|
69
|
+
r"|\bread(ing)?\s+(the\s+)?[A-Z][\w']+(\s+[A-Z][\w']+){1,5}\s*(\(|,?\s*by\b|\s+from\b)\s*[A-Z]\w+\s+[A-Z]\w+", re.S),
|
|
70
|
+
"personal testimony": re.compile(
|
|
71
|
+
r"\b(changed my life|my (only )?savio(u)?r|my (anxiety|depression|stress)\b|i (finally )?found (all )?the answers|"
|
|
72
|
+
r"luckily,? (i|someone)|i feel (such|so much)|so much more energy|i was stuck|i used to (feel|get) (so|stuck)|"
|
|
73
|
+
r"shifted my perspective|helped me (break|get) free)\b", re.I),
|
|
74
|
+
"praise for the product": re.compile(
|
|
75
|
+
r"\b(genius|genial|must.read|should be taught in schools|nobody talks about|secret info|life.?changing|"
|
|
76
|
+
r"my favou?rite one|eye.?opening read)\b", re.I),
|
|
77
|
+
"call to action": re.compile(r"\b(check out|look up|dm me|message me|whatsapp|telegram|link in (my )?bio|sign up)\b", re.I),
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(slots=True)
|
|
82
|
+
class Signals:
|
|
83
|
+
"""What the rules saw in one comment."""
|
|
84
|
+
|
|
85
|
+
category: str
|
|
86
|
+
stance: str = "neutral"
|
|
87
|
+
evidence: list[str] = field(default_factory=list)
|
|
88
|
+
promo: list[str] = field(default_factory=list)
|
|
89
|
+
words: int = 0
|
|
90
|
+
question: bool = False
|
|
91
|
+
pointer: bool = False
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def tokens(text: str) -> list[str]:
|
|
95
|
+
"""Content words, lower case, with links, mentions and stopwords removed."""
|
|
96
|
+
plain = MENTION.sub(" ", URL.sub(" ", text or "")).lower()
|
|
97
|
+
return [w.strip("'-") for w in WORD.findall(plain) if len(w) > 2 and w.strip("'-") not in STOPWORDS]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def promo_signals(text: str) -> list[str]:
|
|
101
|
+
"""The promotion signals a comment carries, by name."""
|
|
102
|
+
return [name for name, rx in PROMO_SIGNALS.items() if rx.search(text or "")]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _stance(text: str) -> tuple[str, list[str]]:
|
|
106
|
+
c = [m.group(0).lower() for m in CRITIQUE.finditer(text)]
|
|
107
|
+
s = [m.group(0).lower() for m in SUPPORT.finditer(text)]
|
|
108
|
+
if len(c) > len(s):
|
|
109
|
+
return "critique", c[:4]
|
|
110
|
+
if len(s) > len(c):
|
|
111
|
+
return "support", s[:4]
|
|
112
|
+
return ("mixed", (c + s)[:4]) if c else ("neutral", [])
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def classify(text: str, *, from_channel: bool = False) -> Signals:
|
|
116
|
+
"""One comment's category, stance and evidence. Pure and deterministic."""
|
|
117
|
+
raw = text or ""
|
|
118
|
+
words = len(MENTION.sub(" ", URL.sub(" ", raw)).split())
|
|
119
|
+
question = "?" in raw
|
|
120
|
+
pointer = bool(URL.search(raw)) or bool(re.search(r"\b(paper|preprint|study|see also|read)\b", raw, re.I))
|
|
121
|
+
if from_channel:
|
|
122
|
+
return Signals("channel", words=words, question=question, pointer=pointer, evidence=["the uploader's own comment"])
|
|
123
|
+
promo = promo_signals(raw)
|
|
124
|
+
if len(promo) >= 2:
|
|
125
|
+
return Signals("promotional", words=words, promo=promo, evidence=promo)
|
|
126
|
+
argued = bool(ARGUMENT.search(raw)) and words >= 8
|
|
127
|
+
insult, dismiss, assoc = INSULT.search(raw), DISMISS.search(raw), ASSOCIATION.search(raw)
|
|
128
|
+
person = PERSON.search(raw)
|
|
129
|
+
if not argued and (insult or assoc) and (person or words < 25):
|
|
130
|
+
hit = (insult or assoc).group(0).lower() # type: ignore[union-attr]
|
|
131
|
+
return Signals("ad_hominem", words=words, evidence=[f"about the person: {hit}"])
|
|
132
|
+
if not argued and dismiss and words < 30:
|
|
133
|
+
return Signals("dismissal", words=words, evidence=[f"rejects without a reason: {dismiss.group(0).lower()}"])
|
|
134
|
+
if argued or words >= 15 or (question and words >= 6) or (pointer and words >= 6):
|
|
135
|
+
stance, ev = _stance(raw)
|
|
136
|
+
return Signals("substantive", stance=stance, evidence=ev, words=words, question=question, pointer=pointer)
|
|
137
|
+
return Signals("reaction", words=words, question=question, evidence=["short, with no argument"])
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# A reply that opens by agreeing ("Exactly, ...", "Agreed.") sides with the comment it answers.
|
|
141
|
+
AGREE_OPEN = re.compile(r"^\W*(exactly|agreed?|yes|yep|true|right|indeed|well said|spot on|this\W*$|100%)\b", re.I)
|