<?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[RyuMoon Data]]></title><description><![CDATA[Practical guides to scraping and data tools for AI and research]]></description><link>https://ryumoondata.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Mon, 09 Nov 2026 11:52:19 GMT</lastBuildDate><atom:link href="https://ryumoondata.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[How to extract company emails and phones from a website list]]></title><description><![CDATA[Buying a lead database is overkill when you already have a short list of company domains: agencies you might hire, exhibitors at a trade show, or startups…]]></description><link>https://ryumoondata.hashnode.dev/how-to-extract-company-emails-and-phones-from-a-website-list</link><guid isPermaLink="true">https://ryumoondata.hashnode.dev/how-to-extract-company-emails-and-phones-from-a-website-list</guid><category><![CDATA[web scraping]]></category><category><![CDATA[lead generation]]></category><category><![CDATA[sales]]></category><category><![CDATA[data]]></category><dc:creator><![CDATA[RyuMoon Data]]></dc:creator><pubDate>Fri, 06 Nov 2026 22:38:50 GMT</pubDate><content:encoded><![CDATA[<p>Buying a lead database is overkill when you already have a short list of company domains: agencies you might hire, exhibitors at a trade show, or startups from a funding round. What you need is a careful pass over each site’s public pages (homepage, <code>/contact</code>, <code>/about</code>) for emails, phone numbers and social profile links.</p>
<p>This tutorial shows a clean way to do that in bulk, with one output row per website, and pay only when something useful is found.</p>
<h2>Before you start — stay on the right side of the line</h2>
<ul>
<li>Use <strong>public business</strong> contact details for legitimate outreach. Do not scrape personal inboxes to spam people.</li>
<li>Honour each site’s terms and applicable privacy law (UK GDPR / PECR if you email people in the UK).</li>
<li>Prefer role addresses (<code>hello@</code>, <code>press@</code>, <code>support@</code>) when a tool can filter personal mailboxes.</li>
<li>Cap pages per site so you do not crawl an entire blog looking for a mailto link.</li>
</ul>
<p>This is not a Google Maps scraper. You paste company websites; you do not search “plumbers near me”.</p>
<h2>Step 1 — Prepare a domain list</h2>
<p>A CSV or plain list is enough:</p>
<pre><code>https://apify.com
https://www.python.org
https://www.gov.uk
</code></pre>
<p>Normalise them first:</p>
<ul>
<li>Add <code>https://</code> if missing.</li>
<li>Drop tracking paths (<code>?utm_…</code>).</li>
<li>Skip social profile URLs (LinkedIn company pages are not “the company website” for this method).</li>
</ul>
<h2>Step 2 — Run the contact extractor</h2>
<h3>Console</h3>
<ol>
<li>Open <a href="https://apify.com/ryumoon-data/company-contact-details-scraper">Company Website Contact Scraper – Emails, Phones, Socials</a>.</li>
<li>Paste the websites.</li>
<li>Set <strong>Max pages per website</strong> to <code>3</code> or <code>4</code> (homepage + contact + about is usually enough).</li>
<li>Optionally enable <strong>Only emails on the website’s own domain</strong> so <code>gmail.com</code> personal addresses drop out.</li>
<li>Optionally enable <strong>Leave out websites with no contacts found</strong> to keep the dataset clean.</li>
<li>Start and export CSV or JSON.</li>
</ol>
<p>On this Actor you pay <strong>$1.50 per 1,000 websites that return contacts</strong> (plus a small start fee). Sites with nothing found can be skipped from charging when that option is on — check the pricing tab for the exact event rules before a big run.</p>
<h3>Python</h3>
<pre><code class="language-bash">pip install apify-client
</code></pre>
<pre><code class="language-python">import os
from apify_client import ApifyClient

client = ApifyClient(os.environ["APIFY_TOKEN"])

run = client.actor("ryumoon-data/company-contact-details-scraper").call(
    run_input={
        "websites": [
            "https://apify.com",
            "https://www.python.org",
        ],
        "maxPagesPerWebsite": 4,
        "onlyEmailsOnSiteDomain": True,
        "skipWebsitesWithoutContacts": True,
        "includePersonalContacts": False,
    }
)

for row in client.dataset(run["defaultDatasetId"]).iterate_items():
    print(
        row.get("website") or row.get("url"),
        "| emails:", row.get("emails") or row.get("email"),
        "| phones:", row.get("phones") or row.get("phone"),
        "| socials:", list((row.get("socials") or row.get("socialProfiles") or {}).keys())[:5],
    )
</code></pre>
<h2>Step 3 — Sample output</h2>
<p>Trimmed example shape (field names can vary slightly by version — use the dataset column picker in Console):</p>
<pre><code class="language-json">{
  "website": "https://apify.com",
  "emails": ["hello@apify.com"],
  "phones": [],
  "socials": {
    "twitter": "https://twitter.com/apify",
    "linkedin": "https://www.linkedin.com/company/apifytech",
    "github": "https://github.com/apify"
  },
  "pagesScanned": 2,
  "scannedUrls": ["https://apify.com", "https://apify.com/contact"]
}
</code></pre>
<p>One row per site keeps enrichment simple: join back to your CRM on domain.</p>
<h2>Step 4 — Turn results into an outreach sheet</h2>
<ol>
<li>Export CSV from the Apify dataset.  </li>
<li>Add columns: <code>company</code>, <code>domain</code>, <code>email</code>, <code>source_page</code>, <code>date_found</code>, <code>permission_basis</code>.  </li>
<li>Deduplicate emails case-insensitively.  </li>
<li>For UK email marketing, record a lawful basis before you send anything. Cold B2B email has rules; when unsure, ask a solicitor or stick to LinkedIn / contact forms.</li>
</ol>
<h2>Quality tips that actually move the needle</h2>
<table>
<thead>
<tr>
<th>Tip</th>
<th>Why it helps</th>
</tr>
</thead>
<tbody><tr>
<td>Cap at 3–4 pages</td>
<td>Contact pages sit near the root; deep blog crawls add cost and noise</td>
</tr>
<tr>
<td>Prefer same-domain emails</td>
<td>Cuts personal Gmail harvested from author bios</td>
</tr>
<tr>
<td>Schedule weekly, not daily</td>
<td>Contact pages rarely change every day</td>
</tr>
<tr>
<td>Spot-check 10 random rows</td>
<td>Catches mailto: image-only pages your parser missed</td>
</tr>
</tbody></table>
<h2>What this method is bad at</h2>
<ul>
<li>Finding the personal email of a specific employee (use a people database, carefully).</li>
<li>Sites that hide contacts behind forms or logins.</li>
<li>Marketplaces and social networks (wrong tool).</li>
</ul>
<h2>Cost sketch</h2>
<p>Twenty company sites, most with a public <code>contact@</code>, cost well under $0.10 in contact events on this pricing. That is enough to validate your sheet format before you paste 2,000 domains.</p>
<h2>Wrap-up</h2>
<ol>
<li>Clean a list of company websites.  </li>
<li>Crawl a few public pages per site for emails, phones and socials.  </li>
<li>Keep one row per domain and record how you plan to use the data.</li>
</ol>
<p>Worked example Actor: <a href="https://apify.com/ryumoon-data/company-contact-details-scraper">apify.com/ryumoon-data/company-contact-details-scraper</a>.</p>
<hr />
<p><em>Disclosure: I built the Apify Actor in the worked example (<a href="https://apify.com/ryumoon-data/company-contact-details-scraper">Company Website Contact Scraper</a>). The workflow is tool-agnostic; swap the Actor if you prefer another contact extractor.</em></p>
]]></content:encoded></item><item><title><![CDATA[How to convert a website to Markdown for LLM and RAG pipelines]]></title><description><![CDATA[If you paste raw HTML into an LLM or a vector store, you waste tokens on navigation, cookie banners and scripts, and your chunks retrieve poorly. Markdown…]]></description><link>https://ryumoondata.hashnode.dev/how-to-convert-a-website-to-markdown-for-llm-and-rag-pipelines</link><guid isPermaLink="true">https://ryumoondata.hashnode.dev/how-to-convert-a-website-to-markdown-for-llm-and-rag-pipelines</guid><category><![CDATA[RAG ]]></category><category><![CDATA[llm]]></category><category><![CDATA[markdown]]></category><category><![CDATA[web scraping]]></category><dc:creator><![CDATA[RyuMoon Data]]></dc:creator><pubDate>Fri, 06 Nov 2026 22:36:32 GMT</pubDate><content:encoded><![CDATA[<p>If you paste raw HTML into an LLM or a vector store, you waste tokens on navigation, cookie banners and scripts, and your chunks retrieve poorly. Markdown keeps headings, lists, code and links, which header-aware splitters (LangChain’s <code>MarkdownHeaderTextSplitter</code>, LlamaIndex equivalents) can turn into clean RAG chunks.</p>
<p>This post shows a practical path: fetch pages as Markdown, keep only the main content, then load a few pages into a tiny retrieval demo. You do not need a YouTube API key, a browser farm on your laptop, or a paid Firecrawl plan to follow along.</p>
<h2>What “good” Markdown for RAG looks like</h2>
<p>A useful page export usually has:</p>
<ol>
<li>The article or docs body, not the whole chrome of the site.</li>
<li>Real <code>#</code> / <code>##</code> headings in reading order.</li>
<li>Links and code fences preserved.</li>
<li>Metadata you can store alongside the text (URL, title, description).</li>
<li>A hard cap on pages so a runaway crawl does not empty your wallet.</li>
</ol>
<p>HTTP-first fetchers are enough for most docs and blogs. Reach for a headless browser only when the first response is empty (heavy client-side apps).</p>
<h2>Step 1 — Pick a small, public start URL</h2>
<p>Use a docs page you are allowed to read. Two safe examples:</p>
<ul>
<li><code>https://docs.apify.com/academy/web-scraping-for-beginners</code></li>
<li><code>https://en.wikipedia.org/wiki/Retrieval-augmented_generation</code></li>
</ul>
<p>Keep the first run to <strong>5 pages or fewer</strong> and crawl depth <strong>0</strong> (only the URLs you paste). Expand later once the shape of the output looks right.</p>
<h2>Step 2 — Run a Markdown crawler</h2>
<h3>Option A: Apify Console (no code)</h3>
<ol>
<li>Open <a href="https://apify.com/ryumoon-data/website-to-markdown-rag">Website to Markdown for LLM &amp; RAG</a>.</li>
<li>Paste the start URLs.</li>
<li>Set <strong>Max pages in total</strong> to <code>5</code>.</li>
<li>Leave rendering on auto / HTTP-first if the Actor offers it.</li>
<li>Click <strong>Start</strong>, then open the default dataset.</li>
</ol>
<p>You are charged per successfully saved page on this Actor ($1 per 1,000 HTTP pages; browser-rendered pages are a separate, slightly cheaper add-on event). Failed pages are not charged as page events.</p>
<h3>Option B: Python with the Apify client</h3>
<pre><code class="language-bash">pip install apify-client
</code></pre>
<pre><code class="language-python">import os
from apify_client import ApifyClient

client = ApifyClient(os.environ["APIFY_TOKEN"])

run = client.actor("ryumoon-data/website-to-markdown-rag").call(
    run_input={
        "startUrls": [
            {"url": "https://docs.apify.com/academy/web-scraping-for-beginners"},
            {"url": "https://en.wikipedia.org/wiki/Retrieval-augmented_generation"},
        ],
        "maxPages": 5,
        "maxCrawlDepth": 0,
        "mainContentOnly": True,
        "includeLinks": True,
    }
)

rows = list(client.dataset(run["defaultDatasetId"]).iterate_items())
print(f"Got {len(rows)} pages")
for row in rows:
    print(row.get("url") or row.get("request", {}).get("url"), "→", len((row.get("markdown") or "")) , "chars")
</code></pre>
<p>Field names above match the Actor’s public input schema (<code>startUrls</code>, <code>maxPages</code>, <code>maxCrawlDepth</code>, <code>mainContentOnly</code>, <code>includeLinks</code>). If a field is missing on a future version, check the input tab on the Store page.</p>
<h2>Step 3 — Inspect a sample row</h2>
<p>A typical successful item looks like this (trimmed):</p>
<pre><code class="language-json">{
  "url": "https://en.wikipedia.org/wiki/Retrieval-augmented_generation",
  "title": "Retrieval-augmented generation",
  "markdown": "# Retrieval-augmented generation\n\n**Retrieval-augmented generation** (RAG) is a technique…\n\n## History\n\n…",
  "description": "…",
  "links": ["https://en.wikipedia.org/wiki/Large_language_model"]
}
</code></pre>
<p>Check three things before you scale up:</p>
<ul>
<li>Headings survived (<code>#</code>, <code>##</code>).</li>
<li>Boilerplate is gone (no “Skip to content”, cookie text, or footer link farms).</li>
<li>The Markdown is short enough to chunk (tens of KB per page is fine; multi-MB dumps usually mean you forgot main-content mode).</li>
</ul>
<h2>Step 4 — Chunk and load into a tiny RAG demo</h2>
<p>Header-aware splitting keeps section context:</p>
<pre><code class="language-python">from langchain_text_splitters import MarkdownHeaderTextSplitter

splitter = MarkdownHeaderTextSplitter(
    headers_to_split_on=[("#", "h1"), ("##", "h2"), ("###", "h3")]
)

docs = []
for row in rows:
    md = row.get("markdown") or ""
    if not md.strip():
        continue
    parts = splitter.split_text(md)
    for p in parts:
        p.metadata["source"] = row.get("url")
        docs.append(p)

print(f"{len(docs)} chunks from {len(rows)} pages")
print(docs[0].metadata, docs[0].page_content[:240])
</code></pre>
<p>From here, push <code>docs</code> into whatever vector store you already use (Chroma, Pinecone, pgvector). The important part was getting clean Markdown <em>before</em> embedding.</p>
<h2>Common failure modes</h2>
<table>
<thead>
<tr>
<th>Symptom</th>
<th>Likely cause</th>
<th>What to try</th>
</tr>
</thead>
<tbody><tr>
<td>Empty Markdown</td>
<td>JS-rendered page</td>
<td>Enable browser / auto rendering for that URL</td>
</tr>
<tr>
<td>Huge noisy Markdown</td>
<td>Main-content off</td>
<td>Turn on main-content-only; add remove selectors</td>
</tr>
<tr>
<td>Crawl never ends</td>
<td>Depth too high</td>
<td>Set <code>maxCrawlDepth</code> to 0–1 and <code>maxPages</code> low</td>
</tr>
<tr>
<td>Many 403/429</td>
<td>Bot protection</td>
<td>Enable residential proxy; slow concurrency</td>
</tr>
</tbody></table>
<h2>When not to use a generic Markdown crawler</h2>
<ul>
<li>Pages behind login or paywalls.</li>
<li>Sites that forbid scraping in their terms — respect robots and the law where you operate.</li>
<li>Binary-heavy sites (raw PDFs only); use a document parser instead or in addition.</li>
</ul>
<h2>Cost sanity check</h2>
<p>For this Actor, five ordinary HTTP pages cost on the order of one US cent of page events plus the small Actor-start fee. That is enough to validate a pipeline. Scale the page cap only after you like the Markdown quality.</p>
<h2>Wrap-up</h2>
<ol>
<li>Start with a handful of public URLs.  </li>
<li>Export Markdown with main content only.  </li>
<li>Split on headings, attach the source URL, then embed.</li>
</ol>
<p>The same pattern works with other crawlers; the Store listing used above is one HTTP-first option aimed at LLM and RAG use: <a href="https://apify.com/ryumoon-data/website-to-markdown-rag">apify.com/ryumoon-data/website-to-markdown-rag</a>.</p>
<hr />
<p><em>Disclosure: I built the Apify Actor used in the worked example (<a href="https://apify.com/ryumoon-data/website-to-markdown-rag">Website to Markdown for LLM &amp; RAG</a>). The steps below still work as a pattern if you swap in another Markdown crawler.</em></p>
]]></content:encoded></item><item><title><![CDATA[How to get YouTube transcripts in bulk (with timestamps)]]></title><description><![CDATA[YouTube’s official Data API only returns captions for videos you own. For research, SEO content gaps, accessibility drafts or meeting notes from public…]]></description><link>https://ryumoondata.hashnode.dev/how-to-get-youtube-transcripts-in-bulk-with-timestamps</link><guid isPermaLink="true">https://ryumoondata.hashnode.dev/how-to-get-youtube-transcripts-in-bulk-with-timestamps</guid><category><![CDATA[youtube]]></category><category><![CDATA[web scraping]]></category><category><![CDATA[Python]]></category><category><![CDATA[data]]></category><dc:creator><![CDATA[RyuMoon Data]]></dc:creator><pubDate>Fri, 06 Nov 2026 22:34:09 GMT</pubDate><content:encoded><![CDATA[<p>YouTube’s official Data API only returns captions for videos <strong>you own</strong>. For research, SEO content gaps, accessibility drafts or meeting notes from public talks, people usually need the captions YouTube already shows on the watch page — at bulk scale, without copying and pasting.</p>
<p>This post walks through a reliable bulk path: collect video URLs, pull captions with language fallback, keep timestamps when you need them, and skip videos that have no captions so you do not pay for empty rows.</p>
<h2>What you will get</h2>
<p>For each public video that has captions:</p>
<ul>
<li>Full plain-text transcript.</li>
<li>Optional timestamped segments (<code>start</code> / <code>end</code> / <code>text</code>).</li>
<li>Language actually used (handy when you ask for English but only Spanish exists).</li>
<li>A clear miss when the video is private, deleted, or caption-free.</li>
</ul>
<p>You will <strong>not</strong> get a speech-to-text engine for silent uploads. No captions means no transcript from this method.</p>
<h2>Step 1 — Collect video URLs or IDs</h2>
<p>Start small (3–10 videos). Mix:</p>
<ul>
<li>Single watch URLs: <code>https://www.youtube.com/watch?v=…</code></li>
<li>Short links: <code>https://youtu.be/…</code></li>
<li>Later: a channel or playlist URL if your tool supports expanding them.</li>
</ul>
<p>Example seed (3Blue1Brown neural-network intro — public, has captions):</p>
<pre><code>https://www.youtube.com/watch?v=aircAruvnKk
</code></pre>
<h2>Step 2 — Run a bulk transcript job</h2>
<h3>Console</h3>
<ol>
<li>Open <a href="https://apify.com/ryumoon-data/youtube-transcript-scraper">YouTube Transcript Scraper – Captions, Timestamps, Bulk</a>.</li>
<li>Paste video URLs into <strong>Video URLs or IDs</strong>.</li>
<li>Set preferred languages to <code>en</code> (or your list).</li>
<li>Turn <strong>Include timestamped segments</strong> on if you need SRT-like timing.</li>
<li>Start the run and open the dataset.</li>
</ol>
<p>Pricing on this Actor is pay-per-event: about <strong>$3 per 1,000 successful transcripts</strong>. Videos without captions are free (you still pay the small Actor-start fee).</p>
<h3>Python</h3>
<pre><code class="language-bash">pip install apify-client
</code></pre>
<pre><code class="language-python">import os
from apify_client import ApifyClient

client = ApifyClient(os.environ["APIFY_TOKEN"])

run = client.actor("ryumoon-data/youtube-transcript-scraper").call(
    run_input={
        "videoUrls": [
            "https://www.youtube.com/watch?v=aircAruvnKk",
            "https://www.youtube.com/watch?v=arj7oStGLkU",
        ],
        "languages": ["en"],
        "preferManualCaptions": True,
        "allowAutoGenerated": True,
        "fallbackToAnyLanguage": True,
        "includeSegments": True,
        "maxVideosTotal": 10,
    }
)

for row in client.dataset(run["defaultDatasetId"]).iterate_items():
    text = row.get("transcript") or row.get("text") or ""
    print(
        row.get("url") or row.get("videoUrl"),
        "| lang:", row.get("language"),
        "| chars:", len(text),
        "| segments:", len(row.get("segments") or []),
    )
</code></pre>
<p>Input field names follow the live schema (<code>videoUrls</code>, <code>languages</code>, <code>includeSegments</code>, <code>channelOrPlaylistUrls</code>, <code>maxVideosPerChannel</code>). Always re-check the Store input tab if a run rejects a key.</p>
<h2>Step 3 — Read a sample output</h2>
<p>Trimmed success shape:</p>
<pre><code class="language-json">{
  "url": "https://www.youtube.com/watch?v=aircAruvnKk",
  "title": "But what is a neural network? | Deep Learning Chapter 1",
  "language": "en",
  "transcript": "This is a 3. It's sloppily written and falling apart…",
  "segments": [
    { "start": 4.2, "end": 5.4, "text": "This is a 3." },
    { "start": 5.4, "end": 8.1, "text": "It's sloppily written and falling apart…" }
  ]
}
</code></pre>
<p>If a row has empty text, treat it as a miss: private video, region block, or no captions. Do not retry those in a tight loop — you will just burn start fees.</p>
<h2>Step 4 — Useful follow-ons</h2>
<p><strong>Search inside a channel’s transcripts</strong></p>
<ol>
<li>Expand a channel URL with <code>channelOrPlaylistUrls</code> and a low <code>maxVideosPerChannel</code> (for example 20).  </li>
<li>Concatenate transcripts.  </li>
<li>Grep for product names or objections.</li>
</ol>
<p><strong>Rough chapter markers</strong></p>
<p>Group consecutive segments into 30–60 second windows and print the first line of each window. Good enough for internal notes; not a substitute for creator chapters.</p>
<p><strong>Content-gap research</strong></p>
<p>Pull transcripts from several competitor videos, count frequent phrases, and compare to your own outline. Pair with Google Trends later if you care about rising queries.</p>
<h2>Why bulk jobs fail on a laptop</h2>
<p>The popular <code>youtube-transcript-api</code> library works from home, then dies on cloud IPs with <code>IpBlocked</code> / <code>RequestBlocked</code>. YouTube rate-limits caption endpoints. Hosted runners that already rotate residential proxies exist so you do not have to maintain a proxy pool yourself. If you self-host, budget for residential IPs and polite concurrency (a handful of parallel videos, not hundreds).</p>
<h2>Limits to accept up front</h2>
<ul>
<li>Members-only, private and deleted videos cannot be read.</li>
<li>Auto-captions can mis-hear names and numbers — spot-check anything customer-facing.</li>
<li>Some live or brand-new uploads have no captions yet; wait and retry once.</li>
</ul>
<h2>Cost example</h2>
<p>Ten successful English transcripts ≈ $0.03 in transcript events plus one Actor-start ($0.005 at 1 GB). That is cheaper than most Store peers that charge around $5–$10 per 1,000 items — verify competitor prices on their pages before you rely on the comparison.</p>
<h2>Wrap-up</h2>
<ol>
<li>Gather public video URLs.  </li>
<li>Request preferred languages with fallback.  </li>
<li>Keep timestamps only when you need them.  </li>
<li>Ignore caption-free rows instead of hammering retries.</li>
</ol>
<p>Actor used in the examples: <a href="https://apify.com/ryumoon-data/youtube-transcript-scraper">apify.com/ryumoon-data/youtube-transcript-scraper</a>.</p>
<hr />
<p><em>Disclosure: I built the Apify Actor used in the worked example (<a href="https://apify.com/ryumoon-data/youtube-transcript-scraper">YouTube Transcript Scraper</a>). Treat this as a tutorial that happens to use that tool; the checklist applies to any caption exporter.</em></p>
]]></content:encoded></item></channel></rss>