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.
Files changed (249) hide show
  1. {gather_engine-2.4.0 → gather_engine-2.6.0}/PKG-INFO +27 -4
  2. gather_engine-2.4.0/src/gather_engine.egg-info/PKG-INFO → gather_engine-2.6.0/README.md +22 -38
  3. {gather_engine-2.4.0 → gather_engine-2.6.0}/pyproject.toml +4 -3
  4. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/__init__.py +1 -1
  5. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/cli_ext.py +6 -0
  6. gather_engine-2.6.0/src/gather/comment_balance.py +234 -0
  7. gather_engine-2.6.0/src/gather/comment_report.py +78 -0
  8. gather_engine-2.6.0/src/gather/comment_signals.py +141 -0
  9. gather_engine-2.6.0/src/gather/decompose_binary.py +127 -0
  10. gather_engine-2.6.0/src/gather/decompose_cmd.py +80 -0
  11. gather_engine-2.6.0/src/gather/decompose_content.py +164 -0
  12. gather_engine-2.6.0/src/gather/decompose_document.py +98 -0
  13. gather_engine-2.6.0/src/gather/decompose_dsp.py +156 -0
  14. gather_engine-2.6.0/src/gather/decompose_image.py +129 -0
  15. gather_engine-2.6.0/src/gather/decompose_pcm.py +166 -0
  16. gather_engine-2.6.0/src/gather/decompose_rebuild.py +130 -0
  17. gather_engine-2.6.0/src/gather/decompose_run.py +116 -0
  18. gather_engine-2.6.0/src/gather/decompose_text.py +150 -0
  19. gather_engine-2.6.0/src/gather/decompose_tree.py +154 -0
  20. gather_engine-2.6.0/src/gather/decompose_zip.py +141 -0
  21. gather_engine-2.6.0/src/gather/frame_sheets.py +121 -0
  22. gather_engine-2.6.0/src/gather/media_probe.py +110 -0
  23. gather_engine-2.6.0/src/gather/media_probe_audio.py +118 -0
  24. gather_engine-2.6.0/src/gather/media_probe_bmff.py +116 -0
  25. gather_engine-2.6.0/src/gather/media_probe_ebml.py +132 -0
  26. gather_engine-2.6.0/src/gather/media_probe_image.py +73 -0
  27. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/pacing.py +44 -0
  28. gather_engine-2.6.0/src/gather/saves_cmd.py +68 -0
  29. gather_engine-2.6.0/src/gather/saves_entities.py +123 -0
  30. gather_engine-2.6.0/src/gather/saves_lang.py +106 -0
  31. gather_engine-2.6.0/src/gather/saves_sidecar.py +197 -0
  32. gather_engine-2.6.0/src/gather/saves_tables.py +209 -0
  33. gather_engine-2.6.0/src/gather/saves_write.py +55 -0
  34. gather_engine-2.6.0/src/gather/storyboard.py +118 -0
  35. gather_engine-2.6.0/src/gather/video_bundle.py +175 -0
  36. gather_engine-2.6.0/src/gather/video_bundle_cmd.py +134 -0
  37. gather_engine-2.6.0/src/gather/video_bundle_run.py +239 -0
  38. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/video_cmd.py +3 -1
  39. gather_engine-2.6.0/src/gather/video_frames.py +106 -0
  40. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/video_source.py +11 -2
  41. gather_engine-2.4.0/README.md → gather_engine-2.6.0/src/gather_engine.egg-info/PKG-INFO +61 -3
  42. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather_engine.egg-info/SOURCES.txt +41 -0
  43. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather_engine.egg-info/requires.txt +5 -0
  44. gather_engine-2.6.0/tests/test_comment_balance.py +143 -0
  45. gather_engine-2.6.0/tests/test_decompose.py +258 -0
  46. gather_engine-2.6.0/tests/test_decompose_content.py +236 -0
  47. gather_engine-2.6.0/tests/test_saves.py +181 -0
  48. gather_engine-2.6.0/tests/test_saves_entities.py +70 -0
  49. gather_engine-2.6.0/tests/test_saves_media.py +124 -0
  50. gather_engine-2.6.0/tests/test_storyboard.py +108 -0
  51. gather_engine-2.6.0/tests/test_video_bundle.py +166 -0
  52. gather_engine-2.6.0/tests/test_video_frames.py +246 -0
  53. {gather_engine-2.4.0 → gather_engine-2.6.0}/LICENSE +0 -0
  54. {gather_engine-2.4.0 → gather_engine-2.6.0}/NOTICE +0 -0
  55. {gather_engine-2.4.0 → gather_engine-2.6.0}/setup.cfg +0 -0
  56. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/__main__.py +0 -0
  57. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/_vendor/__init__.py +0 -0
  58. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/_vendor/safe_spawn.py +0 -0
  59. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/api.py +0 -0
  60. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/arxiv.py +0 -0
  61. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/availability.py +0 -0
  62. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/backends.py +0 -0
  63. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/backends_browser.py +0 -0
  64. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/benchmark.py +0 -0
  65. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/browser.py +0 -0
  66. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/browser_evidence.py +0 -0
  67. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/cache.py +0 -0
  68. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/captions.py +0 -0
  69. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/channel.py +0 -0
  70. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/channel_ledger.py +0 -0
  71. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/citecheck.py +0 -0
  72. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/cli.py +0 -0
  73. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/client_mcp.py +0 -0
  74. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/client_network.py +0 -0
  75. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/commands.py +0 -0
  76. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/context.py +0 -0
  77. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/corpus_cmd.py +0 -0
  78. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/crawl.py +0 -0
  79. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/credentials.py +0 -0
  80. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/derive.py +0 -0
  81. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/digest.py +0 -0
  82. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/docs.py +0 -0
  83. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/docs_tool.py +0 -0
  84. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/dom.py +0 -0
  85. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/export.py +0 -0
  86. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/extract.py +0 -0
  87. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/fastparse.py +0 -0
  88. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/federation.py +0 -0
  89. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/federation_cmd.py +0 -0
  90. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/federation_policy.py +0 -0
  91. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/federation_receipt.py +0 -0
  92. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/feed.py +0 -0
  93. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/fetch.py +0 -0
  94. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/filter_ledger.py +0 -0
  95. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/flagship.py +0 -0
  96. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/grant_checks.py +0 -0
  97. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/grants.py +0 -0
  98. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/http_call.py +0 -0
  99. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/interop.py +0 -0
  100. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/item.py +0 -0
  101. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/ledger_cmd.py +0 -0
  102. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/ledger_verify.py +0 -0
  103. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/local_model.py +0 -0
  104. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/localpath.py +0 -0
  105. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/mcp.py +0 -0
  106. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/method.py +0 -0
  107. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/model.py +0 -0
  108. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/monitor.py +0 -0
  109. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/net.py +0 -0
  110. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/ocr.py +0 -0
  111. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/payloads.py +0 -0
  112. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/pdf.py +0 -0
  113. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/pilot.py +0 -0
  114. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/pilot_bundle.py +0 -0
  115. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/pilot_commands.py +0 -0
  116. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/pilot_manifest.py +0 -0
  117. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/pilot_report.py +0 -0
  118. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/pilot_sources.py +0 -0
  119. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/provenance.py +0 -0
  120. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/recall.py +0 -0
  121. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/reddit.py +0 -0
  122. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/reddit_cmd.py +0 -0
  123. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/reddit_items.py +0 -0
  124. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/report.py +0 -0
  125. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/report_cmd.py +0 -0
  126. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/route.py +0 -0
  127. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/run.py +0 -0
  128. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/run_config.py +0 -0
  129. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/schema_extract.py +0 -0
  130. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/scholar.py +0 -0
  131. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/scope.py +0 -0
  132. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/search.py +0 -0
  133. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/source.py +0 -0
  134. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/spawn.py +0 -0
  135. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/store.py +0 -0
  136. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/stream.py +0 -0
  137. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/track.py +0 -0
  138. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/transcribe.py +0 -0
  139. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/video.py +0 -0
  140. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/web.py +0 -0
  141. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/web_commands.py +0 -0
  142. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/youtube_api.py +0 -0
  143. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/youtube_cmd.py +0 -0
  144. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/youtube_probe.py +0 -0
  145. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/youtube_route.py +0 -0
  146. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather/ytdlp.py +0 -0
  147. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather_engine.egg-info/dependency_links.txt +0 -0
  148. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather_engine.egg-info/entry_points.txt +0 -0
  149. {gather_engine-2.4.0 → gather_engine-2.6.0}/src/gather_engine.egg-info/top_level.txt +0 -0
  150. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_api.py +0 -0
  151. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_arxiv.py +0 -0
  152. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_availability.py +0 -0
  153. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_backends.py +0 -0
  154. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_backends_browser.py +0 -0
  155. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_backends_card.py +0 -0
  156. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_benchmark.py +0 -0
  157. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_browser_evidence.py +0 -0
  158. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_cache.py +0 -0
  159. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_captions.py +0 -0
  160. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_channel.py +0 -0
  161. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_channel_ledger.py +0 -0
  162. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_citecheck.py +0 -0
  163. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_cli.py +0 -0
  164. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_cli_web.py +0 -0
  165. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_client_disclosure.py +0 -0
  166. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_client_network.py +0 -0
  167. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_client_package.py +0 -0
  168. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_client_profile.py +0 -0
  169. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_client_setup.py +0 -0
  170. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_client_vendored.py +0 -0
  171. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_context.py +0 -0
  172. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_context_mountinfo.py +0 -0
  173. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_crawl.py +0 -0
  174. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_credentials.py +0 -0
  175. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_demo.py +0 -0
  176. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_derive.py +0 -0
  177. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_device_names.py +0 -0
  178. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_digest.py +0 -0
  179. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_docs.py +0 -0
  180. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_dom.py +0 -0
  181. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_export.py +0 -0
  182. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_extract.py +0 -0
  183. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_fastparse.py +0 -0
  184. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_federation.py +0 -0
  185. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_federation_cli.py +0 -0
  186. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_federation_receipt.py +0 -0
  187. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_feed.py +0 -0
  188. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_fetch.py +0 -0
  189. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_filter_ledger.py +0 -0
  190. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_flagship_cli.py +0 -0
  191. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_grants.py +0 -0
  192. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_hard_sources.py +0 -0
  193. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_http_call.py +0 -0
  194. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_interop.py +0 -0
  195. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_item.py +0 -0
  196. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_localpath.py +0 -0
  197. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_mcp.py +0 -0
  198. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_mcp_annotations.py +0 -0
  199. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_method.py +0 -0
  200. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_model.py +0 -0
  201. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_monitor.py +0 -0
  202. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_net.py +0 -0
  203. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_nonlocal_paths.py +0 -0
  204. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_pacing.py +0 -0
  205. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_package.py +0 -0
  206. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_pdf.py +0 -0
  207. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_pilot.py +0 -0
  208. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_pilot_bundle.py +0 -0
  209. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_pilot_cli.py +0 -0
  210. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_pilot_manifest.py +0 -0
  211. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_pilot_refresh.py +0 -0
  212. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_pilot_report.py +0 -0
  213. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_pilot_sample.py +0 -0
  214. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_pilot_showcase.py +0 -0
  215. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_pilot_sources.py +0 -0
  216. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_provenance.py +0 -0
  217. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_recall.py +0 -0
  218. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_reddit.py +0 -0
  219. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_reddit_grants.py +0 -0
  220. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_release_workflow.py +0 -0
  221. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_repo_art.py +0 -0
  222. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_repo_card.py +0 -0
  223. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_report.py +0 -0
  224. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_report_quotes.py +0 -0
  225. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_route.py +0 -0
  226. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_run.py +0 -0
  227. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_schema_extract.py +0 -0
  228. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_scholar.py +0 -0
  229. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_scope.py +0 -0
  230. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_search.py +0 -0
  231. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_spawn_children.py +0 -0
  232. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_spawn_working_folder.py +0 -0
  233. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_store.py +0 -0
  234. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_stream.py +0 -0
  235. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_track.py +0 -0
  236. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_vendored.py +0 -0
  237. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_verify_pilot_cli.py +0 -0
  238. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_video.py +0 -0
  239. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_video_source.py +0 -0
  240. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_video_spawn.py +0 -0
  241. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_web.py +0 -0
  242. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_web_refine.py +0 -0
  243. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_xml_safety.py +0 -0
  244. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_youtube_api.py +0 -0
  245. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_youtube_grants.py +0 -0
  246. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_youtube_probe.py +0 -0
  247. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_youtube_route.py +0 -0
  248. {gather_engine-2.4.0 → gather_engine-2.6.0}/tests/test_ytdlp.py +0 -0
  249. {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.4.0
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.4.0 is on PyPI as `gather-engine`.
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.4.0
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.4.0.
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.4.0 is on PyPI as `gather-engine`.
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.4.0
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.4.0.
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.4.0"
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
- all = ["lxml>=5", "playwright>=1.4"]
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.4.0"
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")