From 5657668dbcf597428d92939ebb8167c5c009ff99 Mon Sep 17 00:00:00 2001 From: Spyros Date: Tue, 6 Oct 2026 20:38:55 +0100 Subject: [PATCH 1/6] Parse OWASP event pages and harvest event repos Event repos (www-event-*) are Jekyll sites: front matter, a schema.org JSON-LD block and Liquid templating around the real prose. event_page turns that into a short title/dates/location/description header plus the markdown body, used both at harvest time and when a filed event node is embedded. Events are now harvestable; pages with no content are skipped without blocking the checkpoint. --- application/prompt_client/prompt_client.py | 3 +- .../tests/harvester_test/event_page_test.py | 145 ++++++++++++++ .../fixtures/event_index.md.txt | 45 +++++ .../harvester_test/harvest_event_test.py | 134 +++++++++++++ .../tests/harvester_test/selection_test.py | 6 +- application/utils/harvester/event_page.py | 185 ++++++++++++++++++ application/utils/harvester/pipeline.py | 5 + application/utils/harvester/schemas.py | 10 +- 8 files changed, 526 insertions(+), 7 deletions(-) create mode 100644 application/tests/harvester_test/event_page_test.py create mode 100644 application/tests/harvester_test/fixtures/event_index.md.txt create mode 100644 application/tests/harvester_test/harvest_event_test.py create mode 100644 application/utils/harvester/event_page.py diff --git a/application/prompt_client/prompt_client.py b/application/prompt_client/prompt_client.py index 8aee6d607..1f4852ba8 100644 --- a/application/prompt_client/prompt_client.py +++ b/application/prompt_client/prompt_client.py @@ -10,6 +10,7 @@ from urllib.parse import urljoin, urlparse from application.prompt_client import embed_alignment, litellm_router +from application.utils.harvester.event_page import clean_embedding_text from scipy import sparse from sklearn.metrics.pairwise import cosine_similarity @@ -315,7 +316,7 @@ def get_content(self, url) -> Optional[str]: if is_plain_text_embed_url(url): text = _fetch_plain_http_text(url) if text: - return text + return clean_embedding_text(url, text) logger.warning( "Plain-text URL %s: empty HTTP body, falling through to Playwright", url, diff --git a/application/tests/harvester_test/event_page_test.py b/application/tests/harvester_test/event_page_test.py new file mode 100644 index 000000000..bbdf733f5 --- /dev/null +++ b/application/tests/harvester_test/event_page_test.py @@ -0,0 +1,145 @@ +"""Jekyll event pages: front matter + JSON-LD become clean prose; templating is dropped.""" + +import unittest +from pathlib import Path + +from unittest.mock import patch + +from application.prompt_client import prompt_client +from application.utils.harvester.event_page import ( + clean_embedding_text, + prepare_event_page, +) + +FIXTURE = Path(__file__).parent / "fixtures" / "event_index.md.txt" + + +class PrepareEventPageTests(unittest.TestCase): + def setUp(self) -> None: + self.text = FIXTURE.read_text(encoding="utf-8") + self.out = prepare_event_page(self.text) + + def test_title_comes_from_the_event_name(self) -> None: + self.assertTrue( + self.out.startswith("# AppSec Days - Summer of Security 2020\n"), self.out + ) + + def test_event_facts_are_stated_as_prose(self) -> None: + self.assertIn("Dates: 2020-06-23 to 2020-08-26", self.out) + self.assertIn("Location: https://appsecdays.org/", self.out) + self.assertIn("virtual AppSec Days", self.out) + + def test_markup_noise_is_removed(self) -> None: + for noise in ( + "schema.org", + "@context", + "application/ld+json", + "layout: event", + "{%", + "{{", + " + +{{ site.data.event-details.pitch | markdownify }} + + **[Check out our sponsors](https://appsecdays.org/sponsors/swag/)** + +### Training Sessions Include + +* Learn threat modeling with the OWASP Top 10. +{% if site.data.trainings.count > 0 %} + +{% endif %} diff --git a/application/tests/harvester_test/harvest_event_test.py b/application/tests/harvester_test/harvest_event_test.py new file mode 100644 index 000000000..75b75a848 --- /dev/null +++ b/application/tests/harvester_test/harvest_event_test.py @@ -0,0 +1,134 @@ +"""Event repos are harvested through the event-page parser.""" + +from cre_logging import get_logger + +logger = get_logger(__name__) + +import tempfile +import unittest +from datetime import datetime, timezone +from pathlib import Path +from unittest.mock import patch + +from application import create_app, sqla +from application.database import db +from application.utils.harvester import pipeline as harvest_pipeline +from application.utils.harvester.schemas import HARVESTABLE_KINDS + +HEAD = "abc1234def5678" +FIXTURE = Path(__file__).parent / "fixtures" / "event_index.md.txt" + +REPOS_YAML = """ +repositories: + - id: owasp-www-event-demo + kind: event + enabled: true + type: github + owner: OWASP + repo: www-event-demo + branch: main + paths: {include: ["index.md"]} + chunking: {strategy: markdown_heading, max_tokens: 1000} + polling: {mode: full, interval_minutes: 1440} +""" + + +def _client(files): + class FakeClient: + def __init__(self, *args, **kwargs) -> None: + pass + + def sync(self) -> None: + pass + + def get_current_commit_sha(self) -> str: + return HEAD + + def get_file_at_commit(self, sha: str, path: str) -> str: + return files[path] + + def get_local_path(self) -> Path: + return Path("/nonexistent") + + return FakeClient + + +class HarvestEventTests(unittest.TestCase): + def setUp(self) -> None: + self.app = create_app(mode="test") + self.ctx = self.app.app_context() + self.ctx.push() + sqla.create_all() + self.tmp = tempfile.TemporaryDirectory() + self.yaml = Path(self.tmp.name) / "repos.yaml" + self.yaml.write_text(REPOS_YAML) + + def tearDown(self) -> None: + sqla.session.remove() + sqla.drop_all() + self.ctx.pop() + self.tmp.cleanup() + + def _run(self, files): + patches = [ + patch.object(harvest_pipeline, "GitRepositoryClient", _client(files)), + patch.object( + harvest_pipeline.ChangeDetector, + "get_files_at_commit", + lambda self, sha: list(files), + ), + patch.object( + harvest_pipeline, + "_commit_timestamp", + lambda client, sha: datetime(2026, 1, 1, tzinfo=timezone.utc), + ), + ] + for p in patches: + p.start() + self.addCleanup(p.stop) + return harvest_pipeline.run_harvester( + sqla.session, + "run-1", + repos_yaml=self.yaml, + sync_repos=False, + kinds=HARVESTABLE_KINDS, + ) + + def _texts(self): + return [row.payload["text"] for row in sqla.session.query(db.HarvestInput)] + + def test_events_are_harvestable(self) -> None: + self.assertIn("event", HARVESTABLE_KINDS) + + def test_event_page_is_parsed_before_chunking(self) -> None: + summary = self._run({"index.md": FIXTURE.read_text(encoding="utf-8")}) + self.assertEqual(summary.errors, 0) + self.assertEqual(summary.repositories, 1) + self.assertGreater(summary.chunks_written, 0) + joined = "\n".join(self._texts()) + self.assertIn("Summer of Security 2020", joined) + self.assertIn("2020-06-23", joined) + self.assertIn("threat modeling", joined) + for noise in ("schema.org", "ld+json", "{%", "{{", "layout: event"): + self.assertNotIn(noise, joined) + + def test_page_with_no_content_is_skipped_and_checkpoint_still_advances( + self, + ) -> None: + from application.utils.harvester.checkpoint_store import CheckpointStore + + summary = self._run( + {"index.md": "---\nlayout: event\n---\n{% include x.md %}\n"} + ) + self.assertEqual(summary.errors, 0) + self.assertEqual(summary.chunks_written, 0) + self.assertEqual( + CheckpointStore(session=sqla.session) + .load("owasp-www-event-demo") + .last_processed_commit, + HEAD, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/application/tests/harvester_test/selection_test.py b/application/tests/harvester_test/selection_test.py index 1764349fa..c4a38e042 100644 --- a/application/tests/harvester_test/selection_test.py +++ b/application/tests/harvester_test/selection_test.py @@ -127,7 +127,7 @@ def test_default_kinds_are_the_curated_standards_only(self) -> None: sel = select_repositories(cfg.repositories, self.store, now=NOW) self.assertEqual([r.repo for r in sel.selected], ["std"]) - def test_harvestable_kinds_exclude_chapter_and_event(self) -> None: + def test_harvestable_kinds_exclude_only_chapters(self) -> None: cfg = _repos( _entry("std", kind="standard"), _entry("proj", kind="project"), @@ -138,7 +138,9 @@ def test_harvestable_kinds_exclude_chapter_and_event(self) -> None: sel = select_repositories( cfg.repositories, self.store, kinds=HARVESTABLE_KINDS, now=NOW ) - self.assertEqual([r.repo for r in sel.selected], ["std", "proj", "other"]) + self.assertEqual( + [r.repo for r in sel.selected], ["std", "proj", "other", "evt"] + ) def test_explicit_kinds_filter(self) -> None: cfg = _repos(_entry("std", kind="standard"), _entry("proj", kind="project")) diff --git a/application/utils/harvester/event_page.py b/application/utils/harvester/event_page.py new file mode 100644 index 000000000..0bc93d14d --- /dev/null +++ b/application/utils/harvester/event_page.py @@ -0,0 +1,185 @@ +"""Turn an OWASP event page (a Jekyll ``index.md``) into plain markdown prose. + +Event repos (``www-event-*``) keep their facts in YAML front matter and a +schema.org JSON-LD ``