3tears-scrape 0.18.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 (60) hide show
  1. 3tears_scrape-0.18.0/.gitignore +216 -0
  2. 3tears_scrape-0.18.0/LICENSE +21 -0
  3. 3tears_scrape-0.18.0/PKG-INFO +221 -0
  4. 3tears_scrape-0.18.0/README.md +192 -0
  5. 3tears_scrape-0.18.0/pyproject.toml +70 -0
  6. 3tears_scrape-0.18.0/sidecar/.dockerignore +8 -0
  7. 3tears_scrape-0.18.0/sidecar/.python-version +1 -0
  8. 3tears_scrape-0.18.0/sidecar/Dockerfile +32 -0
  9. 3tears_scrape-0.18.0/sidecar/LICENSE +619 -0
  10. 3tears_scrape-0.18.0/sidecar/conftest.py +13 -0
  11. 3tears_scrape-0.18.0/sidecar/docker-compose.yml +20 -0
  12. 3tears_scrape-0.18.0/sidecar/entrypoint.sh +36 -0
  13. 3tears_scrape-0.18.0/sidecar/main.py +743 -0
  14. 3tears_scrape-0.18.0/sidecar/pyproject.toml +23 -0
  15. 3tears_scrape-0.18.0/sidecar/tests/__init__.py +0 -0
  16. 3tears_scrape-0.18.0/sidecar/tests/test_render_contract.py +1268 -0
  17. 3tears_scrape-0.18.0/sidecar/uv.lock +743 -0
  18. 3tears_scrape-0.18.0/src/threetears/scrape/__init__.py +12 -0
  19. 3tears_scrape-0.18.0/src/threetears/scrape/collections.py +631 -0
  20. 3tears_scrape-0.18.0/src/threetears/scrape/driver.py +221 -0
  21. 3tears_scrape-0.18.0/src/threetears/scrape/drivers/__init__.py +5 -0
  22. 3tears_scrape-0.18.0/src/threetears/scrape/drivers/api.py +262 -0
  23. 3tears_scrape-0.18.0/src/threetears/scrape/drivers/camoufox.py +344 -0
  24. 3tears_scrape-0.18.0/src/threetears/scrape/drivers/document.py +418 -0
  25. 3tears_scrape-0.18.0/src/threetears/scrape/drivers/listing_detail.py +320 -0
  26. 3tears_scrape-0.18.0/src/threetears/scrape/drivers/multi_document.py +373 -0
  27. 3tears_scrape-0.18.0/src/threetears/scrape/drivers/network_capture.py +222 -0
  28. 3tears_scrape-0.18.0/src/threetears/scrape/drivers/nodriver_download.py +184 -0
  29. 3tears_scrape-0.18.0/src/threetears/scrape/drivers/nodriver_sidecar.py +148 -0
  30. 3tears_scrape-0.18.0/src/threetears/scrape/enrichment.py +146 -0
  31. 3tears_scrape-0.18.0/src/threetears/scrape/eval_loop.py +1589 -0
  32. 3tears_scrape-0.18.0/src/threetears/scrape/extraction.py +1906 -0
  33. 3tears_scrape-0.18.0/src/threetears/scrape/forms.py +198 -0
  34. 3tears_scrape-0.18.0/src/threetears/scrape/llm_retry.py +162 -0
  35. 3tears_scrape-0.18.0/src/threetears/scrape/migrations.py +219 -0
  36. 3tears_scrape-0.18.0/src/threetears/scrape/page_finder.py +303 -0
  37. 3tears_scrape-0.18.0/src/threetears/scrape/py.typed +0 -0
  38. 3tears_scrape-0.18.0/src/threetears/scrape/request_shape_finder.py +177 -0
  39. 3tears_scrape-0.18.0/src/threetears/scrape/target_source.py +186 -0
  40. 3tears_scrape-0.18.0/src/threetears/scrape/tool.py +287 -0
  41. 3tears_scrape-0.18.0/tests/__init__.py +0 -0
  42. 3tears_scrape-0.18.0/tests/test_collections.py +331 -0
  43. 3tears_scrape-0.18.0/tests/test_driver_api.py +301 -0
  44. 3tears_scrape-0.18.0/tests/test_driver_camoufox.py +721 -0
  45. 3tears_scrape-0.18.0/tests/test_driver_contract.py +200 -0
  46. 3tears_scrape-0.18.0/tests/test_driver_document.py +582 -0
  47. 3tears_scrape-0.18.0/tests/test_driver_listing_detail.py +409 -0
  48. 3tears_scrape-0.18.0/tests/test_driver_multi_document.py +402 -0
  49. 3tears_scrape-0.18.0/tests/test_driver_network_capture.py +208 -0
  50. 3tears_scrape-0.18.0/tests/test_driver_nodriver_download.py +269 -0
  51. 3tears_scrape-0.18.0/tests/test_driver_nodriver_sidecar.py +392 -0
  52. 3tears_scrape-0.18.0/tests/test_enrichment.py +136 -0
  53. 3tears_scrape-0.18.0/tests/test_eval_loop.py +1730 -0
  54. 3tears_scrape-0.18.0/tests/test_extraction.py +1345 -0
  55. 3tears_scrape-0.18.0/tests/test_forms.py +107 -0
  56. 3tears_scrape-0.18.0/tests/test_migrations_drift.py +140 -0
  57. 3tears_scrape-0.18.0/tests/test_page_finder.py +300 -0
  58. 3tears_scrape-0.18.0/tests/test_request_shape_finder.py +202 -0
  59. 3tears_scrape-0.18.0/tests/test_target_source.py +213 -0
  60. 3tears_scrape-0.18.0/tests/test_tool.py +352 -0
@@ -0,0 +1,216 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[codz]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py.cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ #Pipfile.lock
96
+
97
+ # UV
98
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ #uv.lock
102
+
103
+ # poetry
104
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
105
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
106
+ # commonly ignored for libraries.
107
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
108
+ #poetry.lock
109
+ #poetry.toml
110
+
111
+ # pdm
112
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
113
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
114
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
115
+ #pdm.lock
116
+ #pdm.toml
117
+ .pdm-python
118
+ .pdm-build/
119
+
120
+ # pixi
121
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
122
+ #pixi.lock
123
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
124
+ # in the .venv directory. It is recommended not to include this directory in version control.
125
+ .pixi
126
+
127
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
128
+ __pypackages__/
129
+
130
+ # Celery stuff
131
+ celerybeat-schedule
132
+ celerybeat.pid
133
+
134
+ # SageMath parsed files
135
+ *.sage.py
136
+
137
+ # Environments
138
+ .env
139
+ .envrc
140
+ .venv
141
+ env/
142
+ venv/
143
+ ENV/
144
+ env.bak/
145
+ venv.bak/
146
+
147
+ # Spyder project settings
148
+ .spyderproject
149
+ .spyproject
150
+
151
+ # Rope project settings
152
+ .ropeproject
153
+
154
+ # mkdocs documentation
155
+ /site
156
+
157
+ # mypy
158
+ .mypy_cache/
159
+ .dmypy.json
160
+ dmypy.json
161
+
162
+ # Pyre type checker
163
+ .pyre/
164
+
165
+ # pytype static type analyzer
166
+ .pytype/
167
+
168
+ # Cython debug symbols
169
+ cython_debug/
170
+
171
+ # PyCharm
172
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
173
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
174
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
175
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
176
+ #.idea/
177
+
178
+ # Abstra
179
+ # Abstra is an AI-powered process automation framework.
180
+ # Ignore directories containing user credentials, local state, and settings.
181
+ # Learn more at https://abstra.io/docs
182
+ .abstra/
183
+
184
+ # Visual Studio Code
185
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
186
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
187
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
188
+ # you could uncomment the following to ignore the entire vscode folder
189
+ # .vscode/
190
+
191
+ # Ruff stuff:
192
+ .ruff_cache/
193
+
194
+ # PyPI configuration file
195
+ .pypirc
196
+
197
+ # Cursor
198
+ # Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
199
+ # exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
200
+ # refer to https://docs.cursor.com/context/ignore-files
201
+ .cursorignore
202
+ .cursorindexingignore
203
+
204
+ # Marimo
205
+ marimo/_static/
206
+ marimo/_lsp/
207
+ __marimo__/
208
+
209
+ # Claude Code local state
210
+ .claude/
211
+
212
+ # prawduct session evidence (local governance artifacts, never shipped)
213
+ .prawduct/
214
+
215
+ # macOS folder metadata
216
+ .DS_Store
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mark Pace
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,221 @@
1
+ Metadata-Version: 2.4
2
+ Name: 3tears-scrape
3
+ Version: 0.18.0
4
+ Summary: AI-driven, self-healing web scraping: pluggable render backends + LLM-proposed extraction candidates validated and judged against real page content, never hand-rolled per-site parsers
5
+ Project-URL: Repository, https://github.com/pacepace/3tears
6
+ Author: pace
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Framework :: AsyncIO
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.14
14
+ Classifier: Topic :: Software Development :: Libraries
15
+ Classifier: Typing :: Typed
16
+ Requires-Python: >=3.14
17
+ Requires-Dist: 3tears
18
+ Requires-Dist: 3tears-agent-tools[document]
19
+ Requires-Dist: 3tears-models
20
+ Requires-Dist: 3tears-observe
21
+ Requires-Dist: beautifulsoup4>=4.12
22
+ Requires-Dist: camoufox>=0.4
23
+ Requires-Dist: httpx[socks]>=0.27
24
+ Requires-Dist: langchain-core>=0.3
25
+ Requires-Dist: playwright<1.61
26
+ Requires-Dist: pydantic>=2.0
27
+ Requires-Dist: pyyaml>=6.0
28
+ Description-Content-Type: text/markdown
29
+
30
+ # 3tears-scrape
31
+
32
+ AI-driven, self-healing web scraping. Onboarding a new site is a config addition (a `ScrapeTarget` row), never a hand-written parser: an LLM proposes extraction candidates, each is structurally validated against the real page, an LLM judge picks the winner by comparing candidate values against real page content, and the winning strategy is persisted as a recipe reused on every later fetch until it stops working.
33
+
34
+ ```bash
35
+ pip install 3tears-scrape
36
+ ```
37
+
38
+ ## What you get
39
+
40
+ - **`ScrapeDriver`** -- a pluggable, backend-agnostic render contract (`RenderedPage`, `NavStep`, `NetworkCall`). Five implementations ship: `NodriverSidecarDriver` (real headless Chromium via an isolated HTTP sidecar -- see `sidecar/`), `CamoufoxDriver` (in-process stealth Firefox, no sidecar needed), `DocumentDriver` (PDF/DOCX/XLSX/CSV/TXT/MD/LaTeX via `3tears-agent-tools`' `parse_document`), `ApiDriver` (stateless JSON API GET), and `NetworkCaptureDriver` (authenticated in-session XHR capture for pages whose real data never reaches a statelessly-fetchable API).
41
+ - **Two extraction-strategy shapes** -- CSS-selector candidates for real (or synthesized) HTML tables, and regex candidates for text-block/prose pages with no table structure at all. Both run through the identical propose -> structurally-validate -> judge -> persist cycle; the judge only ever sees extracted values and real page content, never which mechanism produced them.
42
+ - **`run_eval_loop` / `run_eval_loop_multi_row`** -- the self-healing cycle itself: reuse a target's existing `ScrapeRecipe` with zero LLM calls while it keeps validating; only regenerate candidates once `consecutive_validation_failures` crosses a threshold.
43
+ - **`ScrapeTarget` / `ScrapeRecipe` / `ScrapeExtraction`** -- the domain-agnostic persisted entities (three-tier L1/L2/L3-backed collections). The core never learns what a field *means* -- only that whatever a candidate extracts for a caller-supplied field name parses as its declared type.
44
+ - **Pluggable target config** -- `TargetSource` (`StaticTargetSource` / `YamlTargetSource` / `CollectionTargetSource`) and `bootstrap_targets()` for seeding a database-backed target collection from a git-tracked YAML file, never overwriting a row already present.
45
+ - **`ScrapeTool`** -- an ad-hoc, single-call MCP-exposed entry point: give it a URL and a field schema, get back real extracted data through the same eval loop, no target pre-configuration required.
46
+ - **`enrichment.py`** -- a secondary, separate LLM pass capturing free-form context a structured schema has no field for, kept distinct from validated structured data.
47
+ - **`find_target_page()`** -- a bounded-turn research agent: give it a plain-language query ("Ohio WARN Act notices"), it searches and fetches candidate pages (WebSearch/WebFetch, capped turns), deterministically verifies the winner has real extractable structure (a table, a document link, a JSON API response -- never a browser fetch, so it never needs the sidecar running), and returns a `PageFinderResult` (`url`, a `driver_backend` guess, `verified`). Independently callable -- it never persists a `ScrapeTarget` or forces extraction to follow; a caller with a known URL never needs this at all.
48
+ - **`discover_candidates()` / `discover_row_candidates()`** -- "capture every variable on this page": the inverse of `generate_candidates`/`generate_row_candidates`, no caller-supplied `field_schema` required. An LLM proposes field names/types/selectors it finds on the page, each validated by the exact same `validate_candidate`/`validate_row_candidate` every schema-driven candidate already goes through -- a discovered field that doesn't structurally validate is dropped, never included. Returns a `DiscoverySchemaResult` whose `field_schema`/`strategy` are already shaped exactly as `ScrapeTarget.field_schema`/`ScrapeRecipe.extraction_strategy` expect. Never persists anything -- a caller decides what to do with the result.
49
+
50
+ ## Quickstart
51
+
52
+ ```python
53
+ from threetears.scrape.drivers.nodriver_sidecar import NodriverSidecarDriver
54
+ from threetears.scrape.eval_loop import run_eval_loop_multi_row
55
+ from threetears.scrape.collections import ScrapeRecipeCollection, ScrapeExtractionCollection
56
+
57
+ driver = NodriverSidecarDriver("http://localhost:8088")
58
+ page = await driver.render("https://example.gov/warn-notices")
59
+
60
+ extraction = await run_eval_loop_multi_row(
61
+ "example_warn_notices", page.html, page.final_url,
62
+ schema={"employer": str, "county": str, "affected_count": int},
63
+ recipe_collection=ScrapeRecipeCollection(registry, config),
64
+ extraction_collection=ScrapeExtractionCollection(registry, config),
65
+ api_key=openrouter_api_key,
66
+ )
67
+ print(extraction.validation_status, extraction.structured_fields["records"])
68
+ ```
69
+
70
+ ## The nodriver sidecar
71
+
72
+ `NodriverSidecarDriver` talks HTTP to a genuinely separate process/container -- this is the AGPL isolation boundary nodriver (AGPL-3.0) requires, since this package itself is MIT-licensed. Build and run it via `docker buildx bake nodriver-sidecar` from the repo root; see `sidecar/README.md` for details. If you don't want the sidecar dependency at all, `CamoufoxDriver` is a fully in-process, MPL-2.0-safe alternative.
73
+
74
+ ## Architecture
75
+
76
+ > Lifted from faidh's `src/faidh/scrape/` into this package (`scrape-task-01`, 2026-07-15) as a directory move -- zero logic changes. faidh's WARN Act plugin is used throughout as the running example of a consumer; it remains a faidh-side module (`faidh/src/faidh/intake/plugins/warn_act.py`), not part of this package, and is the only place WARN-domain meaning exists.
77
+
78
+ ### Why this exists
79
+
80
+ WARN Act notices are published by ~24 US state labor departments in wildly inconsistent formats -- real HTML tables, PDF/DOCX/XLSX filings, prose text blocks, JSON APIs, and at least one state gated behind an authenticated search form with no stable URLs. The naive answer is "write a scraper per state." That's 24+ bespoke, brittle, un-composable parsers that all rot independently.
81
+
82
+ The actual answer: **two orthogonal, config-selected axes** -- how to *fetch* a page and how to *extract* from it -- cover every state as a combination of the two, with zero jurisdiction-specific code in the core.
83
+
84
+ ### 1. Data flow: source → Postgres (faidh's WARN Act consumer, as an example)
85
+
86
+ ```mermaid
87
+ flowchart TD
88
+ A[ScrapeTarget config<br/>YAML seed row] --> B["ScrapeDriver.render(url, ...)"]
89
+ B --> C[RenderedPage<br/>html, status, final_url, timing_ms, network_calls]
90
+ C --> D{run_eval_loop /<br/>run_eval_loop_multi_row}
91
+ D -->|reuse existing recipe| E[ScrapeRecipe]
92
+ D -->|regenerate via LLM candidates + judge| E
93
+ E --> F[ScrapeExtraction persisted<br/>structured_fields = records]
94
+ F --> G["WarnActPlugin._produce()<br/>generic records → Tier-2 fields"]
95
+ G --> H[ArbitrarySignalEntity.create<br/>signal_type=warn_act_notice]
96
+ H --> I["PluginBase.collect()<br/>throttle-gate → _produce() → save_entity() → yield"]
97
+ I --> J[(Postgres:<br/>faidh_arbitrary_signals)]
98
+ I --> K["publish_arbitrary_signals()"]
99
+ K --> L[NATS subject: arbitrary_signals]
100
+ L --> M[Downstream: scoreboard, retrospect, ...]
101
+
102
+ F -.-> N[(Postgres:<br/>scrape_targets / scrape_recipes /<br/>scrape_extractions — provenance)]
103
+ ```
104
+
105
+ Two Postgres-writing paths run **in parallel, not sequentially**:
106
+
107
+ - **Provenance** (`scrape_targets`, `scrape_recipes`, `scrape_extractions`) -- what was fetched, what strategy won, when. Domain-agnostic, lives in this package.
108
+ - **Signal** (`faidh_arbitrary_signals`) -- what faidh's forecasting actually reads. Faidh-specific, mapped by `WarnActPlugin._produce()`, stays in faidh.
109
+
110
+ ### 2. The abstraction: `ScrapeTarget` is the entire adapter surface
111
+
112
+ Zero jurisdiction-specific Python exists anywhere in this package. In faidh's WARN Act consumer, every state is a `ScrapeTarget` row (`faidh/src/faidh/intake/plugins/seeds/warn_act_targets.yaml`), bootstrapped into the DB.
113
+
114
+ | Field | Controls |
115
+ |---|---|
116
+ | `url` | what to fetch |
117
+ | `driver_backend` | which of 5 `ScrapeDriver` implementations renders it |
118
+ | `wait_for` | CSS selector to settle on (async-loading pages) |
119
+ | `nav_steps` | ordered click/fill/wait_for/wait_ms/scroll_into_view/scroll_page/evaluate actions (search-form-gated, lazy-render, or in-page-JS-state targets) |
120
+ | `timeout_seconds` | per-target render budget |
121
+ | `field_schema` | field name → Python type, e.g. `{"employer": str, "county": str}` |
122
+ | `extraction_strategy_type` | `"css"` or `"regex"` |
123
+ | `api_results_path` / `api_fragment_field` | JSON traversal (API driver only) |
124
+ | `multi_row` | one record vs. many per page |
125
+
126
+ The core (`driver.py`, `extraction.py`, `eval_loop.py`, `collections.py`) never branches on target identity -- only on `ScrapeTarget` fields and a caller-supplied `field_schema`. It doesn't know what `employer` *means*; it only enforces that whatever a candidate extracts for that field name parses as the declared type. Stated in `extraction.py`'s own docstring: *"this module never hardcodes what a field means."*
127
+
128
+ ### 3. Fetch × Extract = many combinations, not one scraper per site
129
+
130
+ ```mermaid
131
+ flowchart LR
132
+ subgraph Fetch["Axis 1 — driver_backend (drivers/)"]
133
+ D1[nodriver_sidecar<br/>headless Chromium, sidecar]
134
+ D2[camoufox<br/>in-process stealth Firefox]
135
+ D3[document<br/>PDF/DOCX/XLSX/CSV → parse_document]
136
+ D4[api<br/>stateless JSON GET]
137
+ D5[network_capture<br/>authenticated XHR capture]
138
+ end
139
+ subgraph Converge["Everything converges here"]
140
+ R[RenderedPage.html<br/>real or synthetic — identical downstream]
141
+ end
142
+ subgraph Extract["Axis 2 — extraction_strategy_type"]
143
+ S1[css<br/>real/synthesized table → LLM proposes selectors]
144
+ S2[regex<br/>prose text block → LLM proposes named-group patterns]
145
+ end
146
+ D1 & D2 & D3 & D4 & D5 --> R
147
+ R --> S1
148
+ R --> S2
149
+ S1 & S2 --> J[Identical propose → validate →<br/>judge → persist cycle, eval_loop.py]
150
+ ```
151
+
152
+ In faidh's WARN Act consumer, every one of the ~24 onboarded states is one config row picking a combination from this matrix. A new driver/strategy is only written when a target needs a *fetch mechanism* or *extraction shape* that doesn't exist yet -- five times across the WARN Act build: regex strategy, `ApiDriver`, native CSV support, `NetworkCaptureDriver`, plus the original CSS/nodriver baseline.
153
+
154
+ ### 4. "The signal must carry the value; the model must never infer it" -- where the rule actually lives
155
+
156
+ | Checkpoint | Mechanism | Proof (faidh's WARN Act consumer) |
157
+ |---|---|---|
158
+ | Structural validation, all-or-nothing per record | `validate_candidate` / `validate_row_candidate` (+ regex variants), `extraction.py` -- no coercion, no defaults, a record missing even one requested field is **dropped entirely** | Oklahoma: 217 real records exist, only 128 survived -- the other 89 genuinely lack a workforce-region value on the page |
159
+ | Schema omits what a page can't provide | `field_schema` per target -- never forces a value that isn't there | New York schema doesn't request `affected_count`/`effective_date` because its page has neither |
160
+ | Judge does semantic verification, not just structural plausibility | `_judge_candidates` / `_judge_row_candidates`, `eval_loop.py` -- sees real page HTML + candidate values, told structural validity is already checked, job is *semantic correctness* | Georgia: an earlier schema mistakenly asked for `county`; judge rejected the resulting hallucinated-mapping candidate rather than accepting it |
161
+ | Confirmed vs. best-guess survives to the signal | `ScrapeExtraction.validation_status` ∈ `validated` / `needs_review` / `failed` | `WarnActPlugin._produce()` logs a distinct warning for `needs_review` yields rather than treating them as confirmed |
162
+
163
+ **Open tension, not fully resolved:** a `needs_review` record still gets yielded as a real signal -- "surface for review, never silently drop" beats "silently withhold," by deliberate design. But nothing downstream currently filters on `validation_status`. The signal *carries* the distinction; no consumer currently *reads* it. A consumer treating every signal as equally trustworthy today would be wrong to.
164
+
165
+ ### 5. Where new capabilities plug in
166
+
167
+ ```mermaid
168
+ flowchart TD
169
+ Core["This package<br/>driver.py / extraction.py / eval_loop.py / collections.py"]
170
+ T01["scrape-task-01 (done, 2026-07-15)<br/>Lift: moved core here from faidh<br/>package-boundary move only, zero logic change"]
171
+ T02["scrape-task-02 (planned)<br/>Page-finder agent<br/>outputs ScrapeTarget-shaped guess<br/>(URL + driver_backend/wait_for)"]
172
+ T03["scrape-task-03 (planned)<br/>Schema-discovery mode<br/>flag on generate_candidates —<br/>schema OUT instead of schema IN"]
173
+ T01 --> Core
174
+ Core -.plain data in/out, no hidden state.-> T02
175
+ Core -.plain data in/out, no hidden state.-> T03
176
+ T02 --> BT["bootstrap_targets()<br/>target_source.py"]
177
+ BT --> Core
178
+ ```
179
+
180
+ `task-02`/`task-03` are new front-end stages that will produce/consume the same plain data shapes (`RenderedPage`, `FieldSchema`, `ScrapeExtraction`) the core already uses -- neither requires touching `driver.py`, `eval_loop.py`, or `collections.py`.
181
+
182
+ ### 6. Module map
183
+
184
+ ```
185
+ packages/scrape/src/threetears/scrape/
186
+ ├── driver.py ScrapeDriver ABC, RenderedPage, NavStep, NetworkCall
187
+ ├── drivers/
188
+ │ ├── nodriver_sidecar.py HTTP → headless Chromium sidecar (AGPL boundary)
189
+ │ ├── camoufox.py in-process stealth Firefox (MPL-2.0-safe)
190
+ │ ├── document.py parse_document() + document_text_to_html()
191
+ │ ├── api.py stateless JSON GET, _resolve_path()
192
+ │ └── network_capture.py wraps another driver, _find_largest_record_list()
193
+ ├── extraction.py generate_candidates/generate_row_candidates (+ regex),
194
+ │ validate_candidate/validate_row_candidate (+ regex),
195
+ │ html_to_text(), strip_boilerplate()
196
+ ├── eval_loop.py run_eval_loop()/run_eval_loop_multi_row(),
197
+ │ _judge_candidates()/_judge_row_candidates(),
198
+ │ _persist_extraction(), _save_recipe()
199
+ ├── collections.py ScrapeTarget/ScrapeRecipe/ScrapeExtraction (BaseEntity)
200
+ │ + BaseCollection subclasses (L1/L2/L3 via
201
+ │ threetears.core.backends.protocol.DurableStore)
202
+ ├── migrations.py v001-v008, PACKAGE_NAME="3tears_scrape"
203
+ ├── target_source.py TargetSource ABC, YamlTargetSource, CollectionTargetSource,
204
+ │ StaticTargetSource, bootstrap_targets()
205
+ ├── tool.py ScrapeTool — ad-hoc single-call MCP entry point
206
+ ├── enrichment.py secondary free-form LLM notes pass (separate from structured_fields)
207
+ └── llm_retry.py bounded_retry_structured_call() — shared by extraction.py + eval_loop.py
208
+
209
+ Example consumer (faidh repo, not part of this package):
210
+ faidh/src/faidh/intake/plugins/warn_act.py WarnActPlugin — the ONLY place WARN-domain meaning exists
211
+ faidh/src/faidh/intake/plugins/seeds/warn_act_targets.yaml the ScrapeTarget config rows
212
+ faidh/src/faidh/intake/runner.py publish_observations(), publish_arbitrary_signals(), poll_scrape_targets()
213
+ faidh/src/faidh/intake/plugins/__init__.py PluginBase.collect() — persist-then-yield template method
214
+ faidh/src/faidh/intake/signals/arbitrary.py ArbitrarySignalEntity/Collection — the actual Postgres sink
215
+ ```
216
+
217
+ **Call chain, one live poll cycle (faidh's WARN Act consumer):** `runner.publish_observations()` → `WarnActPlugin.collect()` (inherited `PluginBase`) → `WarnActPlugin._produce()` → resolves `self._drivers[target.driver_backend]` → `driver.render(...)` → `run_eval_loop_multi_row(...)` (or `run_eval_loop` if `multi_row=False`) → `_reuse_row_recipe`/`_regenerate_row_recipe` (or `_regex_` variants) → `_persist_extraction()` writes `scrape_extractions` → back in `_produce()`, each record becomes an `ArbitrarySignalEntity` → `collect()`'s `save_entity()` writes `faidh_arbitrary_signals` → `publish_arbitrary_signals()` re-drives `collect()` and publishes each yielded entity to `arbitrary_signals()` on NATS.
218
+
219
+ ## License
220
+
221
+ MIT. See [LICENSE](LICENSE). The bundled sidecar (`sidecar/`) wraps nodriver (AGPL-3.0) as a genuinely separate process -- see `sidecar/README.md`.
@@ -0,0 +1,192 @@
1
+ # 3tears-scrape
2
+
3
+ AI-driven, self-healing web scraping. Onboarding a new site is a config addition (a `ScrapeTarget` row), never a hand-written parser: an LLM proposes extraction candidates, each is structurally validated against the real page, an LLM judge picks the winner by comparing candidate values against real page content, and the winning strategy is persisted as a recipe reused on every later fetch until it stops working.
4
+
5
+ ```bash
6
+ pip install 3tears-scrape
7
+ ```
8
+
9
+ ## What you get
10
+
11
+ - **`ScrapeDriver`** -- a pluggable, backend-agnostic render contract (`RenderedPage`, `NavStep`, `NetworkCall`). Five implementations ship: `NodriverSidecarDriver` (real headless Chromium via an isolated HTTP sidecar -- see `sidecar/`), `CamoufoxDriver` (in-process stealth Firefox, no sidecar needed), `DocumentDriver` (PDF/DOCX/XLSX/CSV/TXT/MD/LaTeX via `3tears-agent-tools`' `parse_document`), `ApiDriver` (stateless JSON API GET), and `NetworkCaptureDriver` (authenticated in-session XHR capture for pages whose real data never reaches a statelessly-fetchable API).
12
+ - **Two extraction-strategy shapes** -- CSS-selector candidates for real (or synthesized) HTML tables, and regex candidates for text-block/prose pages with no table structure at all. Both run through the identical propose -> structurally-validate -> judge -> persist cycle; the judge only ever sees extracted values and real page content, never which mechanism produced them.
13
+ - **`run_eval_loop` / `run_eval_loop_multi_row`** -- the self-healing cycle itself: reuse a target's existing `ScrapeRecipe` with zero LLM calls while it keeps validating; only regenerate candidates once `consecutive_validation_failures` crosses a threshold.
14
+ - **`ScrapeTarget` / `ScrapeRecipe` / `ScrapeExtraction`** -- the domain-agnostic persisted entities (three-tier L1/L2/L3-backed collections). The core never learns what a field *means* -- only that whatever a candidate extracts for a caller-supplied field name parses as its declared type.
15
+ - **Pluggable target config** -- `TargetSource` (`StaticTargetSource` / `YamlTargetSource` / `CollectionTargetSource`) and `bootstrap_targets()` for seeding a database-backed target collection from a git-tracked YAML file, never overwriting a row already present.
16
+ - **`ScrapeTool`** -- an ad-hoc, single-call MCP-exposed entry point: give it a URL and a field schema, get back real extracted data through the same eval loop, no target pre-configuration required.
17
+ - **`enrichment.py`** -- a secondary, separate LLM pass capturing free-form context a structured schema has no field for, kept distinct from validated structured data.
18
+ - **`find_target_page()`** -- a bounded-turn research agent: give it a plain-language query ("Ohio WARN Act notices"), it searches and fetches candidate pages (WebSearch/WebFetch, capped turns), deterministically verifies the winner has real extractable structure (a table, a document link, a JSON API response -- never a browser fetch, so it never needs the sidecar running), and returns a `PageFinderResult` (`url`, a `driver_backend` guess, `verified`). Independently callable -- it never persists a `ScrapeTarget` or forces extraction to follow; a caller with a known URL never needs this at all.
19
+ - **`discover_candidates()` / `discover_row_candidates()`** -- "capture every variable on this page": the inverse of `generate_candidates`/`generate_row_candidates`, no caller-supplied `field_schema` required. An LLM proposes field names/types/selectors it finds on the page, each validated by the exact same `validate_candidate`/`validate_row_candidate` every schema-driven candidate already goes through -- a discovered field that doesn't structurally validate is dropped, never included. Returns a `DiscoverySchemaResult` whose `field_schema`/`strategy` are already shaped exactly as `ScrapeTarget.field_schema`/`ScrapeRecipe.extraction_strategy` expect. Never persists anything -- a caller decides what to do with the result.
20
+
21
+ ## Quickstart
22
+
23
+ ```python
24
+ from threetears.scrape.drivers.nodriver_sidecar import NodriverSidecarDriver
25
+ from threetears.scrape.eval_loop import run_eval_loop_multi_row
26
+ from threetears.scrape.collections import ScrapeRecipeCollection, ScrapeExtractionCollection
27
+
28
+ driver = NodriverSidecarDriver("http://localhost:8088")
29
+ page = await driver.render("https://example.gov/warn-notices")
30
+
31
+ extraction = await run_eval_loop_multi_row(
32
+ "example_warn_notices", page.html, page.final_url,
33
+ schema={"employer": str, "county": str, "affected_count": int},
34
+ recipe_collection=ScrapeRecipeCollection(registry, config),
35
+ extraction_collection=ScrapeExtractionCollection(registry, config),
36
+ api_key=openrouter_api_key,
37
+ )
38
+ print(extraction.validation_status, extraction.structured_fields["records"])
39
+ ```
40
+
41
+ ## The nodriver sidecar
42
+
43
+ `NodriverSidecarDriver` talks HTTP to a genuinely separate process/container -- this is the AGPL isolation boundary nodriver (AGPL-3.0) requires, since this package itself is MIT-licensed. Build and run it via `docker buildx bake nodriver-sidecar` from the repo root; see `sidecar/README.md` for details. If you don't want the sidecar dependency at all, `CamoufoxDriver` is a fully in-process, MPL-2.0-safe alternative.
44
+
45
+ ## Architecture
46
+
47
+ > Lifted from faidh's `src/faidh/scrape/` into this package (`scrape-task-01`, 2026-07-15) as a directory move -- zero logic changes. faidh's WARN Act plugin is used throughout as the running example of a consumer; it remains a faidh-side module (`faidh/src/faidh/intake/plugins/warn_act.py`), not part of this package, and is the only place WARN-domain meaning exists.
48
+
49
+ ### Why this exists
50
+
51
+ WARN Act notices are published by ~24 US state labor departments in wildly inconsistent formats -- real HTML tables, PDF/DOCX/XLSX filings, prose text blocks, JSON APIs, and at least one state gated behind an authenticated search form with no stable URLs. The naive answer is "write a scraper per state." That's 24+ bespoke, brittle, un-composable parsers that all rot independently.
52
+
53
+ The actual answer: **two orthogonal, config-selected axes** -- how to *fetch* a page and how to *extract* from it -- cover every state as a combination of the two, with zero jurisdiction-specific code in the core.
54
+
55
+ ### 1. Data flow: source → Postgres (faidh's WARN Act consumer, as an example)
56
+
57
+ ```mermaid
58
+ flowchart TD
59
+ A[ScrapeTarget config<br/>YAML seed row] --> B["ScrapeDriver.render(url, ...)"]
60
+ B --> C[RenderedPage<br/>html, status, final_url, timing_ms, network_calls]
61
+ C --> D{run_eval_loop /<br/>run_eval_loop_multi_row}
62
+ D -->|reuse existing recipe| E[ScrapeRecipe]
63
+ D -->|regenerate via LLM candidates + judge| E
64
+ E --> F[ScrapeExtraction persisted<br/>structured_fields = records]
65
+ F --> G["WarnActPlugin._produce()<br/>generic records → Tier-2 fields"]
66
+ G --> H[ArbitrarySignalEntity.create<br/>signal_type=warn_act_notice]
67
+ H --> I["PluginBase.collect()<br/>throttle-gate → _produce() → save_entity() → yield"]
68
+ I --> J[(Postgres:<br/>faidh_arbitrary_signals)]
69
+ I --> K["publish_arbitrary_signals()"]
70
+ K --> L[NATS subject: arbitrary_signals]
71
+ L --> M[Downstream: scoreboard, retrospect, ...]
72
+
73
+ F -.-> N[(Postgres:<br/>scrape_targets / scrape_recipes /<br/>scrape_extractions — provenance)]
74
+ ```
75
+
76
+ Two Postgres-writing paths run **in parallel, not sequentially**:
77
+
78
+ - **Provenance** (`scrape_targets`, `scrape_recipes`, `scrape_extractions`) -- what was fetched, what strategy won, when. Domain-agnostic, lives in this package.
79
+ - **Signal** (`faidh_arbitrary_signals`) -- what faidh's forecasting actually reads. Faidh-specific, mapped by `WarnActPlugin._produce()`, stays in faidh.
80
+
81
+ ### 2. The abstraction: `ScrapeTarget` is the entire adapter surface
82
+
83
+ Zero jurisdiction-specific Python exists anywhere in this package. In faidh's WARN Act consumer, every state is a `ScrapeTarget` row (`faidh/src/faidh/intake/plugins/seeds/warn_act_targets.yaml`), bootstrapped into the DB.
84
+
85
+ | Field | Controls |
86
+ |---|---|
87
+ | `url` | what to fetch |
88
+ | `driver_backend` | which of 5 `ScrapeDriver` implementations renders it |
89
+ | `wait_for` | CSS selector to settle on (async-loading pages) |
90
+ | `nav_steps` | ordered click/fill/wait_for/wait_ms/scroll_into_view/scroll_page/evaluate actions (search-form-gated, lazy-render, or in-page-JS-state targets) |
91
+ | `timeout_seconds` | per-target render budget |
92
+ | `field_schema` | field name → Python type, e.g. `{"employer": str, "county": str}` |
93
+ | `extraction_strategy_type` | `"css"` or `"regex"` |
94
+ | `api_results_path` / `api_fragment_field` | JSON traversal (API driver only) |
95
+ | `multi_row` | one record vs. many per page |
96
+
97
+ The core (`driver.py`, `extraction.py`, `eval_loop.py`, `collections.py`) never branches on target identity -- only on `ScrapeTarget` fields and a caller-supplied `field_schema`. It doesn't know what `employer` *means*; it only enforces that whatever a candidate extracts for that field name parses as the declared type. Stated in `extraction.py`'s own docstring: *"this module never hardcodes what a field means."*
98
+
99
+ ### 3. Fetch × Extract = many combinations, not one scraper per site
100
+
101
+ ```mermaid
102
+ flowchart LR
103
+ subgraph Fetch["Axis 1 — driver_backend (drivers/)"]
104
+ D1[nodriver_sidecar<br/>headless Chromium, sidecar]
105
+ D2[camoufox<br/>in-process stealth Firefox]
106
+ D3[document<br/>PDF/DOCX/XLSX/CSV → parse_document]
107
+ D4[api<br/>stateless JSON GET]
108
+ D5[network_capture<br/>authenticated XHR capture]
109
+ end
110
+ subgraph Converge["Everything converges here"]
111
+ R[RenderedPage.html<br/>real or synthetic — identical downstream]
112
+ end
113
+ subgraph Extract["Axis 2 — extraction_strategy_type"]
114
+ S1[css<br/>real/synthesized table → LLM proposes selectors]
115
+ S2[regex<br/>prose text block → LLM proposes named-group patterns]
116
+ end
117
+ D1 & D2 & D3 & D4 & D5 --> R
118
+ R --> S1
119
+ R --> S2
120
+ S1 & S2 --> J[Identical propose → validate →<br/>judge → persist cycle, eval_loop.py]
121
+ ```
122
+
123
+ In faidh's WARN Act consumer, every one of the ~24 onboarded states is one config row picking a combination from this matrix. A new driver/strategy is only written when a target needs a *fetch mechanism* or *extraction shape* that doesn't exist yet -- five times across the WARN Act build: regex strategy, `ApiDriver`, native CSV support, `NetworkCaptureDriver`, plus the original CSS/nodriver baseline.
124
+
125
+ ### 4. "The signal must carry the value; the model must never infer it" -- where the rule actually lives
126
+
127
+ | Checkpoint | Mechanism | Proof (faidh's WARN Act consumer) |
128
+ |---|---|---|
129
+ | Structural validation, all-or-nothing per record | `validate_candidate` / `validate_row_candidate` (+ regex variants), `extraction.py` -- no coercion, no defaults, a record missing even one requested field is **dropped entirely** | Oklahoma: 217 real records exist, only 128 survived -- the other 89 genuinely lack a workforce-region value on the page |
130
+ | Schema omits what a page can't provide | `field_schema` per target -- never forces a value that isn't there | New York schema doesn't request `affected_count`/`effective_date` because its page has neither |
131
+ | Judge does semantic verification, not just structural plausibility | `_judge_candidates` / `_judge_row_candidates`, `eval_loop.py` -- sees real page HTML + candidate values, told structural validity is already checked, job is *semantic correctness* | Georgia: an earlier schema mistakenly asked for `county`; judge rejected the resulting hallucinated-mapping candidate rather than accepting it |
132
+ | Confirmed vs. best-guess survives to the signal | `ScrapeExtraction.validation_status` ∈ `validated` / `needs_review` / `failed` | `WarnActPlugin._produce()` logs a distinct warning for `needs_review` yields rather than treating them as confirmed |
133
+
134
+ **Open tension, not fully resolved:** a `needs_review` record still gets yielded as a real signal -- "surface for review, never silently drop" beats "silently withhold," by deliberate design. But nothing downstream currently filters on `validation_status`. The signal *carries* the distinction; no consumer currently *reads* it. A consumer treating every signal as equally trustworthy today would be wrong to.
135
+
136
+ ### 5. Where new capabilities plug in
137
+
138
+ ```mermaid
139
+ flowchart TD
140
+ Core["This package<br/>driver.py / extraction.py / eval_loop.py / collections.py"]
141
+ T01["scrape-task-01 (done, 2026-07-15)<br/>Lift: moved core here from faidh<br/>package-boundary move only, zero logic change"]
142
+ T02["scrape-task-02 (planned)<br/>Page-finder agent<br/>outputs ScrapeTarget-shaped guess<br/>(URL + driver_backend/wait_for)"]
143
+ T03["scrape-task-03 (planned)<br/>Schema-discovery mode<br/>flag on generate_candidates —<br/>schema OUT instead of schema IN"]
144
+ T01 --> Core
145
+ Core -.plain data in/out, no hidden state.-> T02
146
+ Core -.plain data in/out, no hidden state.-> T03
147
+ T02 --> BT["bootstrap_targets()<br/>target_source.py"]
148
+ BT --> Core
149
+ ```
150
+
151
+ `task-02`/`task-03` are new front-end stages that will produce/consume the same plain data shapes (`RenderedPage`, `FieldSchema`, `ScrapeExtraction`) the core already uses -- neither requires touching `driver.py`, `eval_loop.py`, or `collections.py`.
152
+
153
+ ### 6. Module map
154
+
155
+ ```
156
+ packages/scrape/src/threetears/scrape/
157
+ ├── driver.py ScrapeDriver ABC, RenderedPage, NavStep, NetworkCall
158
+ ├── drivers/
159
+ │ ├── nodriver_sidecar.py HTTP → headless Chromium sidecar (AGPL boundary)
160
+ │ ├── camoufox.py in-process stealth Firefox (MPL-2.0-safe)
161
+ │ ├── document.py parse_document() + document_text_to_html()
162
+ │ ├── api.py stateless JSON GET, _resolve_path()
163
+ │ └── network_capture.py wraps another driver, _find_largest_record_list()
164
+ ├── extraction.py generate_candidates/generate_row_candidates (+ regex),
165
+ │ validate_candidate/validate_row_candidate (+ regex),
166
+ │ html_to_text(), strip_boilerplate()
167
+ ├── eval_loop.py run_eval_loop()/run_eval_loop_multi_row(),
168
+ │ _judge_candidates()/_judge_row_candidates(),
169
+ │ _persist_extraction(), _save_recipe()
170
+ ├── collections.py ScrapeTarget/ScrapeRecipe/ScrapeExtraction (BaseEntity)
171
+ │ + BaseCollection subclasses (L1/L2/L3 via
172
+ │ threetears.core.backends.protocol.DurableStore)
173
+ ├── migrations.py v001-v008, PACKAGE_NAME="3tears_scrape"
174
+ ├── target_source.py TargetSource ABC, YamlTargetSource, CollectionTargetSource,
175
+ │ StaticTargetSource, bootstrap_targets()
176
+ ├── tool.py ScrapeTool — ad-hoc single-call MCP entry point
177
+ ├── enrichment.py secondary free-form LLM notes pass (separate from structured_fields)
178
+ └── llm_retry.py bounded_retry_structured_call() — shared by extraction.py + eval_loop.py
179
+
180
+ Example consumer (faidh repo, not part of this package):
181
+ faidh/src/faidh/intake/plugins/warn_act.py WarnActPlugin — the ONLY place WARN-domain meaning exists
182
+ faidh/src/faidh/intake/plugins/seeds/warn_act_targets.yaml the ScrapeTarget config rows
183
+ faidh/src/faidh/intake/runner.py publish_observations(), publish_arbitrary_signals(), poll_scrape_targets()
184
+ faidh/src/faidh/intake/plugins/__init__.py PluginBase.collect() — persist-then-yield template method
185
+ faidh/src/faidh/intake/signals/arbitrary.py ArbitrarySignalEntity/Collection — the actual Postgres sink
186
+ ```
187
+
188
+ **Call chain, one live poll cycle (faidh's WARN Act consumer):** `runner.publish_observations()` → `WarnActPlugin.collect()` (inherited `PluginBase`) → `WarnActPlugin._produce()` → resolves `self._drivers[target.driver_backend]` → `driver.render(...)` → `run_eval_loop_multi_row(...)` (or `run_eval_loop` if `multi_row=False`) → `_reuse_row_recipe`/`_regenerate_row_recipe` (or `_regex_` variants) → `_persist_extraction()` writes `scrape_extractions` → back in `_produce()`, each record becomes an `ArbitrarySignalEntity` → `collect()`'s `save_entity()` writes `faidh_arbitrary_signals` → `publish_arbitrary_signals()` re-drives `collect()` and publishes each yielded entity to `arbitrary_signals()` on NATS.
189
+
190
+ ## License
191
+
192
+ MIT. See [LICENSE](LICENSE). The bundled sidecar (`sidecar/`) wraps nodriver (AGPL-3.0) as a genuinely separate process -- see `sidecar/README.md`.