<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Liora Tools]]></title><description><![CDATA[Liora Tools]]></description><link>https://lioratools.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Fri, 30 Oct 2026 19:00:04 GMT</lastBuildDate><atom:link href="https://lioratools.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Turn Any URL into Clean Markdown for LLMs and RAG (No Headless Browser Needed)]]></title><description><![CDATA[Convert web pages or whole docs sections into chunked, metadata-rich Markdown ready for ChatGPT, Claude, LangChain or your own embeddings pipeline.]]></description><link>https://lioratools.hashnode.dev/turn-any-url-into-clean-markdown-for-llms-and-rag-no-headless-browser-needed</link><guid isPermaLink="true">https://lioratools.hashnode.dev/turn-any-url-into-clean-markdown-for-llms-and-rag-no-headless-browser-needed</guid><category><![CDATA[llm]]></category><category><![CDATA[RAG ]]></category><category><![CDATA[markdown]]></category><category><![CDATA[web scraping]]></category><category><![CDATA[AI]]></category><dc:creator><![CDATA[Liora Tools]]></dc:creator><pubDate>Thu, 29 Oct 2026 08:16:17 GMT</pubDate><content:encoded><![CDATA[<p>Paste raw HTML into an LLM and you pay for thousands of tokens of navigation menus, cookie banners, footers and script tags. Answers get worse and bills go up. Almost every RAG pipeline therefore starts with the same unglamorous step: <strong>turn the web page into clean text.</strong></p>
<p>You can do it yourself with <code>requests</code> + <code>readability</code> + <code>markdownify</code>. Then you add crawling, robots.txt, retries, chunking and dedup, and soon you're maintaining a crawler. This tutorial shows a shortcut: <a href="https://apify.com/liora-tools/url-to-markdown">URL to Markdown for LLM &amp; RAG</a>, an Apify Actor I built for this job.</p>
<h2>What it does</h2>
<ul>
<li>Fetches public pages over plain HTTP (no headless browser), about 0.5 s per page in our tests.</li>
<li>Extracts the <strong>main content</strong> and drops menus, footers, ads and cookie banners. Or use <code>full</code> mode for the whole page.</li>
<li>Outputs <strong>Markdown or plain text</strong>, plus metadata: title, description, language, published date, word and character counts.</li>
<li>Optionally <strong>pre-chunks</strong> the text with overlap, ready for embeddings.</li>
<li>Optionally <strong>crawls same-site links</strong> or seeds from <code>sitemap.xml</code>, so you can grab an entire docs section.</li>
<li>Can generate <code>llms.txt</code> and <code>llms-full.txt</code> for the pages it converts.</li>
</ul>
<h2>Step 1: Convert a single page</h2>
<p>The simplest input is a list of URLs:</p>
<pre><code class="language-json">{
  "urls": ["https://docs.python.org/3/tutorial/index.html"],
  "extractMode": "main",
  "outputFormat": "markdown"
}
</code></pre>
<p><code>urls</code> is the only required field. It accepts full URLs, bare domains (<code>example.com/page</code>) or several URLs on one line.</p>
<h2>Step 2: Crawl a docs section and chunk it</h2>
<p>Here's the input we used in testing to pull the Python tutorial for a RAG index:</p>
<pre><code class="language-json">{
  "urls": ["https://docs.python.org/3/tutorial/index.html"],
  "extractMode": "main",
  "outputFormat": "markdown",
  "crawlDepth": 1,
  "maxPages": 50,
  "includeUrlRegex": ["/tutorial/"],
  "chunkSize": 2000,
  "chunkOverlap": 200
}
</code></pre>
<p>What the important fields do:</p>
<ul>
<li><code>crawlDepth</code>: <code>0</code> converts only the listed URLs. <code>1</code> or more also follows links on the same website.</li>
<li><code>includeUrlRegex</code> / <code>excludeUrlRegex</code>: keep the crawl inside <code>/tutorial/</code> or <code>/docs/</code> and skip changelogs or tag pages.</li>
<li><code>useSitemap</code>: seed the crawl from <code>sitemap.xml</code> (found via robots.txt) instead of following links.</li>
<li><code>chunkSize</code> / <code>chunkOverlap</code>: split each page into overlapping chunks, measured in characters.</li>
<li><code>removeSelectors</code> / <code>keepSelectors</code>: fine-tune extraction, for example remove <code>.cookie-banner</code> or keep only <code>article</code>.</li>
<li><code>maxResults</code>: hard stop after N converted pages. This is your cost cap.</li>
</ul>
<h2>Step 3: Run it from code</h2>
<pre><code class="language-python">from apify_client import ApifyClient

client = ApifyClient("&lt;YOUR_APIFY_TOKEN&gt;")
run = client.actor("liora-tools/url-to-markdown").call(run_input={
    "urls": ["https://docs.python.org/3/tutorial/index.html"],
    "crawlDepth": 1,
    "includeUrlRegex": ["/tutorial/"],
    "chunkSize": 2000,
    "chunkOverlap": 200,
    "maxResults": 50,
})

docs = []
for page in client.dataset(run["defaultDatasetId"]).iterate_items():
    if page["status"] != "ok":
        continue
    for chunk in page.get("chunks") or [{"text": page["content"]}]:
        docs.append({"text": chunk["text"], "source": page["url"], "title": page["title"]})

print(len(docs), "chunks ready for embedding")
</code></pre>
<p>From there, pass <code>docs</code> to your embedding model and vector store: LangChain, LlamaIndex, pgvector or anything else. Each chunk keeps its source URL, so your RAG answers can cite pages.</p>
<p>Prefer one HTTP call? Use the sync endpoint:</p>
<pre><code class="language-bash">curl -X POST "https://api.apify.com/v2/acts/liora-tools~url-to-markdown/run-sync-get-dataset-items?token=$APIFY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"urls": ["https://docs.python.org/3/tutorial/errors.html"]}'
</code></pre>
<h2>Step 4: Read the output</h2>
<p>One item per page. This is a real record from our test crawl of the Python tutorial (the <code>content</code> field is shortened here):</p>
<pre><code class="language-json">{
  "url": "https://docs.python.org/3/tutorial/errors.html",
  "depth": 1,
  "status": "ok",
  "error": null,
  "statusCode": 200,
  "finalUrl": "https://docs.python.org/3/tutorial/errors.html",
  "title": "8. Errors and Exceptions",
  "description": "Until now error messages havenâ€™t been more than mentioned, but if you have tried out the examples you have probably seen some. â€¦",
  "language": "en",
  "publishedDate": "2026-10-21",
  "content": "# 8. Errors and Exceptions\n\nUntil now error messages havenâ€™t been more than mentioned â€¦\n\n## 8.1. Syntax Errors\n\nâ€¦",
  "wordCount": 2888,
  "charCount": 22581,
  "chunks": null,
  "links": null,
  "durationMs": 443,
  "scrapedAt": "2026-10-21T16:27:17.018135+00:00"
}
</code></pre>
<p>Headings, code blocks and links survive as real Markdown, which LLMs handle well. With chunking on, <code>chunks</code> becomes a list like <code>[{"index": 0, "charStart": 0, "text": "â€¦"}]</code>.</p>
<p>The <code>status</code> field tells you what happened on every URL:</p>
<ul>
<li><code>ok</code>: converted.</li>
<li><code>needs_javascript</code>: the page is a client-side app shell with no server-rendered text.</li>
<li><code>no_text</code>: no readable text found.</li>
<li><code>skipped</code>: disallowed by robots.txt.</li>
<li><code>error</code>: failed after retries.</li>
</ul>
<p>After each run, a <code>RUN_SUMMARY</code> record in the key-value store lists counts, <code>failedUrls</code> and a <code>retryInput</code> you can paste back as input to retry only the failures.</p>
<h2>Measured results</h2>
<p>From our own tests on 2026-10-21:</p>
<ul>
<li>Wikipedia "Large language model": 13,058 words, 52 chunks, 1.4 s.</li>
<li>BBC News front page: 1,298 words, 0.8 s.</li>
<li>Python tutorial crawl at depth 1: 6 pages in about 4 s total.</li>
</ul>
<h2>Keeping a knowledge base fresh</h2>
<p>For a scheduled job, turn on <code>"onlyChanged": true</code> with a <code>stateKey</code>. Pages whose HTML didn't change since the last run with that key are skipped, and skipped pages are free. Put it on an Apify Schedule (say, weekly) and re-embed only what changed.</p>
<h2>What it costs</h2>
<p>Pay per event: <strong>$0.001 per converted page</strong> ($1 per 1,000 pages), plus Apify's $0.00005 start fee per run. A 300-page documentation site costs about $0.30. Errors, robots.txt skips, empty pages and JavaScript-only shells aren't charged. <code>maxConsecutiveFailures</code> (default 25) also stops a run early if a site keeps failing.</p>
<h2>Honest limits</h2>
<ul>
<li><strong>No JavaScript rendering.</strong> Single-page apps that render everything in the browser come back as <code>needs_javascript</code>. You aren't charged, but you won't get the text either. If such a page embeds its article as schema.org JSON-LD, the Actor uses that. Otherwise, use a browser-based crawler.</li>
<li><strong>Public pages only.</strong> No proxies, stealth browsers, CAPTCHA bypass or logins. robots.txt is respected by default.</li>
<li><strong>No PDFs or Office files.</strong> This Actor handles HTML pages only.</li>
<li><strong>Main-content extraction is heuristic.</strong> On unusual layouts it can keep a sidebar or miss a section. <code>keepSelectors</code> / <code>removeSelectors</code> fix most cases.</li>
<li><strong>You're responsible for your right to use the content</strong> you convert.</li>
</ul>
<h2>Wrap-up</h2>
<p>If your LLM app needs web content, cleaning it is the cheapest quality win you'll get: fewer tokens, better retrieval, citable sources. Try it on one page of your own docs first, then add <code>crawlDepth</code> and chunking once the output looks right.</p>
<p><strong>Actor:</strong> <a href="https://apify.com/liora-tools/url-to-markdown">https://apify.com/liora-tools/url-to-markdown</a></p>
<hr />
<p>Disclosure: written with AI assistance and reviewed by the Liora Tools team; I build these tools.</p>
]]></content:encoded></item><item><title><![CDATA[How to Pull Job Listings from Greenhouse, Lever and Ashby Career Sites Without Writing a Scraper]]></title><description><![CDATA[Use the public job-board APIs these ATS platforms already publish to get clean, normalized job data, with filters and "new jobs only" monitoring.]]></description><link>https://lioratools.hashnode.dev/how-to-pull-job-listings-from-greenhouse-lever-and-ashby-career-sites-without-writing-a-scraper</link><guid isPermaLink="true">https://lioratools.hashnode.dev/how-to-pull-job-listings-from-greenhouse-lever-and-ashby-career-sites-without-writing-a-scraper</guid><category><![CDATA[Python]]></category><category><![CDATA[automation]]></category><category><![CDATA[data]]></category><category><![CDATA[web scraping]]></category><category><![CDATA[apify]]></category><dc:creator><![CDATA[Liora Tools]]></dc:creator><pubDate>Thu, 29 Oct 2026 08:14:29 GMT</pubDate><content:encoded><![CDATA[<p>If you've ever tried to track open roles at a list of companies, you know the pain. Every company runs its careers page on a different applicant tracking system (ATS), and each one has its own HTML, its own pagination and its own quirks. Writing a scraper for each is a weekend you won't get back, and it breaks the next time they redesign.</p>
<p>Here's the part many people miss: <strong>most of these ATS platforms already publish official public JSON APIs for their job boards.</strong> That's how companies embed job listings on their own sites. Greenhouse, Lever, Ashby, Workable, SmartRecruiters and Recruitee all have them. You don't need a headless browser, proxies or a login. You need to know the endpoints and normalize the fields.</p>
<p>This tutorial shows how to do that with an Apify Actor I built, <a href="https://apify.com/liora-tools/ats-career-site-jobs">Career Site Jobs: Greenhouse, Lever, Ashby &amp; more</a>. It wraps those public APIs and returns one consistent schema.</p>
<h2>Why not call the APIs yourself?</h2>
<p>You can, and for one company it's easy. The work shows up when you scale:</p>
<ul>
<li>Each ATS names fields differently (<code>location.name</code> vs <code>categories.location</code> vs <code>locationName</code>).</li>
<li>Salary shows up in a structured field on some boards and only inside the description on others.</li>
<li>"Remote" can be a boolean, a workplace type or just the word in the location string.</li>
<li>Tracking <em>new</em> postings means storing what you saw last time.</li>
</ul>
<p>The Actor handles those parts so you get a flat table you can drop into a spreadsheet, a database or an LLM prompt.</p>
<h2>Step 1: Collect your company list</h2>
<p>The input accepts three formats, one per line:</p>
<ul>
<li>A careers board URL: <code>https://boards.greenhouse.io/stripe</code>, <code>https://jobs.lever.co/palantir</code>, <code>https://jobs.ashbyhq.com/openai</code></li>
<li>An explicit <code>ats:slug</code>: <code>greenhouse:stripe</code>, <code>ashby:openai</code></li>
<li>A bare slug like <code>stripe</code>. The Actor then tries each ATS automatically.</li>
</ul>
<p>It also reads Workable (<code>apply.workable.com/...</code>), SmartRecruiters, Recruitee, Personio (<code>acme.jobs.personio.de</code>) and Workday (pass the full <code>myworkdayjobs.com</code> career-site URL).</p>
<p>To find a company's ATS, open its careers page and hover over a job link. The domain usually tells you (<code>boards.greenhouse.io</code>, <code>jobs.lever.co</code>, <code>jobs.ashbyhq.com</code>).</p>
<p>If you just want to try it, there are two preset lists: <code>ai-companies</code> and <code>big-tech-startups</code>.</p>
<h2>Step 2: Configure filters</h2>
<p>Here's an input that pulls remote engineering roles that mention Python from three boards:</p>
<pre><code class="language-json">{
  "companies": [
    "https://boards.greenhouse.io/stripe",
    "https://jobs.lever.co/palantir",
    "ashby:openai"
  ],
  "titleKeywords": ["engineer"],
  "excludeTitleKeywords": ["intern"],
  "descriptionKeywords": ["Python"],
  "remoteOnly": true,
  "includeDescription": true,
  "descriptionFormat": "text",
  "maxItems": 100
}
</code></pre>
<p>Useful filters from the input schema:</p>
<ul>
<li><code>titleKeywords</code> / <code>excludeTitleKeywords</code>: case-insensitive title matching.</li>
<li><code>descriptionKeywords</code>: searches the full job description, not only the title.</li>
<li><code>locations</code>, <code>departments</code>, <code>employmentTypes</code>: substring matches.</li>
<li><code>workplaceTypes</code>: any of <code>remote</code>, <code>hybrid</code>, <code>onsite</code>.</li>
<li><code>seniorityLevels</code>: detected from the title (<code>junior</code>, <code>senior</code>, <code>principal/staff</code>, ...).</li>
<li><code>minSalary</code> and <code>onlyWithSalary</code>: work only where a salary is published.</li>
<li><code>onlyVisaSponsorship</code>: a rule-based check of the description text.</li>
<li><code>postedWithinDays</code>: uses the publish date from the ATS. Not every ATS provides one.</li>
<li><code>maxItems</code> / <code>maxJobsPerCompany</code>: hard caps that also cap your cost.</li>
</ul>
<h2>Step 3: Run it</h2>
<p>You can run it from the Apify Console (paste the JSON, click Start) or from code. With the Python client:</p>
<pre><code class="language-python">from apify_client import ApifyClient

client = ApifyClient("&lt;YOUR_APIFY_TOKEN&gt;")
run = client.actor("liora-tools/ats-career-site-jobs").call(run_input={
    "companies": ["https://boards.greenhouse.io/stripe", "ashby:openai"],
    "titleKeywords": ["engineer"],
    "maxItems": 50,
})

for job in client.dataset(run["defaultDatasetId"]).iterate_items():
    print(job["company"], "|", job["title"], "|", job["location"], "|", job["url"])
</code></pre>
<p>Or with plain <code>curl</code>, which returns the dataset items directly:</p>
<pre><code class="language-bash">curl -X POST "https://api.apify.com/v2/acts/liora-tools~ats-career-site-jobs/run-sync-get-dataset-items?token=$APIFY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"companies": ["huggingface"], "maxItems": 10}'
</code></pre>
<h2>Step 4: Read the output</h2>
<p>Each item uses the same schema no matter which ATS it came from. This record is from one of our test runs against Hugging Face's Workable board (description shortened):</p>
<pre><code class="language-json">{
  "ats": "workable",
  "companySlug": "huggingface",
  "company": "Hugging Face",
  "jobId": "81B46579FE",
  "title": "Open-Source Machine Learning Engineer - EMEA Remote",
  "department": "Open Source",
  "team": "Engineering",
  "location": "Paris, ÃŽle-de-France, France",
  "offices": ["Paris, ÃŽle-de-France, France"],
  "isRemote": true,
  "workplaceType": "remote",
  "employmentType": "Full-time",
  "publishedAt": "2026-05-28T16:00:00+00:00",
  "url": "https://apply.workable.com/j/81B46579FE",
  "applyUrl": "https://apply.workable.com/j/81B46579FE/apply",
  "compensation": null,
  "descriptionText": "At Hugging Face, we're on a journey to democratize good AI. â€¦",
  "isNew": null,
  "scrapedAt": "2026-10-21T09:36:53.996612+00:00"
}
</code></pre>
<p>When a board publishes pay, <code>compensation</code> is filled in. One record from the same test set had <code>{"summary": "$257K â€“ $335K â€¢ Offers Equity"}</code>. When there's no salary, it's <code>null</code>. The Actor doesn't guess.</p>
<p>Export the dataset as CSV, JSON or Excel from the Console, or pull it through the API.</p>
<h2>Step 5: Turn it into a monitor</h2>
<p>This is the feature I use most. Set <code>"onlyNewJobs": true</code> and give the watchlist a <code>stateKey</code>:</p>
<pre><code class="language-json">{
  "companies": ["greenhouse:stripe", "ashby:openai"],
  "titleKeywords": ["data engineer"],
  "onlyNewJobs": true,
  "stateKey": "data-eng-watchlist"
}
</code></pre>
<p>Then add an Apify Schedule (for example, daily at 08:00). Each run returns only postings that weren't there on the last run with the same key. Connect a webhook or an integration (Slack, email, Google Sheets, Zapier, Make) and you have a job alert that only fires on new roles.</p>
<p>Use different <code>stateKey</code> values for different watchlists so they don't overwrite each other.</p>
<h2>What it costs</h2>
<p>Pricing is pay per event: <strong>$0.002 per job saved</strong> ($2 per 1,000 jobs), plus Apify's default start fee of $0.00005 per run. A daily monitor on 20 companies that returns a handful of new jobs costs fractions of a cent per day. Set <code>maxItems</code> if you want a hard ceiling.</p>
<h2>Honest limits</h2>
<ul>
<li><strong>Only supported ATS platforms.</strong> Greenhouse, Lever, Ashby, Workable, SmartRecruiters, Recruitee, Personio and Workday. iCIMS, Taleo and SuccessFactors aren't supported yet. Companies with a fully custom careers page won't work either.</li>
<li><strong>Workday is polite by design.</strong> It's read through the same public JSON endpoint the career site uses, and only when the site's robots.txt allows it. Disallowed sites are skipped.</li>
<li><strong>Data is only as good as the board.</strong> Salary, publish date and department show up only if the company fills them in. <code>postedWithinDays</code> can't filter on boards with no publish date.</li>
<li><strong>Seniority and visa sponsorship are rule-based</strong>, read from titles and description text. Treat them as a helpful filter, not ground truth.</li>
<li><strong>Public postings only.</strong> No logins, no proxies, no CAPTCHA bypass. Personal data such as emails and phone numbers in descriptions is masked.</li>
</ul>
<h2>Ideas for what to build</h2>
<ul>
<li>A niche job board ("remote Rust jobs at AI startups") rebuilt every morning.</li>
<li>Hiring-signal tracking for sales: which target accounts just opened ten engineering roles?</li>
<li>Salary benchmarking from boards that publish pay ranges.</li>
<li>Feeding plain-text descriptions into an LLM to tag tech stack or seniority.</li>
</ul>
<p>If you try it and a board you need isn't covered, open an issue on the Actor page. That's how the support list grows.</p>
<p><strong>Actor:</strong> <a href="https://apify.com/liora-tools/ats-career-site-jobs">https://apify.com/liora-tools/ats-career-site-jobs</a></p>
<hr />
<p>Disclosure: written with AI assistance and reviewed by the Liora Tools team; I build these tools.</p>
]]></content:encoded></item></channel></rss>