feat(markdown): convert crawled HTML and documents to Markdown via convert-markdown.ifad.org - #1
Open
lleirborras wants to merge 16 commits into
Open
feat(markdown): convert crawled HTML and documents to Markdown via convert-markdown.ifad.org#1lleirborras wants to merge 16 commits into
lleirborras wants to merge 16 commits into
Conversation
Reviewed design for routing crawled HTML and binary documents through convert-markdown.ifad.org so body holds Markdown. Records the insertion point, transport choice, config shape, failure semantics and test plan. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XMpa3bomxH6WW3GBBfmoVZ
Nine TDD tasks derived from the design spec, validated by tech-lead review. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XMpa3bomxH6WW3GBBfmoVZ
…ctory Adds the markdown_conversion nested config block (default disabled), its validation (booleans, on_failure enum, wait_seconds range, positive poll_interval/timeout, http(s) base_url, and an ES bulk-size guard), and a memoised Config#markdown_converter factory backed by a new Crawler::MarkdownConverter skeleton class. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XMpa3bomxH6WW3GBBfmoVZ
…/content_hash Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XMpa3bomxH6WW3GBBfmoVZ
…builders Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XMpa3bomxH6WW3GBBfmoVZ
…heck and stats Adds the HTTP side of MarkdownConverter: a hand-built multipart upload to POST /api/v1/convert/upload?wait=N, status polling with 1.5x backoff capped at 5s against a hard deadline, one retry after 1s for network errors, 5xx and an expired job (404 while polling), a 5s /api/v1/health check, markdown truncation to max_body_size and thread-safe converted/failed counters. convert! never raises; it returns :converted, :skipped or :failed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XMpa3bomxH6WW3GBBfmoVZ
The status_url in a converter job document is server-controlled and was concatenated onto base_url unchecked, so a response of "@evil.host/x" parsed as host evil.host with the configured base_url demoted to userinfo and the poll left the intended service. Accept only an absolute path and require the parsed URI to keep the configured host with no userinfo; both failures raise ConversionError naming the offending status_url and are not retried. Also pins the final-failure log assertions to their exact message instead of at_least(:once), and asserts p_addr is nil on the constructed Net::HTTP. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XMpa3bomxH6WW3GBBfmoVZ
…unhealthy converter Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XMpa3bomxH6WW3GBBfmoVZ
DocumentMapper now emits body_format and content_hash for both HTML and binary documents. When Success#markdown is present, it becomes the body (body_format: 'markdown'); binary docs then omit _attachment since the ingest attachment processor is unneeded. Otherwise behavior is unchanged (body_format: 'text', _attachment kept for binary docs). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XMpa3bomxH6WW3GBBfmoVZ
… markdown on the console sink _reduce_whitespace now defaults to false when markdown_conversion is enabled, since the default ingest pipeline collapses whitespace in `body` and would destroy markdown formatting. An explicit _reduce_whitespace: true is still honoured, with a one-time warning. The console sink now prints crawl_result.markdown when present, instead of the raw HTML/binary-content placeholder. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XMpa3bomxH6WW3GBBfmoVZ
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XMpa3bomxH6WW3GBBfmoVZ
…lock Adds config/crawler.yml.example markdown_conversion block and xlsx MIME type, docs/features/MARKDOWN_CONVERSION.md, and a README link, matching the paths already referenced by lib/crawler/api/config.rb and lib/crawler/markdown_converter.rb. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XMpa3bomxH6WW3GBBfmoVZ
A converter that dies mid-crawl used to cost every remaining document a full upload, a retry and possibly the whole per-document timeout before falling back to plain text. MarkdownConverter now counts consecutive failures and opens a circuit breaker after 20 of them: for the next 60 seconds convert! returns :failed without any HTTP call, so on_failure keeps deciding as before (text -> plain-text body, skip -> not indexed) and the failures still show up in the final stats line. Exactly one thread re-probes /health after the cooldown; success closes the breaker, failure re-arms it. A successful conversion resets the counter. healthy? now retries once after RETRY_DELAY so a single blip neither aborts a crawl nor keeps the breaker open, and the per-document retry announcement drops from warn to debug: only the final failure of a document deserves a warning. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XMpa3bomxH6WW3GBBfmoVZ
- parse_job: a valid JSON body that is not an object (`null`, an array) reached job['status'] and blew up with a NoMethodError inside the generic rescue; it now fails as a plain ConversionError. - raise_on_http_error!: the error body is remote input, so inspect it before interpolating - a body with newlines could otherwise forge entries in the system log. - html_payload: exclude_tags may remove the <body> element itself, which made Transformer.transform! raise on a nil tag; the remaining document is now uploaded as-is. - require_dependency the ContentEngine modules the converter uses instead of relying on another file having loaded them first. - Config::SENSITIVE_FIELDS gains markdown_conversion, so a converter base_url (which can carry credentials) is redacted from Config#to_s. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XMpa3bomxH6WW3GBBfmoVZ
log_markdown_conversion_stats shadowed the delegated `stats` method with a local; rename it. The line is now also printed on the resumable shutdown path (so the documented "log ends with Markdown conversions:" holds) and suppressed when the crawl aborted on the start-up health check, where there is nothing to report. start_url_test! runs verify_markdown_converter! too: a URL test used to report plain-text bodies without ever asking the converter, and it also warms the markdown_converter memo on the main thread. The duplicated crawl-start event and unexpected-error handling move into helpers to keep both entry points inside the metrics limits. Also assign the converter once in Coordinator#convert_and_output_crawl_result and fix the stale comment claiming Jsoup's .clone is shallow - it is a deep copy, which is what MarkdownConverter#html_payload relies on. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XMpa3bomxH6WW3GBBfmoVZ
Explain that body_format and content_hash are written to every document even when the feature is disabled, and that both names stay reserved for extraction rules, so the index shape never depends on the converter and a later re-crawl can skip unchanged content. Document the circuit breaker, the health check running for both crawl and urltest, and the retry announcement now being a debug line. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XMpa3bomxH6WW3GBBfmoVZ
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds an opt-in
markdown_conversionfeature: when enabled, every crawled HTML page and PDF/DOCX/XLSX/PPTX is uploaded to the shared IFAD serviceconvert-markdown.ifad.organd the returned Markdown becomes the Elasticsearchbody, so all IFAD apps index identical conversions. Plain-text extraction (HTML) and the_attachmentTika path (binaries) remain the fallback.Design spec:
docs/superpowers/specs/2026-09-04-markdown-conversion-design.md. Feature doc:docs/features/MARKDOWN_CONVERSION.md.How it works
Coordinator#process_crawl_resultcallsconfig.markdown_converter.convert!on the crawl-task thread after the rule engine and before the sink, so no HTTP call ever runs under the Elasticsearch sink's queue lock. Coordinator delta is one call site plus one small helper.Crawler::MarkdownConverter(lib/crawler/markdown_converter.rb) owns everything: MIME map, Jsoup pre-processing (honoursexclude_tags/data-elastic-exclude, UTF-8 charset, clone so link extraction is untouched), hand-built multipart upload vianet/http, submit + poll with backoff and a hard deadline, retry-once rules, SSRF guard on the server-suppliedstatus_url, health check, circuit breaker, atomic stats.DocumentMapper:body= markdown when present, plusbody_format(markdown|text) andcontent_hashon every doc;_attachmentomitted only when markdown is present._reduce_whitespacetofalsewhen the feature is on (the ingest pipeline would otherwise flatten markdown); an explicittrueis honoured with a warning.Crawl#start!andstart_url_test!fail fast withErrors::MarkdownConverterUnavailableErrorwhen the converter is unhealthy, so an outage cannot silently produce a text-only index. Mid-crawl outages trip a circuit breaker (20 consecutive failures, 60 s cooldown, single-thread re-probe).Config
Note: changes that apply even when disabled
body_formatandcontent_hashare added to every document (HTML and binary) regardless ofenabled, and both names are reserved for extraction rules. Deliberate:content_hashenables a later skip-unchanged re-crawl. Documented in the feature doc.Verification
crawler-ciDocker image: 791 examples, 0 failures, 16 pre-existing pending. Rubocop project-wide clean.bin/crawler's JVM properties (https.protocols=SSLv3,force.http.jre.executor=true) set inside the container,Net::HTTPreachedhttps://convert-markdown.ifad.org/api/v1/healthand got HTTP 200, proving jruby-openssl is unaffected by that property.Follow-ups (not in this PR)
content_hash.🤖 Generated with Claude Code
https://claude.ai/code/session_01XMpa3bomxH6WW3GBBfmoVZ