<?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[Raspberry Rondel]]></title><description><![CDATA[Raspberry Rondel]]></description><link>https://hsinghsien-chen.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Tue, 27 Oct 2026 04:02:01 GMT</lastBuildDate><atom:link href="https://hsinghsien-chen.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Data Plumbing #1: Compare 50 Google Trends keywords on one scale]]></title><description><![CDATA[Google Trends compares up to 8 terms. Here's how to put 50 keywords on one 0–100 scale with an anchor, and how much each method gets wrong.]]></description><link>https://hsinghsien-chen.hashnode.dev/data-plumbing-01-google-trends-50-keywords-one-scale</link><guid isPermaLink="true">https://hsinghsien-chen.hashnode.dev/data-plumbing-01-google-trends-50-keywords-one-scale</guid><category><![CDATA[Python]]></category><category><![CDATA[SEO]]></category><category><![CDATA[Data Science]]></category><category><![CDATA[google trends]]></category><dc:creator><![CDATA[Raspberry Rondel]]></dc:creator><pubDate>Mon, 26 Oct 2026 02:14:25 GMT</pubDate><content:encoded><![CDATA[<p>Hi, this is <strong>Data Plumbing Weekly</strong>: one practical data/automation problem per week, with code you can run.</p>
<h2>This week's problem</h2>
<p>You have 50 keywords and you want one chart: which ones are big, which are small, and how they move over a year. Google Trends won't give you that in one go. The Explore page compares up to <strong>8</strong> terms at a time (<a href="https://support.google.com/trends/answer/4359550">Google Trends Help</a>; Google's own <a href="https://developers.google.com/search/apis/trends">Trends API page</a> says "the Trends UI only lets you make comparisons between eight terms"). The classic Explore page allowed 5.</p>
<p>So you split the list into 7 requests and paste the results together. The numbers look fine. They are not. Google scales every request on its own 0–100 range (<a href="https://support.google.com/trends/answer/4365533">FAQ</a>): the biggest point <em>in that request</em> becomes 100, and everything is rounded to whole numbers. A "40" from request A and a "40" from request B are on different scales.</p>
<p>This issue measures how wrong that gets with 50 keywords, and which fix holds up.</p>
<p>If you want the basic anchor method with a short walkthrough first, we covered it in <a href="https://hsinghsien-chen.hashnode.dev/compare-long-keyword-list-google-trends-one-scale">Compare a long keyword list in Google Trends on one scale</a>. This issue goes further: the 8-term limit, a 50-keyword simulation, and which anchor choice loses keywords.</p>
<h2>The fix (step by step)</h2>
<ol>
<li><strong>Put one shared term, the anchor, in every request.</strong> Its real interest is the same in every request, so any change in its numbers comes from the scaling. That gives you a conversion factor per request: <code>factor = sum(anchor in reference request) / sum(anchor in this request)</code>.</li>
<li><strong>Fill each request up to the limit</strong>: anchor + 7 keywords = 8 terms, so 50 keywords take 7 requests. Keep geo, time range and category identical.</li>
<li><strong>Multiply each request by its factor, then rescale so the overall peak is 100.</strong></li>
<li><strong>Pick the anchor from the middle of your list, not the top.</strong> If you don't know popularity yet, do two passes: pass 1 with any anchor, then sort keywords by the pass-1 estimate, pick the middle one as anchor, and fetch again. Sorting puts the giants in one request and the tiny keywords in another, so a tiny keyword never shares a request with a giant that rounds it down to 0.</li>
<li><strong>Flag, don't guess.</strong> Warn when the anchor's mean in a request is below 5 (rounding error grows), and skip any request where the anchor is all zeros or the dates don't line up.</li>
</ol>
<h3>The code</h3>
<p>No network calls: the bottom half generates <strong>synthetic</strong> data (not real Google Trends values) where we know the truth, then imitates a Trends request (own peak = 100, rounded to integers). That lets us measure each strategy's error exactly. <code>rescale_groups</code> is the part you'd reuse on real data: one DataFrame per request, indexed by date.</p>
<pre><code class="language-python"># issue01.py - Data Plumbing Weekly #1. Needs: pip install pandas numpy
import numpy as np
import pandas as pd

LOW_ANCHOR_MEAN = 5.0  # below this, integer rounding error gets amplified


def make_groups(keywords, anchor, terms_per_request=8):
    """Anchor + up to (terms_per_request - 1) other keywords per request."""
    others = [k for k in keywords if k != anchor]
    size = terms_per_request - 1
    return [[anchor] + others[i:i + size] for i in range(0, len(others), size)]


def rescale_groups(groups, anchor):
    """One DataFrame per request (index=dates, columns=terms, 0-100 ints).
    Returns (combined on one 0-100 scale, factors, warnings)."""
    warnings, factors, scaled = [], {}, []
    ref_idx = next((i for i, g in enumerate(groups) if g[anchor].sum() &gt; 0), None)
    if ref_idx is None:
        raise ValueError("anchor is zero in every group; cannot rescale")
    ref = groups[ref_idx]
    for i, g in enumerate(groups):
        if not g.index.equals(ref.index):
            warnings.append(f"group {i}: date axis differs; skipped")
            continue
        s = float(g[anchor].sum())
        if s == 0:
            warnings.append(f"group {i}: anchor all zeros; skipped")
            continue
        if s / len(g) &lt; LOW_ANCHOR_MEAN:
            warnings.append(f"group {i}: anchor mean {s / len(g):.2f} &lt; 5")
        factors[i] = float(ref[anchor].sum()) / s
        cols = g.columns if i == ref_idx else g.columns.drop(anchor)
        scaled.append(g[cols] * factors[i])
    combined = pd.concat(scaled, axis=1)
    peak = float(combined.to_numpy().max())
    return (combined * 100 / peak).round(2), factors, warnings


# ---------- simulation: synthetic data, NOT real Google Trends values ----------
def make_truth(seed, n_kw=50, n_weeks=52):
    rng = np.random.default_rng(seed)
    t = np.arange(n_weeks)
    level = np.exp(rng.normal(np.log(200), 1.3, n_kw))  # ~3 orders of magnitude
    data = {
        f"kw{k:02d}": level[k]
        * (1 + 0.25 * np.sin(2 * np.pi * (t / 52 + rng.uniform())))
        * (1 + rng.uniform(-0.3, 0.3) * t / n_weeks)
        * rng.lognormal(0, 0.08, n_weeks)
        for k in range(n_kw)
    }
    return pd.DataFrame(data, index=pd.date_range("2025-10-19", periods=n_weeks, freq="W"))


def fake_trends(truth, terms):
    """Model of one request: its own peak = 100, integers."""
    block = truth[terms]
    return (block / block.to_numpy().max() * 100).round().astype(int)


def anchored(truth, anchor, keywords=None):
    groups = make_groups(keywords or list(truth.columns), anchor)
    combined, _, warnings = rescale_groups([fake_trends(truth, g) for g in groups], anchor)
    return combined, len(groups)


def naive(truth, per_request=8):
    kws = list(truth.columns)
    chunks = [kws[i:i + per_request] for i in range(0, len(kws), per_request)]
    return pd.concat([fake_trends(truth, c) for c in chunks], axis=1), len(chunks)


def score(est, truth):
    true100 = truth / truth.to_numpy().max() * 100
    rel = (est.mean() - true100.mean()).abs() / true100.mean()
    return rel, int((est.sum() == 0).sum())


rows = []
for seed in range(1, 21):
    truth = make_truth(seed)
    by_size = truth.mean().sort_values()
    first, median, biggest = truth.columns[0], by_size.index[25], by_size.index[-1]
    pass1, n1 = anchored(truth, first)
    order = pass1.mean().sort_values(ascending=False).index.tolist()
    runs = {
        "naive concat": naive(truth),
        "anchor = biggest": anchored(truth, biggest),
        "anchor = first (arbitrary)": (pass1, n1),
        "anchor = median": anchored(truth, median),
        "two-pass sorted": (anchored(truth, order[25], order)[0], 2 * n1),
    }
    for name, (est, n) in runs.items():
        rel, lost = score(est, truth)
        rows.append({"strategy": name, "requests": n, "median_err_%": rel.median() * 100,
                     "worst_err_%": rel.max() * 100, "kw_err_&gt;10%": (rel &gt; 0.10).sum(),
                     "kw_lost": lost})

summary = pd.DataFrame(rows).groupby("strategy", sort=False).agg(
    requests=("requests", "first"),
    median_err=("median_err_%", "median"),
    worst_err=("worst_err_%", "max"),
    kw_over_10pct=("kw_err_&gt;10%", "mean"),
    kw_lost=("kw_lost", "mean"),
)
print("20 synthetic lists x 50 keywords x 52 weeks, 8 terms per request")
print(summary.round(2).to_string())
</code></pre>
<p>Output (Python 3.13, pandas 3.0.6, run before publishing):</p>
<pre><code class="language-text">20 synthetic lists x 50 keywords x 52 weeks, 8 terms per request
                            requests  median_err  worst_err  kw_over_10pct  kw_lost
strategy                                                                           
naive concat                       7      178.74   12137.39          42.85     0.95
anchor = biggest                   7        0.64     100.00           6.70     2.05
anchor = first (arbitrary)         7        0.88     100.00           8.70     0.90
anchor = median                    7        0.56     100.00           4.25     0.80
two-pass sorted                   14        0.56      43.37           2.25     0.00
</code></pre>
<p>How to read it: errors are on each keyword's average level, in percent. <code>kw_over_10pct</code> and <code>kw_lost</code> (keywords that came out as all zeros) are averages per list of 50.</p>
<ul>
<li><strong>Naive concatenation is useless for comparison</strong>: about 43 of 50 keywords end up more than 10% off, typically by about 180%.</li>
<li><strong>Any anchor fixes the typical keyword</strong> (median error under 1%). The differences are in the tail: small keywords that share a request with a big one get rounded to 0 and are lost (worst error 100%).</li>
<li><strong>Using your biggest keyword as the anchor is the worst anchored option</strong> (most keywords lost, about 2 per list). It's also the most tempting choice.</li>
<li><strong>Two-pass sorted</strong> lost no keywords in any of the 20 lists and had the fewest keywords off by more than 10%, at the cost of twice the requests (14 instead of 7).</li>
</ul>
<h2>3 quick tips</h2>
<ul>
<li><strong>Drop the partial last point</strong> before comparing. In the data the Trends site returns, the newest period is often flagged <code>isPartial</code> (incomplete), and its value can change on a later fetch.</li>
<li><strong>Fetch all requests close together in time.</strong> Trends is computed from a sample, and Google adds privacy noise that is most noticeable on low-interest queries (<a href="https://support.google.com/trends/answer/4365533">FAQ</a>). Two fetches of the same term at different times can differ.</li>
<li><strong>0 can mean "too small", not "no searches."</strong> Google says low-volume searches appear as 0 (<a href="https://support.google.com/trends/answer/4365533">FAQ</a>). Don't build "keyword X has zero demand" conclusions on it.</li>
</ul>
<h2>What we tested (and what didn't work)</h2>
<ul>
<li><strong>Tested:</strong> the code above, on the box, before publishing; plus five plain-assert tests (rescaling recovers the true scale exactly when there is no rounding; groups never exceed the term limit; zero-anchor and mismatched-date groups are skipped, not guessed; the global peak is 100). All passed.</li>
<li><strong>Not tested here:</strong> real Google Trends data. The simulation models only two documented behaviours (per-request 0–100 scaling and integer rounding). It leaves out sampling noise, privacy noise and low-volume filtering, so real-world errors will be larger than these numbers. Treat the table as "which strategy breaks first", not as an accuracy promise.</li>
<li><strong>What didn't work:</strong> an anchor from the bottom of the list (we tried the 6th smallest of 50 in an earlier run of the same simulation). Its integer values are so small that the rounding error goes into every factor: in that run, 43–49 of 50 keywords were more than 10% off.</li>
</ul>
<h2>Tool of the week (not ours)</h2>
<p>The official <strong><a href="https://developers.google.com/search/apis/trends">Google Trends API (alpha)</a></strong>. Google says it provides "consistently scaled data" and "makes it easier to compare dozens of terms", which removes the need for anchors. When we read the page on 2026-10-17, access was through an alpha-tester application only (<a href="https://developers.google.com/search/blog/2025/07/trends-api">announcement, July 2025</a>). If you get in, use it instead of stitching.</p>
<h2>From our workshop (disclosure: we built this and sell it)</h2>
<p>We built <strong><a href="https://apify.com/raspberry_rondel/actor-google-trends">Google Trends Bulk Compare</a></strong>, an Apify Actor that runs the single-anchor method for you: it splits your list into an anchor plus up to 4 keywords per request, rescales with sum-based factors, sets the global peak to 100, and saves the factors and low-anchor warnings with each run. It does <strong>not</strong> do the two-pass sorting above; choose a mid-popularity anchor yourself. Price: <strong>USD 0.003 per keyword that returns data</strong>; keywords that fail are free. We have tested it with up to 30 keywords per run, not 50, so treat a 50-keyword run as untested. You don't need it: the code above works with any fetcher.</p>
<hr />
<p><em>How this was made: drafted with AI assistance and checked by our team before publishing. The code was run before publishing, and the output above is copied from that run. All numbers come from synthetic data unless a source is linked.</em></p>
]]></content:encoded></item><item><title><![CDATA[How to Compare a Long Keyword List in Google Trends on One 0-100 Scale]]></title><description><![CDATA[Why separate Trends requests can't be compared, the anchor-term rescaling fix, a runnable pandas implementation, and the pitfalls.]]></description><link>https://hsinghsien-chen.hashnode.dev/compare-long-keyword-list-google-trends-one-scale</link><guid isPermaLink="true">https://hsinghsien-chen.hashnode.dev/compare-long-keyword-list-google-trends-one-scale</guid><category><![CDATA[google trends]]></category><category><![CDATA[Python]]></category><category><![CDATA[SEO]]></category><category><![CDATA[Data Science]]></category><dc:creator><![CDATA[Raspberry Rondel]]></dc:creator><pubDate>Sun, 18 Oct 2026 16:47:08 GMT</pubDate><content:encoded><![CDATA[<p>If you've ever tried to chart 12 keywords from Google Trends, you've hit the wall: one comparison only holds a handful of terms (the current Explore page allows up to 8; the older compare box allowed 5). The obvious workaround (run several smaller requests and paste the results together) gives you numbers that look right but mean nothing when you put them side by side.</p>
<p>This post explains why, and walks through the <strong>anchor-term rescaling</strong> method that fixes it, with a small pandas implementation you can run offline.</p>
<h2>Why separate requests aren't comparable</h2>
<p>Google Trends doesn't return search volume. It returns a <strong>relative index</strong>: within a single request, the highest point across <em>all terms in that request</em> becomes 100, and everything else is scaled against it and rounded to an integer.</p>
<p>So "60" in request A and "60" in request B are on different scales. If request A contains a giant term, everything else in A gets squashed toward zero. If request B contains only small terms, one of them gets stretched to 100. Concatenating the two is like merging two charts that use different y-axes without telling anyone.</p>
<h2>The fix: a shared anchor term</h2>
<p>The trick is to put the <strong>same term (the anchor)</strong> in every request. Because the anchor's real interest is the same no matter which request it's in, any difference in its numbers between requests comes from the different scaling. That gives you a conversion factor.</p>
<p>The procedure:</p>
<ol>
<li><p>Pick an anchor term.</p>
</li>
<li><p>Split your other keywords into groups small enough that each group plus the anchor fits in one request (groups of 4 plus the anchor, 5 terms per request, work under both the old and the new limit).</p>
</li>
<li><p>Fetch each group with <strong>identical</strong> geo, timeframe, and category.</p>
</li>
<li><p>Pick a reference group (e.g. the first one where the anchor isn't all zeros).</p>
</li>
<li><p>For each group <code>g</code>, compute a factor:</p>
<pre><code>factor_g = sum(anchor in reference group) / sum(anchor in group g)
</code></pre>
</li>
<li><p>Multiply every series in group <code>g</code> by <code>factor_g</code>. Report the anchor once, from the reference group.</p>
</li>
<li><p><strong>Renormalize globally</strong>: divide everything by the overall max and multiply by 100, so the biggest point across all keywords is 100 again.</p>
</li>
</ol>
<h3>Why sums instead of per-point ratios?</h3>
<p>You could compute a ratio for each date and average them, but each data point is an integer from 0 to 100. A point where the anchor reads <code>2</code> could really be anywhere from 1.5 to 2.5. That's ±25% on one point. Summing the whole series first and dividing once spreads that rounding error across the full timeline. Points where the anchor reads <code>0</code> would also break a per-point ratio (division by zero), but they're harmless in a sum.</p>
<h2>A runnable implementation in pandas</h2>
<p>The code below has no network calls. The DataFrames are <strong>made-up example data</strong> so you can see the math work. In real use you'd fill them with whatever your fetcher returns (one DataFrame per request, indexed by date).</p>
<pre><code class="language-python">import pandas as pd

LOW_ANCHOR_MEAN = 5  # below this, integer rounding error gets amplified


def rescale_groups(groups: list[pd.DataFrame], anchor: str):
    """Put several Google Trends requests (each with its own 0-100 scale)
    onto one shared 0-100 scale using an anchor term present in every group.

    Returns (combined DataFrame, factors dict, warnings list).
    """
    warnings, factors, scaled = [], {}, []

    # Reference = first group where the anchor has any signal at all.
    ref_idx = next((i for i, g in enumerate(groups) if g[anchor].sum() &gt; 0), None)
    if ref_idx is None:
        raise ValueError("anchor is zero in every group; cannot rescale")
    ref = groups[ref_idx]
    ref_sum = ref[anchor].sum()

    for i, g in enumerate(groups):
        # Groups must share the exact same date axis to be comparable.
        if not g.index.equals(ref.index):
            warnings.append(f"group {i}: date axis differs from reference; skipped")
            continue
        s = g[anchor].sum()
        if s == 0:
            warnings.append(f"group {i}: anchor is all zeros; skipped")
            continue
        mean = s / len(g)
        if mean &lt; LOW_ANCHOR_MEAN:
            warnings.append(
                f"group {i}: anchor mean {mean:.2f} &lt; {LOW_ANCHOR_MEAN}; "
                "rounding error is amplified, consider a more popular anchor"
            )
        factors[i] = float(ref_sum / s)
        cols = g.columns if i == ref_idx else g.columns.drop(anchor)
        scaled.append(g[cols] * factors[i])

    combined = pd.concat(scaled, axis=1)
    peak = combined.to_numpy().max()
    combined = (combined * (100 / peak if peak &gt; 0 else 0)).round(2)
    return combined, factors, warnings


# ---- EXAMPLE DATA (made up for illustration, not real Google Trends values) ----
dates = pd.date_range("2026-01-04", periods=6, freq="W")

# Request 1: anchor + two big terms. "term_a" sets this request's 100.
group0 = pd.DataFrame(
    {"anchor": [20, 22, 21, 19, 23, 20],
     "term_a": [80, 90, 100, 85, 95, 88],
     "term_b": [40, 42, 45, 41, 39, 44]},
    index=dates,
)

# Request 2: anchor + two small terms. With no giant term here,
# the anchor itself is stretched toward 100.
group1 = pd.DataFrame(
    {"anchor": [87, 96, 91, 83, 100, 87],
     "term_c": [30, 35, 33, 31, 36, 34],
     "term_d": [10, 12, 11, 9, 13, 10]},
    index=dates,
)

combined, factors, warnings = rescale_groups([group0, group1], anchor="anchor")

print("factors:", {k: round(v, 4) for k, v in factors.items()})
print("warnings:", warnings or "none")
print(combined)
</code></pre>
<p>Running it (Python 3, pandas) prints:</p>
<pre><code>factors: {0: 1.0, 1: 0.2298}
warnings: none
            anchor  term_a  term_b  term_c  term_d
2026-01-04    20.0    80.0    40.0    6.89    2.30
2026-01-11    22.0    90.0    42.0    8.04    2.76
2026-01-18    21.0   100.0    45.0    7.58    2.53
2026-01-25    19.0    85.0    41.0    7.12    2.07
2026-02-01    23.0    95.0    39.0    8.27    2.99
2026-02-08    20.0    88.0    44.0    7.81    2.30
</code></pre>
<p>In raw request 2, <code>term_c</code> peaked at 36 and looked like it might be close to <code>term_b</code>. Once it's on the shared scale you can see it's really around 7-8, a small fraction of <code>term_b</code>. That's exactly the kind of wrong conclusion that naive concatenation produces.</p>
<p>Note the output has decimals. That's expected: rescaled values are estimates, not Google's native integers.</p>
<h2>Pitfalls (the part that actually bites)</h2>
<h3>1. An anchor with too little volume</h3>
<p>This is the big one. If your anchor averages, say, 3 in a group, each point's ±0.5 rounding is a huge relative error, and the factor inherits it. Every keyword in that group is then multiplied by a noisy factor. If the anchor rounds to <strong>0</strong> across a whole group, you can't rescale that group at all.</p>
<p>In the implementation I base this on, a group whose anchor mean is below 5 triggers a warning, and a group whose anchor is all zeros is marked as failed instead of guessed. I'd recommend both behaviors: flag it, don't invent numbers.</p>
<h3>2. Choosing the anchor</h3>
<p>A good anchor is:</p>
<ul>
<li><strong>Steady</strong>: no huge spikes, so it isn't the 100 in some groups and tiny in others for reasons unrelated to scaling.</li>
<li><strong>Fairly popular</strong> in your geo and timeframe, so it stays well above zero in every group.</li>
<li><strong>Not dwarfed</strong> by your biggest keyword. If one keyword is 50× the anchor, the anchor gets crushed toward 0 in that keyword's group.</li>
</ul>
<p>A practical check: after a run, look at the anchor's mean per group and at the factors. Factors far from 1 combined with a low anchor mean are where the error lives.</p>
<h3>3. Rounding error is real, even when everything is done right</h3>
<p>Rescaling can't recover precision that Google rounded away. Low-volume keywords in a group with a large factor carry roughly ±0.5 × factor of uncertainty per point. Treat small differences between small keywords with suspicion.</p>
<h3>4. Sampling noise</h3>
<p>Google Trends is computed from a sample of searches, and recent data gets revised (the latest point is often flagged as partial). Two requests made at different times can return slightly different values for the same term. Fetch all your groups <strong>in one session, close together in time</strong>, and consider dropping partial points before comparing.</p>
<h3>5. Timeframe, geo, and category must match exactly</h3>
<p>The anchor only links groups if every request uses the same geo, timeframe, and category. Watch out for the date axis too: if one group comes back with a different set of dates, don't try to line it up. Exclude it. The implementation above (and the one I based it on) requires an identical date axis and skips any group that doesn't match.</p>
<h3>6. Rate limits and HTTP 429</h3>
<p>If you're using <code>pytrends</code>, you will meet <code>TooManyRequestsError</code> / HTTP 429 sooner or later, especially in loops. What helps:</p>
<ul>
<li>Send requests <strong>one at a time</strong> with a delay between them (seconds, not milliseconds).</li>
<li>Retry only on 429 / 5xx / timeouts, with <strong>exponential backoff plus jitter</strong>, and honor <code>Retry-After</code> if present.</li>
<li>Don't retry a 400: that's a bad request and retrying won't fix it.</li>
<li>Cap the number of retries, and record a failed keyword as failed rather than silently dropping or zero-filling it.</li>
</ul>
<h3>7. Browser scrapers and UI changes</h3>
<p>Google has redesigned the Trends pages more than once. Any scraper that drives a browser and reads values out of the rendered page depends on that page's layout and selectors, so a redesign is exactly the kind of change that breaks it. The approach I've had better luck with is to skip the DOM entirely and use the JSON endpoints that the Trends UI itself calls (the same data the "download CSV" button gives you). These are still unofficial and can change without notice, so build in validation either way.</p>
<h2>A quick validation you should run</h2>
<p>Before you trust a pipeline, compare it to ground truth: pick 5 terms, fetch them in <strong>one</strong> direct request (Google's own scale), then fetch them again split into anchored groups and rescale. The difference between the two tells you how much error your anchor choice introduces. With a weak anchor you'll see small but non-zero errors on the low-volume terms. That's the rounding issue from pitfall 1, and it's the best way I know to pick an anchor with data instead of guessing.</p>
<h2>Wrap-up</h2>
<ul>
<li>Each Google Trends request is scaled to its own 0-100 peak, so separate requests can't be compared directly.</li>
<li>Put a shared anchor in every request, scale each group by <code>sum(anchor_ref) / sum(anchor_group)</code>, then renormalize so the global max is 100.</li>
<li>Most of the error comes from the anchor: choose a steady, popular one, warn when its mean is low, and never rescale a group where it's all zeros.</li>
<li>Keep geo, timeframe, category, and date axis identical across groups, fetch close together in time, and back off politely on 429s.</li>
</ul>
<h2>Disclosure: we built a hosted version of this</h2>
<p>We (Raspberry Rondel) make <a href="https://apify.com/raspberry_rondel/actor-google-trends">Google Trends Bulk Compare</a>, an Apify Actor that packages this method: groups of up to 4 keywords plus the anchor, sum-based factors and a global renormalisation, with low-anchor groups flagged and the factors and warnings saved with each run.</p>
<ul>
<li><strong>Pricing:</strong> USD 0.003 per keyword that returns data. Failed or empty keywords are free, and there is no start fee.</li>
<li><strong>Try it:</strong> the Store page opens with a prefilled compare run: 6 keywords (coffee, tea, matcha, bubble tea, espresso, latte; Taiwan, past 12 months) on one shared scale, for at most USD 0.018. Running it needs an Apify account.</li>
<li><strong>What we have tested:</strong> up to 30 keywords per run; we haven't tested beyond that. In our runs on the Apify platform, per-keyword mode took 12–14 s per keyword (30 keywords took about 7 minutes); compare mode sends fewer requests. In our internal test the values matched the Google Trends CSV-export endpoint for the same requests (424/424 points), fetched by our own runner. We haven't yet compared against a CSV downloaded by hand in a normal browser session.</li>
<li><strong>Calling it from code:</strong> for long lists, start the run with the Apify client's <code>.call()</code> (or an async run plus polling). Apify's synchronous "run and get dataset items" endpoint returns HTTP 408 once a run passes 300 s.</li>
</ul>
<p>You don't need the Actor to use the method. Everything above works with any fetcher and the pandas function in this post.</p>
<p><em>This article was written with AI assistance. The pandas example was run before publishing, and the output shown is copied from that run.</em></p>
]]></content:encoded></item><item><title><![CDATA[Full-Page Screenshots of Chinese, Japanese and Emoji Pages with Headless Chromium (No Tofu Boxes)]]></title><description><![CDATA[If you take website screenshots with headless Chromium for visual QA, monitoring or archiving, two problems show up the moment the pages aren't plain…]]></description><link>https://hsinghsien-chen.hashnode.dev/full-page-screenshots-of-chinese-japanese-and-emoji-pages-with-headless-chromium-no-tofu-boxes</link><guid isPermaLink="true">https://hsinghsien-chen.hashnode.dev/full-page-screenshots-of-chinese-japanese-and-emoji-pages-with-headless-chromium-no-tofu-boxes</guid><category><![CDATA[playwright]]></category><category><![CDATA[Python]]></category><category><![CDATA[Web Development]]></category><dc:creator><![CDATA[Raspberry Rondel]]></dc:creator><pubDate>Sun, 18 Oct 2026 16:26:27 GMT</pubDate><content:encoded><![CDATA[<p>If you take website screenshots with headless Chromium for visual QA, monitoring or archiving, two problems show up the moment the pages aren't plain English:</p>
<ol>
<li><strong>Tofu boxes.</strong> Chinese, Japanese, Korean and emoji come out as empty rectangles (□□□), because the server or container has no font for them.</li>
<li><strong>Half-empty full-page shots.</strong> The page is the right height, but images further down are grey placeholders and "load more" content never appears, because nothing scrolled the page.</li>
</ol>
<p>There's also a subtler third one: the text renders, but <strong>Traditional Chinese is drawn with the Japanese font</strong>, so some characters and punctuation have the wrong regional shape.</p>
<p>This post reproduces all three on a small local test page and fixes them with Playwright for Python. Everything below was run, and the output is copied from that run.</p>
<p><strong>Test setup:</strong> Linux, Python 3.13, Playwright for Python 1.64.0 with its bundled Chromium headless shell (156.0.8078.4), and the Debian/Ubuntu packages <code>fonts-noto-cjk</code> and <code>fonts-noto-color-emoji</code>.</p>
<h2>The test page</h2>
<p><code>site/index.html</code> has a title with an emoji, one paragraph each in Traditional Chinese, Simplified Chinese, Japanese and Korean, an emoji line (including a skin-tone modifier, a flag and a ZWJ family), and then:</p>
<ul>
<li>two images with native <code>loading="lazy"</code>,</li>
<li>two images that use the common JavaScript pattern (<code>data-src</code> swapped in by an <code>IntersectionObserver</code>),</li>
<li>an "infinite scroll" sentinel that appends a new section (with an image) three times as you reach the bottom.</li>
</ul>
<p>The <code>&lt;html&gt;</code> tag says <code>lang="zh-Hant"</code>, and the Simplified Chinese paragraph says <code>lang="zh-Hans"</code>. Some real sites use script subtags like these.</p>
<pre><code class="language-html">&lt;html lang="zh-Hant"&gt;
...
&lt;p id="zh-Hant"&gt;繁體中文：臺灣的「鐵路便當」很好吃。數字 0123456789，標點「」、。！&lt;/p&gt;
&lt;p id="zh-Hans" lang="zh-Hans"&gt;简体中文：这是一个用于测试网页截图的页面。&lt;/p&gt;
&lt;p id="ja" lang="ja"&gt;日本語：ウェブページのスクリーンショットをテストします。&lt;/p&gt;
&lt;p id="ko" lang="ko"&gt;한국어: 웹 페이지 스크린샷 테스트입니다.&lt;/p&gt;
&lt;p id="emoji"&gt;Emoji: 😀 🎉 👍🏽 🇹🇼 👨‍👩‍👧 ❤️&lt;/p&gt;
...
&lt;img class="js-lazy" data-src="img/3.svg" alt="js lazy image 3"&gt;
</code></pre>
<p>The full page, the images and a script that runs every case are in the companion folder (<code>site/</code>, <code>run_demo.sh</code>).</p>
<h2>The script</h2>
<p><code>shoot.py</code> does four things in this order:</p>
<ol>
<li><strong>Scrolls</strong> down in 600 px steps with a 400 ms pause, until the page height stops growing for two rounds at the bottom (or a time/height limit is hit), then scrolls back to the top so sticky headers sit where a visitor would see them.</li>
<li><strong>Waits</strong> for images that are still loading (with a timeout) and for <code>document.fonts.ready</code>.</li>
<li><strong>Reports fonts.</strong> It asks Chromium, through the DevTools Protocol method <code>CSS.getPlatformFontsForNode</code>, which installed font actually drew each element you list, and how many glyphs came from each.</li>
<li><strong>Captures</strong> with <code>full_page=True</code> and <code>animations="disabled"</code>.</li>
</ol>
<pre><code class="language-python">"""Full-page screenshot with headless Chromium (Playwright for Python).

- scrolls the page first so lazy-loaded images and infinite-scroll content load
- waits for images and web fonts before capturing
- reports which real font Chromium used for each element you name (via CDP),
  so you can catch tofu boxes before you look at the PNG

Usage:
  python shoot.py URL out.png [--no-scroll] [--locale zh-TW] [--fallback-lang zh_TW.UTF-8]
                  [--check "#id1,#id2"]
"""
import argparse
import os
import time

from playwright.sync_api import sync_playwright

SCROLL_JS = """
async ({ step, pauseMs, maxSecs, maxHeight }) =&gt; {
  const sleep = (ms) =&gt; new Promise((r) =&gt; setTimeout(r, ms));
  const height = () =&gt; Math.max(document.documentElement.scrollHeight,
                                document.body ? document.body.scrollHeight : 0);
  const start = Date.now();
  let y = 0, lastHeight = -1, stableRounds = 0;
  while (Date.now() - start &lt; maxSecs * 1000) {
    y = Math.min(y + step, height());
    window.scrollTo(0, y);
    await sleep(pauseMs);
    const h = height();
    if (y + window.innerHeight &gt;= h) {          // reached the bottom
      stableRounds = (h === lastHeight) ? stableRounds + 1 : 0;
      if (stableRounds &gt;= 2 || h &gt;= maxHeight) break;   // nothing new loaded
    }
    lastHeight = h;
  }
  window.scrollTo(0, 0);                         // sticky headers back in place
  return { height: height(), seconds: (Date.now() - start) / 1000 };
}
"""

WAIT_ASSETS_JS = """
async (timeoutMs) =&gt; {
  const imgs = [...document.images].filter((i) =&gt; !i.complete);
  const each = imgs.map((i) =&gt; new Promise((r) =&gt; { i.onload = i.onerror = r; }));
  await Promise.race([Promise.all(each), new Promise((r) =&gt; setTimeout(r, timeoutMs))]);
  if (document.fonts) await document.fonts.ready;
}
"""

IMG_STATS_JS = """
() =&gt; {
  const imgs = [...document.images];
  const loaded = imgs.filter((i) =&gt; i.complete &amp;&amp; i.naturalWidth &gt; 0).length;
  return { images: imgs.length, loaded };
}
"""


def platform_fonts(page, selectors):
    """Ask Chromium which installed fonts it actually used to draw each element."""
    cdp = page.context.new_cdp_session(page)
    cdp.send("DOM.enable")
    cdp.send("CSS.enable")
    root = cdp.send("DOM.getDocument")["root"]["nodeId"]
    report = {}
    for sel in selectors:
        node = cdp.send("DOM.querySelector", {"nodeId": root, "selector": sel})["nodeId"]
        if not node:
            report[sel] = "not found"
            continue
        res = cdp.send("CSS.getPlatformFontsForNode", {"nodeId": node})
        fonts = [f"{f['familyName']} ({f['glyphCount']})" for f in res["fonts"]]
        report[sel] = ", ".join(fonts) or "(no text)"
    return report


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("url")
    ap.add_argument("out")
    ap.add_argument("--no-scroll", action="store_true")
    ap.add_argument("--locale", default="zh-TW")
    ap.add_argument("--width", type=int, default=1280)
    ap.add_argument("--height", type=int, default=800)
    ap.add_argument("--max-height", type=int, default=20000)
    ap.add_argument("--fallback-lang", default="",
                    help="LANG for the browser process, e.g. zh_TW.UTF-8; picks the CJK "
                         "fallback face for text that has no lang= of its own")
    ap.add_argument("--check", default="", help="comma-separated CSS selectors to font-check")
    args = ap.parse_args()

    with sync_playwright() as p:
        env = {**os.environ, "LANG": args.fallback_lang} if args.fallback_lang else None
        browser = p.chromium.launch(env=env)
        context = browser.new_context(
            viewport={"width": args.width, "height": args.height},
            locale=args.locale,          # navigator.language / Accept-Language (not the fallback font)
            device_scale_factor=1,
        )
        page = context.new_page()
        t0 = time.time()
        resp = page.goto(args.url, wait_until="load", timeout=60_000)
        print("status:", resp.status if resp else None)

        if not args.no_scroll:
            info = page.evaluate(SCROLL_JS, {"step": 600, "pauseMs": 400,
                                             "maxSecs": 15, "maxHeight": args.max_height})
            print(f"scrolled: height={info['height']}px in {info['seconds']:.1f}s")
        page.evaluate(WAIT_ASSETS_JS, 10_000)
        print("images:", page.evaluate(IMG_STATS_JS))

        if args.check:
            for sel, fonts in platform_fonts(page, args.check.split(",")).items():
                print(f"font {sel}: {fonts}")

        page.screenshot(path=args.out, full_page=True, animations="disabled", timeout=120_000)
        print(f"saved {args.out} in {time.time() - t0:.1f}s")
        browser.close()


if __name__ == "__main__":
    main()
</code></pre>
<p>Install and run:</p>
<pre><code class="language-bash">python3 -m venv .venv &amp;&amp; . .venv/bin/activate
pip install playwright==1.64.0
playwright install chromium
python shoot.py https://example.com/ out.png --check "h1,p"
</code></pre>
<h2>Problem 1: tofu boxes in a slim container</h2>
<p>To simulate a slim image without having to build one, the demo gives Chromium a private <code>fonts.conf</code> that only lists one Latin font (DejaVu Sans), via the <code>FONTCONFIG_FILE</code> environment variable:</p>
<pre><code class="language-bash">FONTCONFIG_FILE=$PWD/slim-fonts/fonts.conf \
  python shoot.py "$URL" a-slim-noscroll.png --no-scroll \
  --check "#title,#zh-Hant,#zh-Hans,#ja,#ko,#emoji"
</code></pre>
<pre><code class="language-text">== A: slim fonts, no scrolling ==
status: 200
images: {'images': 4, 'loaded': 2}
font #title: DejaVu Sans (15)
font #zh-Hant: DejaVu Sans (39)
font #zh-Hans: DejaVu Sans (21)
font #ja: DejaVu Sans (28)
font #ko: DejaVu Sans (23)
font #emoji: DejaVu Sans (25)
saved a-slim-noscroll.png in 0.2s
</code></pre>
<p>In the screenshot every CJK character is a box, and so is most of the emoji line. The two emoji DejaVu Sans happens to cover (😀 and ❤) come out as black-and-white outlines instead of colour emoji. Note what the font report says: the glyphs are still "counted" against DejaVu Sans, which has no CJK glyphs at all. So the check isn't "is there a font?", it's <strong>"is CJK text being drawn by a font that can't contain CJK?"</strong> If your Chinese paragraph reports only a Latin family, you have tofu, and you can fail the job before anyone looks at the PNG.</p>
<p><strong>Fix:</strong> install CJK and emoji fonts in the image and rebuild the font cache.</p>
<pre><code class="language-dockerfile">RUN apt-get update &amp;&amp; apt-get install -y --no-install-recommends \
      fonts-noto-cjk fonts-noto-color-emoji \
    &amp;&amp; fc-cache -f &amp;&amp; rm -rf /var/lib/apt/lists/*
</code></pre>
<p>With the normal system fonts:</p>
<pre><code class="language-text">== B: CJK + emoji fonts, no scrolling ==
status: 200
images: {'images': 4, 'loaded': 2}
font #title: Arimo (1), Noto Sans CJK JP (13), Noto Color Emoji (1)
font #zh-Hant: Arimo (11), Noto Sans CJK JP (28)
font #zh-Hans: Noto Sans CJK JP (21)
font #ja: Noto Sans CJK JP (28)
font #ko: Arimo (6), NanumGothic (17)
font #emoji: Arimo (12), Noto Color Emoji (6)
saved b-fonts-noscroll.png in 0.3s
</code></pre>
<p>No more boxes, and the emoji (including 👍🏽, 🇹🇼 and the ZWJ family) come from Noto Color Emoji. But look at the Chinese lines: both the Traditional and the Simplified paragraph were drawn with <strong>Noto Sans CJK JP</strong>. That's problem 3, which we'll come back to. First, the missing images.</p>
<h2>Problem 2: lazy-loaded content is missing</h2>
<p>Run B didn't scroll. The screenshot is 3,207 px tall: the two native lazy images near the top loaded, the two <code>data-src</code> images are grey boxes showing their alt text, and none of the three "infinite scroll" sections exist. <code>full_page=True</code> captures the whole document, but it doesn't scroll the way a visitor does, so <code>IntersectionObserver</code> callbacks for content further down never fire.</p>
<p>With the scroll step:</p>
<pre><code class="language-text">== C: CJK + emoji fonts, scroll first ==
status: 200
scrolled: height=5358px in 3.6s
images: {'images': 7, 'loaded': 7}
...
saved c-fonts-scroll.png in 4.0s
</code></pre>
<p>All 7 images loaded, and the page grew from 3,207 to 5,358 px as the three batches were appended. The loop doesn't stop at the first time it reaches the bottom: it waits until the height has stayed the same for two rounds, because infinite-scroll pages add content a moment <em>after</em> you arrive (the test page waits 300 ms).</p>
<p>Things that matter on real pages:</p>
<ul>
<li><strong>Always cap it.</strong> Some feeds never end. The script stops after 15 seconds or 20,000 px, whichever comes first. Pick limits that fit your pages.</li>
<li><strong>Scroll back to the top</strong> before capturing, or sticky headers and "back to top" buttons end up in odd places.</li>
<li><strong><code>networkidle</code> is not a substitute.</strong> Lazy content only starts loading after the scroll, so an idle network before scrolling tells you nothing about it.</li>
<li><strong>Disable animations</strong> (<code>animations="disabled"</code>), so carousels and fade-ins are captured in a stable state.</li>
</ul>
<h2>Problem 3: Chinese drawn with the Japanese font</h2>
<p>Chinese, Japanese and Korean share a lot of code points (Han unification), but each region has its own preferred glyph shapes, and punctuation is placed differently: in Traditional Chinese fonts <code>，</code> and <code>。</code> usually sit in the centre of the square, while Japanese fonts put them in the lower left. Noto Sans CJK ships TC, SC, HK, JP and KR versions of the same characters, and Chromium has to pick one.</p>
<p>In run B, Chromium picked JP for both <code>lang="zh-Hant"</code> and <code>lang="zh-Hans"</code>. We then tried a few variations on the same setup (runs B and D to G in <code>run_demo.sh</code>):</p>
<table>
<thead>
<tr>
<th>What we changed</th>
<th>Font Chromium picked for the Chinese text</th>
</tr>
</thead>
<tbody><tr>
<td><code>lang="zh-Hant"</code> / <code>lang="zh-Hans"</code> (original page)</td>
<td>Noto Sans CJK JP for both</td>
</tr>
<tr>
<td><code>lang="zh-TW"</code> / <code>lang="zh-CN"</code></td>
<td>Noto Sans CJK TC / Noto Sans CJK SC</td>
</tr>
<tr>
<td>No <code>lang</code> on <code>&lt;html&gt;</code> (Traditional paragraph), browser started with the default <code>LANG=C.UTF-8</code></td>
<td>Noto Sans CJK JP</td>
</tr>
<tr>
<td>No <code>lang</code> on <code>&lt;html&gt;</code> (Traditional paragraph), browser started with <code>LANG=zh_TW.UTF-8</code></td>
<td>Noto Sans CJK TC</td>
</tr>
<tr>
<td>No <code>lang</code> on <code>&lt;html&gt;</code> (Traditional paragraph), Playwright <code>locale="zh-TW"</code> / <code>"ja-JP"</code> / <code>"en-US"</code></td>
<td>Noto Sans CJK JP in all three</td>
</tr>
<tr>
<td>Explicit <code>font-family</code> per <code>:lang()</code></td>
<td>the font you named</td>
</tr>
</tbody></table>
<p>This was one Chromium build on one Linux font setup, so treat the table as "check yours" rather than a rule, but it shows two useful things. In this setup, script-only tags (<code>zh-Hant</code>, <code>zh-Hans</code>) didn't pick the Chinese faces, while region tags (<code>zh-TW</code>, <code>zh-CN</code>) did. And Playwright's <code>locale</code> option changes <code>navigator.language</code> and <code>Accept-Language</code>, but not the fallback font.</p>
<p><strong>If you own the page,</strong> use region tags or name the faces explicitly. <code>site/fixed.html</code> does both:</p>
<pre><code class="language-css">:lang(zh-TW) { font-family: "Noto Sans CJK TC", "Noto Sans TC", "PingFang TC", "Microsoft JhengHei", sans-serif; }
:lang(zh-CN) { font-family: "Noto Sans CJK SC", "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif; }
:lang(ja)    { font-family: "Noto Sans CJK JP", "Noto Sans JP", "Hiragino Sans", "Yu Gothic", sans-serif; }
:lang(ko)    { font-family: "Noto Sans CJK KR", "Noto Sans KR", "Apple SD Gothic Neo", "Malgun Gothic", sans-serif; }
</code></pre>
<pre><code class="language-text">== D: fixed page (lang=zh-TW / zh-CN + :lang() CSS), scroll first ==
status: 200
scrolled: height=5358px in 3.6s
images: {'images': 7, 'loaded': 7}
font #title: Noto Sans CJK TC (14), Noto Color Emoji (1)
font #zh-Hant: Noto Sans CJK TC (39)
font #zh-Hans: Noto Sans CJK SC (21)
font #ja: Noto Sans CJK JP (28)
font #ko: Noto Sans CJK KR (23)
font #emoji: Noto Sans CJK TC (12), Noto Color Emoji (6)
saved d-fixed-page.png in 3.9s
</code></pre>
<p>Each language now gets its own face. (The stack also lists the macOS and Windows names, so real visitors get the right fonts too.)</p>
<p><strong>If you don't own the page</strong> and it has no <code>lang</code> attribute, the browser process's <code>LANG</code> decides which face fills in. <code>shoot.py --fallback-lang zh_TW.UTF-8</code> passes that to <code>chromium.launch(env=...)</code>:</p>
<pre><code class="language-text">== E1: page without lang=, browser started with default LANG ==
font #zh-Hant: Arimo (11), Noto Sans CJK JP (28)
== E2: page without lang=, browser started with LANG=zh_TW.UTF-8 ==
font #zh-Hant: Arimo (11), Noto Sans CJK TC (28)
</code></pre>
<p>This only helps text that has no <code>lang</code> of its own. A page that declares <code>lang="zh-Hant"</code> still got JP in our test, and we deliberately don't inject CSS into pages we don't own: for monitoring and archiving, the screenshot should show the page as its authors shipped it.</p>
<h2>Checklist</h2>
<ul>
<li> Install <code>fonts-noto-cjk</code> and <code>fonts-noto-color-emoji</code> (or equivalents) and run <code>fc-cache -f</code></li>
<li> Font-check a few CJK elements with <code>CSS.getPlatformFontsForNode</code>; fail the job if CJK text is drawn only by a Latin font</li>
<li> For your own pages: use <code>zh-TW</code> / <code>zh-CN</code> / <code>zh-HK</code> region tags or <code>:lang()</code> font stacks</li>
<li> For pages without <code>lang</code>: start the browser with the <code>LANG</code> of the region you expect</li>
<li> Scroll in steps until the height stops growing, with a time and height cap, then scroll back to the top</li>
<li> Wait for pending images and <code>document.fonts.ready</code>; capture with <code>animations="disabled"</code></li>
<li> Respect robots.txt and the site's terms, and screenshot for QA, monitoring or archiving, not to republish other people's content</li>
</ul>
<h2>Disclosure: we built a hosted version</h2>
<p>We (Raspberry Rondel) make <strong><a href="https://apify.com/raspberry_rondel/website-screenshot-pro">Website Screenshot</a></strong>, an Apify Actor that takes these screenshots for a list of URLs, for visual QA, page monitoring and archiving. It ships with CJK and emoji fonts, captures full-page or viewport shots as PNG or JPEG, and respects robots.txt by default.</p>
<p>Pricing: USD 0.004 per screenshot stored for a page that loaded (HTTP status below 400). Error pages, blocked pages, failed loads and duplicates are free. Each run also has a start event of USD 0.0005 per GB of run memory, charged once per run (about USD 0.002 at the default 4 GB).</p>
<p>You're responsible for how you use screenshots of pages you don't own. Everything in this post works without the Actor.</p>
<p><em>This article was written with AI assistance. The code was run and the output above is copied from that run.</em></p>
]]></content:encoded></item><item><title><![CDATA[How to Build a B2B Contact List from Company Websites Responsibly]]></title><description><![CDATA[robots.txt and Crawl-delay, contact-page discovery, E.164 phone normalisation (Taiwan formats included), LINE links, de-duplication and GDPR/PDPA, with a…]]></description><link>https://hsinghsien-chen.hashnode.dev/how-to-build-a-b2b-contact-list-from-company-websites-responsibly</link><guid isPermaLink="true">https://hsinghsien-chen.hashnode.dev/how-to-build-a-b2b-contact-list-from-company-websites-responsibly</guid><category><![CDATA[Python]]></category><category><![CDATA[web scraping]]></category><category><![CDATA[lead generation]]></category><category><![CDATA[#gdpr]]></category><dc:creator><![CDATA[Raspberry Rondel]]></dc:creator><pubDate>Sat, 17 Oct 2026 08:36:46 GMT</pubDate><content:encoded><![CDATA[<p>You have a list of target companies from a trade-show catalogue or a CRM export, and you need each one's general business contact: the sales inbox, the switchboard, the official LINE account. The manual way is to open every homepage, find "Contact" or "關於我們", copy what's there into a spreadsheet, and repeat. It's slow, and you end up with phone numbers in five formats and no record of where anything came from.</p>
<p>This post shows how to automate that responsibly: follow robots.txt, read only a few pages per site, collect only <strong>public business contact info</strong>, and keep the source of every value. A runnable Python example is included.</p>
<h2>1. Decide what you are collecting (and what you are not)</h2>
<p>The target is <strong>company</strong> contact points: role-based addresses (<code>info@</code>, <code>sales@</code>), main lines, fax and toll-free numbers, and the company's own Facebook page and LINE official account. A named employee's email or a sole trader's mobile is personal data under GDPR and Taiwan's PDPA, even when it's on a public page.</p>
<ul>
<li><strong>Minimise.</strong> Keep only the columns you'll use.</li>
<li><strong>Keep provenance.</strong> Store the URL each value came from, so you can answer "where did you get my address?" and re-check stale data.</li>
</ul>
<h2>2. Crawl politely: robots.txt and Crawl-delay</h2>
<p>Before you fetch anything, read <code>/robots.txt</code> and follow it (<a href="https://www.rfc-editor.org/rfc/rfc9309">RFC 9309</a>):</p>
<ul>
<li>Skip every path that is <code>Disallow</code>ed for your user agent.</li>
<li>Honour <code>Crawl-delay</code> if it's set. <code>urllib.robotparser</code> exposes it as <code>crawl_delay()</code>. Without it, still wait at least a second per host.</li>
<li>If robots.txt returns a <strong>5xx</strong> error, don't crawl that site. RFC 9309 says to treat a server error as "complete disallow". A 404 means there are no rules.</li>
<li>Send an honest <code>User-Agent</code> that says who you are. Don't log in, don't rotate proxies to look like someone else, and stop if you hit a CAPTCHA or bot challenge. A site that blocks you has answered your question.</li>
</ul>
<h2>3. Find the contact and about pages</h2>
<p>Contact details usually sit in the homepage header or footer, a contact page and an about page, so you don't need a full crawl. Fetch the homepage, pick same-site links whose URL or anchor text matches <code>contact</code>, <code>about</code>, <code>聯絡</code>, <code>關於</code> or <code>お問い合わせ</code>, and visit a few. About four pages per domain covers most small company sites. If nothing matches, try <code>/contact</code> and <code>/about</code> directly.</p>
<h2>4. Extract emails, phones, LINE and social links</h2>
<p><strong>Emails.</strong> Take <code>mailto:</code> links first, then scan visible text, and filter obvious junk: asset names (<code>banner@2x.png</code>) and placeholders (<code>name@domain.com</code>). If a site deliberately obfuscates its address, respect that and don't decode it.</p>
<p><strong>Phones.</strong> Regexes alone will match dates, order numbers and prices. Two rules help a lot:</p>
<ol>
<li><strong>Require context.</strong> Only parse a phone number from a <code>tel:</code> link or a line that has a label nearby: <code>Tel</code>, <code>Phone</code>, <code>電話</code>, <code>客服</code>, <code>傳真</code>, <code>Fax</code>.</li>
<li><strong>Let libphonenumber do the parsing.</strong> The <a href="https://pypi.org/project/phonenumbers/"><code>phonenumbers</code></a> package (a Python port of Google's libphonenumber) understands Taiwanese formats such as <code>(02)2345-6789</code>, <code>02-2345-6789</code>, <code>+886 2 2345 6789</code> and <code>0800-000-123</code>. It normalises all of them to <strong>E.164</strong> (<code>+886223456789</code>), which is what makes de-duplication work.</li>
</ol>
<p>In Taiwan, capture extensions (<code>分機 123</code>, <code>轉 123</code>) separately, and strip the 8-digit tax ID (<code>統一編號</code>) that often sits next to the number in the footer.</p>
<p><strong>LINE.</strong> Businesses in Taiwan and Japan often publish a LINE official account instead of an email. Look for <code>line.me/R/ti/p/@account</code>, <code>lin.ee</code> short links and <code>page.line.me</code>.</p>
<p><strong>Social profiles.</strong> Keep the company's own profiles and drop share buttons (<code>/intent/tweet</code>, <code>/sharer</code>).</p>
<h2>5. De-duplicate with provenance</h2>
<p>The same number often appears on three pages in three formats. Key phones by E.164 (plus extension) and emails by their lower-cased form, and keep a list of source URLs per key: one record per domain, with an audit trail.</p>
<h2>6. A runnable example</h2>
<p>The script below puts sections 2–5 together using only the standard library plus <code>phonenumbers</code>. To test it safely, I ran it against a <strong>fictional</strong> company site served from my own machine. All addresses use <code>example.com</code>, and the numbers are made up. Don't point a test script at real third-party sites.</p>
<p>The test site has a <code>robots.txt</code> that blocks <code>/private/</code> and sets a crawl delay:</p>
<pre><code class="language-text">User-agent: *
Disallow: /private/
Crawl-delay: 1
</code></pre>
<p>The homepage links to an about page, a contact page and a page under <code>/private/</code>. Its footer puts a phone number and a tax ID on the same line:</p>
<pre><code class="language-html">&lt;!doctype html&gt;&lt;html lang="zh-Hant"&gt;&lt;head&gt;&lt;meta charset="utf-8"&gt;&lt;title&gt;Example Trading Co.&lt;/title&gt;&lt;/head&gt;
&lt;body&gt;
&lt;header&gt;&lt;a href="/"&gt;Home&lt;/a&gt; &lt;a href="/products.html"&gt;Products&lt;/a&gt; &lt;a href="/about-us.html"&gt;關於我們&lt;/a&gt; &lt;a href="/contact.html"&gt;聯絡我們&lt;/a&gt;&lt;/header&gt;
&lt;p&gt;Fictional demo company.&lt;/p&gt;
&lt;img src="/img/logo@2x.png" alt="logo"&gt;
&lt;a href="https://twitter.com/intent/tweet?url=https://example.com"&gt;Share on X&lt;/a&gt;
&lt;footer&gt;
  &lt;a href="https://www.facebook.com/exampletrading"&gt;Facebook&lt;/a&gt;
  &lt;a href="https://lin.ee/AbCdEfG"&gt;加入 LINE 好友&lt;/a&gt;
  &lt;a href="/private/contact-staff.html"&gt;Staff only&lt;/a&gt;
  電話 (02)2345-6789｜統一編號 12345678
&lt;/footer&gt;
&lt;/body&gt;&lt;/html&gt;
</code></pre>
<p>The contact page mixes contacts with things that must <em>not</em> be extracted (an order number, a date, a placeholder email, an image name):</p>
<pre><code class="language-html">&lt;!doctype html&gt;&lt;html lang="zh-Hant"&gt;&lt;head&gt;&lt;meta charset="utf-8"&gt;&lt;title&gt;聯絡我們&lt;/title&gt;&lt;/head&gt;
&lt;body&gt;
&lt;h1&gt;聯絡我們&lt;/h1&gt;
&lt;p&gt;業務洽詢：&lt;a href="mailto:sales@example.com"&gt;sales@example.com&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;客服信箱：info@example.com&lt;/p&gt;
&lt;p&gt;電話：&lt;a href="tel:+886223456789"&gt;(02)2345-6789&lt;/a&gt; 分機 123&lt;/p&gt;
&lt;p&gt;傳真：02-2345-6790&lt;/p&gt;
&lt;p&gt;免付費客服：0800-000-123&lt;/p&gt;
&lt;p&gt;Order no. 2026101700123, updated 2026-10-17&lt;/p&gt;
&lt;p&gt;Example form text: name@domain.com (image: banner@2x.png)&lt;/p&gt;
&lt;p&gt;LINE 官方帳號：&lt;a href="https://line.me/R/ti/p/@exampletrading"&gt;@exampletrading&lt;/a&gt;&lt;/p&gt;
&lt;footer&gt;&lt;a href="https://www.facebook.com/exampletrading"&gt;Facebook&lt;/a&gt;&lt;/footer&gt;
&lt;/body&gt;&lt;/html&gt;
</code></pre>
<p>The extractor, <code>extract_contacts.py</code> (<code>pip install phonenumbers</code>):</p>
<pre><code class="language-python">"""Polite public-business-contact extractor (tutorial demo).

Reads a site's robots.txt, honours Disallow and Crawl-delay, visits the homepage
plus up to 3 contact/about pages, and returns one de-duplicated record per domain.
Only run it on sites you are allowed to crawl. Demo data is fictional.
"""
import json, re, sys, time, urllib.error, urllib.request, urllib.robotparser
from html.parser import HTMLParser
from urllib.parse import urljoin, urlsplit
import phonenumbers

UA = "ContactListTutorialBot/0.1 (+https://example.com/bot)"
MAX_PAGES = 4
PAGE_HINTS = re.compile(r"contact|about|聯絡|聯繫|關於|お問い合わせ", re.I)
EMAIL = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,24}")
JUNK_EMAIL = re.compile(r"\.(png|jpe?g|gif|svg|webp)$|^(you|name|user|email)@(example|domain)\.", re.I)
PHONE_CONTEXT = re.compile(r"tel|phone|電話|客服|傳真|fax|免付費", re.I)
FAX = re.compile(r"傳真|fax", re.I)
EXT = re.compile(r"^\s*(?:分機|轉|ext\.?|#)\s*(\d{1,6})", re.I)
LINE = re.compile(r"https?://(?:lin\.ee|line\.me|page\.line\.me)/[^\s\"'&lt;&gt;]+", re.I)
TAX_ID = re.compile(r"統一編號\s*[:：]?\s*\d{8}")
SHARE = re.compile(r"/(?:intent|share|sharer)", re.I)
SOCIAL = re.compile(r"https?://(?:www\.)?(facebook|instagram|linkedin|x|twitter|youtube)\.com/[^\s\"'&lt;&gt;]+", re.I)


class Page(HTMLParser):
    """Collects visible text (one line per block) and link targets."""
    def __init__(self):
        super().__init__(); self.links, self.lines, self._buf = [], [], []
    def handle_starttag(self, tag, attrs):
        href = dict(attrs).get("href")
        if href: self.links.append(href)
        if tag in ("p", "br", "li", "div", "footer", "header", "h1", "h2", "tr"): self._flush()
    def handle_data(self, data): self._buf.append(data)
    def _flush(self):
        line = " ".join("".join(self._buf).split())
        if line: self.lines.append(line)
        self._buf = []


def fetch(url):
    req = urllib.request.Request(url, headers={"User-Agent": UA})
    with urllib.request.urlopen(req, timeout=10) as r:
        return r.status, r.read().decode("utf-8", "replace")


def load_robots(base):
    rp = urllib.robotparser.RobotFileParser()
    try:
        _, body = fetch(base + "/robots.txt")
        rp.parse(body.splitlines())
    except urllib.error.HTTPError as e:
        if e.code &gt;= 500:
            return None          # server error: treat as "do not crawl"
        rp.parse([])             # 404 etc.: no rules, everything allowed
    return rp


def extract(start_url, region="TW"):
    parts = urlsplit(start_url); base = f"{parts.scheme}://{parts.netloc}"
    record = {"domain": parts.hostname, "status": "ok", "pages": [], "skippedByRobots": [],
              "emails": {}, "phones": {}, "line": {}, "socials": {}, "junkFiltered": 0}
    rp = load_robots(base)
    if rp is None:
        record["status"] = "robots-unavailable"; return record
    delay = rp.crawl_delay(UA) or 1.0           # at least 1 s between requests
    queue, seen = [base + "/"], set()
    while queue and len(record["pages"]) &lt; MAX_PAGES:
        url = queue.pop(0)
        if url in seen: continue
        seen.add(url)
        if not rp.can_fetch(UA, url):
            record["skippedByRobots"].append(url); continue
        if record["pages"]: time.sleep(delay)
        try:
            status, html = fetch(url)
        except urllib.error.URLError as e:
            record["pages"].append({"url": url, "error": str(e)}); continue
        record["pages"].append({"url": url, "http": status})
        page = Page(); page.feed(html); page._flush()
        collect(page, url, region, record)
        if url == base + "/":                    # discover contact/about pages from the homepage
            for href in page.links:
                full = urljoin(url, href).split("#")[0]
                if urlsplit(full).netloc == parts.netloc and PAGE_HINTS.search(href):
                    queue.append(full)
    if not (record["emails"] or record["phones"] or record["line"] or record["socials"]):
        record["status"] = "no-contacts-found"
    for key in ("emails", "phones", "line", "socials"):    # sets -&gt; sorted lists for JSON
        record[key] = {k: sorted(v) if isinstance(v, set) else v for k, v in record[key].items()}
    return record


def add(bucket, key, source):
    bucket.setdefault(key, set()).add(source)


def collect(page, url, region, rec):
    for href in page.links:
        if href.startswith("mailto:"):
            add(rec["emails"], href[7:].split("?")[0].lower(), url)
        elif LINE.match(href):
            add(rec["line"], href, url)
        elif SOCIAL.match(href) and not SHARE.search(href):
            add(rec["socials"], href.rstrip("/"), url)
    for line in page.lines:
        for e in EMAIL.findall(line):
            if not JUNK_EMAIL.search(e):
                add(rec["emails"], e.lower(), url)
            else:
                rec["junkFiltered"] += 1
        line = TAX_ID.sub(" ", line)                # 統一編號 (8-digit tax ID) is not a phone
        if not PHONE_CONTEXT.search(line):
            continue                                # phones need context (Tel, 電話, 傳真 ...)
        for m in phonenumbers.PhoneNumberMatcher(line, region):
            e164 = phonenumbers.format_number(m.number, phonenumbers.PhoneNumberFormat.E164)
            ext = EXT.match(line[m.end:])
            key = e164 + (f";ext={ext.group(1)}" if ext else "")
            entry = rec["phones"].setdefault(key, {"kind": "fax" if FAX.search(line) else "phone", "sources": []})
            if url not in entry["sources"]: entry["sources"].append(url)


if __name__ == "__main__":
    print(json.dumps(extract(sys.argv[1]), ensure_ascii=False, indent=2))
</code></pre>
<p>Serve the site and run it:</p>
<pre><code class="language-bash">python3 -m http.server 8000 --bind 127.0.0.1 --directory site &amp;
python3 extract_contacts.py http://127.0.0.1:8000/
</code></pre>
<p>Real output (the demo script <code>run_demo.sh</code> picks a free port and appends the server log):</p>
<pre><code class="language-text">{
  "domain": "127.0.0.1",
  "status": "ok",
  "pages": [
    {
      "url": "http://127.0.0.1:48023/",
      "http": 200
    },
    {
      "url": "http://127.0.0.1:48023/about-us.html",
      "http": 200
    },
    {
      "url": "http://127.0.0.1:48023/contact.html",
      "http": 200
    }
  ],
  "skippedByRobots": [
    "http://127.0.0.1:48023/private/contact-staff.html"
  ],
  "emails": {
    "info@example.com": [
      "http://127.0.0.1:48023/about-us.html",
      "http://127.0.0.1:48023/contact.html"
    ],
    "sales@example.com": [
      "http://127.0.0.1:48023/contact.html"
    ]
  },
  "phones": {
    "+886223456789": {
      "kind": "phone",
      "sources": [
        "http://127.0.0.1:48023/",
        "http://127.0.0.1:48023/about-us.html"
      ]
    },
    "+886223456789;ext=123": {
      "kind": "phone",
      "sources": [
        "http://127.0.0.1:48023/contact.html"
      ]
    },
    "+886223456790": {
      "kind": "fax",
      "sources": [
        "http://127.0.0.1:48023/contact.html"
      ]
    },
    "+886800000123": {
      "kind": "phone",
      "sources": [
        "http://127.0.0.1:48023/contact.html"
      ]
    }
  },
  "line": {
    "https://lin.ee/AbCdEfG": [
      "http://127.0.0.1:48023/"
    ],
    "https://line.me/R/ti/p/@exampletrading": [
      "http://127.0.0.1:48023/contact.html"
    ]
  },
  "socials": {
    "https://www.facebook.com/exampletrading": [
      "http://127.0.0.1:48023/",
      "http://127.0.0.1:48023/contact.html"
    ]
  },
  "junkFiltered": 2
}
--- server access log (shows robots.txt first and the Crawl-delay spacing) ---
[17/Oct/2026 16:31:02] "GET /robots.txt HTTP/1.1" 200 -
[17/Oct/2026 16:31:02] "GET / HTTP/1.1" 200 -
[17/Oct/2026 16:31:03] "GET /about-us.html HTTP/1.1" 200 -
[17/Oct/2026 16:31:04] "GET /contact.html HTTP/1.1" 200 -
</code></pre>
<p>What to notice:</p>
<ul>
<li>robots.txt was fetched first, and <code>/private/contact-staff.html</code> was <strong>skipped</strong> even though it matched the "contact" hint.</li>
<li>Requests are about one second apart, as <code>Crawl-delay: 1</code> asks.</li>
<li><code>(02)2345-6789</code> and <code>+886 2 2345 6789</code> became one E.164 key with two sources. The <code>分機 123</code> version kept its own key, and the fax line was labelled <code>fax</code>.</li>
<li>The tax ID, order number, date, placeholder email and image file name produced no contacts (<code>junkFiltered: 2</code> counts the two email-like strings).</li>
<li>The share-on-X link was dropped, and both LINE link styles (<code>lin.ee</code> and <code>line.me/R/ti/p/@…</code>) were kept.</li>
</ul>
<p>It's a teaching script. It reads phones from labelled text (not <code>tel:</code> links), doesn't try <code>/contact</code> when nothing matches, and has no JavaScript rendering, retries or parallelism.</p>
<h2>7. GDPR and PDPA: your responsibilities</h2>
<p>This section is general information, not legal advice.</p>
<ul>
<li><strong>You decide</strong> which sites to read and how to use the results, so compliance is your responsibility, not the tool's.</li>
<li><strong>GDPR (EU/EEA and UK).</strong> You need a lawful basis, usually legitimate interest (Art. 6(1)(f)) backed by a documented balancing test. If any personal data ends up in the list, you have to tell those people within a reasonable time (Art. 14) and honour objections (Art. 21). National ePrivacy rules often require prior consent before you send marketing email to individuals.</li>
<li><strong>Taiwan PDPA (個人資料保護法).</strong> Data the person made public themselves, or that was lawfully made public, may be collected, but only for a <strong>specific purpose</strong> (<a href="https://law.moj.gov.tw/LawClass/LawSingle.aspx?pcode=I0050021&amp;flno=19">Art. 19</a>). The notice obligation can be waived for such data (<a href="https://law.moj.gov.tw/LawClass/LawSingle.aspx?pcode=I0050021&amp;flno=9">Art. 9</a>). If you use it for marketing, you must offer a way to refuse at first contact and stop as soon as the person objects (<a href="https://law.moj.gov.tw/LawClass/LawSingle.aspx?pcode=I0050021&amp;flno=20">Art. 20</a>).</li>
<li><strong>In practice:</strong> prefer role-based addresses, keep source URLs, honour "no solicitation" notices, offer an opt-out in every first message, delete what you don't need, and never build profiles of individuals.</li>
</ul>
<h2>Checklist</h2>
<ul>
<li> Company contact points only</li>
<li> Fetch robots.txt first; skip disallowed paths; 5xx means don't crawl</li>
<li> Crawl-delay (≥1 s per host), honest User-Agent, no logins or proxies, stop at challenges</li>
<li> Homepage plus 3 contact/about pages per domain</li>
<li> Phones only with context; normalise to E.164; keep extensions; strip 統一編號</li>
<li> Filter junk emails; don't decode obfuscated ones</li>
<li> Keep LINE official account links; drop share buttons</li>
<li> De-duplicate by normalised key and keep source URLs</li>
<li> Document lawful basis and purpose; offer an opt-out</li>
</ul>
<h2>Disclosure: we built a hosted version</h2>
<p>If you'd rather not maintain a crawler, we built <strong><a href="https://apify.com/raspberry_rondel/website-contact-extractor">Website Contact Extractor</a></strong>, an Apify Actor that does the steps above for a whole list of company websites, so you don't have to open each contact page or tidy the spreadsheet yourself. You get one row per domain with business emails, E.164 phone numbers (Taiwan formats and 分機 included), social profiles and a LINE column, each with its source page. It obeys robots.txt and Crawl-delay, reads 4 pages per domain by default, doesn't log in or try to work around anti-bot protection, and filters obvious junk. It doesn't find contacts that appear only in images, PDFs or contact forms.</p>
<p>Pricing: Charged per domain with results (USD 0.003), not per page crawled; domains with nothing found are free. Apify also charges its standard start event of USD 0.00005 per run per GB of memory.</p>
<p>Its README sets out your GDPR and PDPA responsibilities in more detail. In our internal test (30 sites, Oct 2026), our re-check of the 378 contact items it returned against their source pages found 0 errors. Results on other sites may differ.</p>
<p>This article was written with AI assistance. The code was run and the output above is copied from that run.</p>
]]></content:encoded></item><item><title><![CDATA[PDF to Markdown for RAG: Fix Reading Order, Tables and Headings Before You Chunk]]></title><description><![CDATA[Plain-text PDF extraction interleaves two-column pages and flattens tables, so your chunks are wrong before you embed them.]]></description><link>https://hsinghsien-chen.hashnode.dev/pdf-to-markdown-for-rag-fix-reading-order-tables-and-headings-before-you-chunk</link><guid isPermaLink="true">https://hsinghsien-chen.hashnode.dev/pdf-to-markdown-for-rag-fix-reading-order-tables-and-headings-before-you-chunk</guid><category><![CDATA[Python]]></category><category><![CDATA[RAG ]]></category><category><![CDATA[llm]]></category><category><![CDATA[markdown]]></category><dc:creator><![CDATA[Raspberry Rondel]]></dc:creator><pubDate>Sat, 17 Oct 2026 06:38:36 GMT</pubDate><content:encoded><![CDATA[<p>If your RAG pipeline answers questions about PDFs badly, look at the chunks before you tune the embeddings or the prompt. A lot of retrieval problems start at extraction: two-column pages read straight across, tables turned into a stream of numbers, and headings that become ordinary lines of text. Then you spend an afternoon cleaning text by hand before you can even chunk it.</p>
<p>This post covers what goes wrong, why converting to <strong>Markdown</strong> (not plain text) fixes most of it, how the common open-source libraries compare, and a small Python script you can run locally to see the difference.</p>
<h2>Three things plain-text extraction breaks</h2>
<p><strong>1. Reading order in two-column layouts.</strong> Most extractors work line by line, top to bottom. On a two-column paper, line 1 of the left column is followed by line 1 of the right column. Every chunk then mixes two unrelated passages, and the embedding ends up halfway between them.</p>
<p><strong>2. Tables.</strong> A table flattened into text keeps the values and loses the structure. "Q1 Q2 Q3 / 12 15 9" no longer tells the model which number belongs to which column. A Markdown table (<code>| Q1 | Q2 | Q3 |</code>) keeps the header attached to each row, and LLMs read that format well.</p>
<p><strong>3. Headings.</strong> Headings are the best chunk boundaries you have, and they're also useful metadata ("this chunk is from <em>3.2 Limitations</em>"). In plain text they look like any other line. In Markdown they're <code>#</code>/<code>##</code> lines, so a splitter such as LangChain's <code>MarkdownHeaderTextSplitter</code> or a 10-line function can cut on them.</p>
<p>CJK documents add one more problem: justified Chinese text is often stored with a space between every character, and line breaks land mid-sentence. Both get in the way of keyword search and chunk-size math.</p>
<h2>The options, briefly</h2>
<p>All of these are real, maintained projects. Check licenses yourself before you ship; the ones below are from PyPI/GitHub metadata as of October 2026.</p>
<table>
<thead>
<tr>
<th>Library</th>
<th>Approach</th>
<th>Good at</th>
<th>Watch out for</th>
<th>License</th>
</tr>
</thead>
<tbody><tr>
<td><strong>pypdf</strong></td>
<td>Pure-Python text extraction</td>
<td>Zero dependencies, fast</td>
<td>No layout, tables or headings</td>
<td>BSD-3</td>
</tr>
<tr>
<td><strong>pdfminer.six</strong></td>
<td>Layout analysis (chars → lines → boxes)</td>
<td>Fine-grained positions, fonts, sizes</td>
<td>You build structure yourself</td>
<td>MIT</td>
</tr>
<tr>
<td><strong>pdfplumber</strong></td>
<td>Built on pdfminer.six</td>
<td>Ruled-table detection, easy word/char access</td>
<td>Borderless tables need tuning</td>
<td>MIT</td>
</tr>
<tr>
<td><strong>PyMuPDF / pymupdf4llm</strong></td>
<td>MuPDF engine, Markdown output</td>
<td>Fast; Markdown output ready to use</td>
<td>AGPL-3.0 or commercial license</td>
<td>AGPL / commercial</td>
</tr>
<tr>
<td><strong>Docling</strong> (IBM)</td>
<td>ML layout + table models</td>
<td>Complex layouts, many formats</td>
<td>Heavier install, model downloads</td>
<td>MIT</td>
</tr>
<tr>
<td><strong>Marker</strong></td>
<td>ML pipeline, optional LLM</td>
<td>Hard layouts, equations</td>
<td>GPU helps; check model-weight terms</td>
<td>Apache-2.0 (code)</td>
</tr>
<tr>
<td><strong>MinerU</strong></td>
<td>ML pipeline, OCR</td>
<td>Scanned docs, Chinese</td>
<td>Heavy; custom license</td>
<td>MinerU license</td>
</tr>
<tr>
<td><strong>unstructured</strong></td>
<td>Element partitioning</td>
<td>Many file types, element metadata</td>
<td>Hi-res mode pulls in a lot</td>
<td>Apache-2.0</td>
</tr>
<tr>
<td><strong>MarkItDown</strong> (Microsoft)</td>
<td>Converts many formats to Markdown</td>
<td>Office files, quick conversions</td>
<td>PDF handling is lighter than PDF-specific tools (uses pdfminer/pdfplumber)</td>
<td>MIT</td>
</tr>
</tbody></table>
<p>A rough rule of thumb: if your PDFs have a <strong>text layer</strong> (born-digital reports, papers, government documents), a layout-aware rules approach on pdfminer.six/pdfplumber is light, deterministic and cheap to run. If they're <strong>scanned</strong> or very visual, you need OCR or an ML pipeline (Docling, Marker, MinerU), and you pay for it in compute.</p>
<h2>A runnable example: two columns + a table → Markdown → chunks</h2>
<p>We'll generate a small sample PDF so you don't need any files, then compare <code>extract_text()</code> with a ~90-line converter.</p>
<pre><code class="language-bash">python -m venv .venv &amp;&amp; source .venv/bin/activate
pip install pdfplumber==0.11.10 fpdf2==2.8.9
</code></pre>
<p><code>make_sample.py</code> builds a page with a title, two columns, and a ruled table:</p>
<pre><code class="language-python"># make_sample.py - build a small two-column PDF with headings and a ruled table
from fpdf import FPDF

LEFT = ("Retrieval quality depends on chunk boundaries. When one chunk mixes "
        "two sections, its embedding blurs both topics. Splitting at headings "
        "keeps each chunk about one thing.")
RIGHT = ("Tables are worse. Flattened into plain text, a row of values "
         "loses its column headers, so the model cannot tell which number "
         "belongs to which metric.")

pdf = FPDF(format="A4")
pdf.add_page()
pdf.set_font("Helvetica", "B", 20)
pdf.cell(0, 12, "Chunking PDFs for RAG", new_x="LMARGIN", new_y="NEXT")
y = pdf.get_y() + 4
for x, title, body in [(10, "Why boundaries matter", LEFT), (110, "Why tables break", RIGHT)]:
    pdf.set_xy(x, y)
    pdf.set_font("Helvetica", "B", 14)
    pdf.multi_cell(90, 8, title)
    pdf.set_x(x)
    pdf.set_font("Helvetica", "", 10)
    pdf.multi_cell(90, 5, body)
pdf.set_y(150)
pdf.set_font("Helvetica", "B", 14)
pdf.cell(0, 8, "Formats compared", new_x="LMARGIN", new_y="NEXT")
pdf.set_font("Helvetica", "", 10)
with pdf.table(col_widths=(60, 40, 40)) as table:  # fpdf2 draws ruled borders
    for row in [("Output", "Headings", "Tables"), ("Plain text", "lost", "flattened"), ("Markdown", "# / ##", "pipe table")]:
        r = table.row()
        for cell in row:
            r.cell(cell)
pdf.output("sample.pdf")
print("wrote sample.pdf")
</code></pre>
<p><code>pdf_to_md.py</code> does four things: groups words into line fragments (splitting at the column gutter), orders them with a recursive <strong>XY-cut</strong> (split at a vertical gutter if one exists, otherwise at the widest horizontal gap), marks headings by font size relative to the page's median size, and swaps ruled tables for Markdown tables using pdfplumber's <code>find_tables()</code>.</p>
<pre><code class="language-python"># pdf_to_md.py - minimal text-layer PDF -&gt; Markdown -&gt; heading-aware chunks
import statistics, sys
import pdfplumber

def lines_of(words, gap=12):
    """Group words into line fragments; a wide horizontal gap (a column gutter) starts a new fragment."""
    rows = {}
    for w in words:
        rows.setdefault(round(w["top"]), []).append(w)
    frags = []
    for ws in rows.values():
        ws.sort(key=lambda w: w["x0"])
        cur = [ws[0]]
        for w in ws[1:]:
            if w["x0"] - cur[-1]["x1"] &gt; gap:
                frags.append(cur); cur = []
            cur.append(w)
        frags.append(cur)
    return [{"x0": f[0]["x0"], "x1": f[-1]["x1"], "top": min(w["top"] for w in f),
             "bottom": max(w["bottom"] for w in f), "size": f[0]["size"],
             "text": " ".join(w["text"] for w in f)} for f in frags]

def gaps(intervals):
    intervals = sorted(intervals); out = []; end = intervals[0][1]
    for a, b in intervals[1:]:
        if a &gt; end:
            out.append((end, a))
        end = max(end, b)
    return out

def xy_cut(items, min_gutter=7):
    """Recursive XY-cut: split at a column gutter if there is one, else at the widest horizontal gap."""
    if len(items) &lt;= 1:
        return items
    width = max(i["x1"] for i in items) - min(i["x0"] for i in items)
    cols = [g for g in gaps([(i["x0"], i["x1"]) for i in items]) if g[1] - g[0] &gt;= min_gutter]
    for a, b in sorted(cols, key=lambda g: g[0] - g[1]):          # widest gutter first
        left = [i for i in items if i["x1"] &lt;= a]; right = [i for i in items if i["x0"] &gt;= b]
        if min(max(i["x1"] for i in left) - min(i["x0"] for i in left),
               max(i["x1"] for i in right) - min(i["x0"] for i in right)) &gt; 0.2 * width:
            return xy_cut(left) + xy_cut(right)
    rows = gaps([(i["top"], i["bottom"]) for i in items])
    if not rows:
        return sorted(items, key=lambda i: (i["top"], i["x0"]))
    a, b = max(rows, key=lambda g: g[1] - g[0])
    return xy_cut([i for i in items if i["bottom"] &lt;= a]) + xy_cut([i for i in items if i["top"] &gt;= b])

def page_to_markdown(page):
    tables = [t for t in page.find_tables() if len(t.extract()) &gt;= 2]   # ruled tables only
    def in_table(w):
        return any(t.bbox[0] &lt;= w["x0"] and w["x1"] &lt;= t.bbox[2] and
                   t.bbox[1] &lt;= w["top"] and w["bottom"] &lt;= t.bbox[3] for t in tables)
    words = [w for w in page.extract_words(extra_attrs=["size"]) if not in_table(w)]
    body = statistics.median(w["size"] for w in words)
    items = lines_of(words)
    for t in tables:
        rows = [[(c or "").replace("\n", " ").strip() for c in r] for r in t.extract()]
        md = ["| " + " | ".join(rows[0]) + " |", "|" + "---|" * len(rows[0])]
        md += ["| " + " | ".join(r) + " |" for r in rows[1:]]
        x0, top, x1, bottom = t.bbox
        items.append({"x0": x0, "x1": x1, "top": top, "bottom": bottom, "size": body,
                      "text": "\n".join(md), "table": True})
    out = []
    for it in xy_cut(items):
        if it.get("table"):
            out.append(it["text"])
        elif it["size"] &gt;= body * 1.8:
            out.append("# " + it["text"])
        elif it["size"] &gt;= body * 1.3:
            out.append("## " + it["text"])
        elif out and not out[-1].startswith(("#", "|")):
            out[-1] += " " + it["text"]                  # re-join wrapped lines into one paragraph
        else:
            out.append(it["text"])
    return "\n\n".join(out)

def chunk_by_heading(md):
    chunks, current = [], []
    for block in md.split("\n\n"):
        if block.startswith("#") and current:
            chunks.append("\n\n".join(current)); current = []
        current.append(block)
    return chunks + ["\n\n".join(current)]

if __name__ == "__main__":
    with pdfplumber.open(sys.argv[1] if len(sys.argv) &gt; 1 else "sample.pdf") as pdf:
        page = pdf.pages[0]
        print("=== naive extract_text() ===")
        print(page.extract_text()[:240], "...\n")
        md = page_to_markdown(page)
        print("=== markdown ===")
        print(md, "\n")
        for i, c in enumerate(chunk_by_heading(md)):
            print(f"--- chunk {i}: {c.splitlines()[0]!r} ({len(c)} chars)")
</code></pre>
<p>Run it:</p>
<pre><code class="language-bash">python make_sample.py &amp;&amp; python pdf_to_md.py
</code></pre>
<p>Here's what <code>extract_text()</code> gives you. Look at lines 3-5: each one runs straight across both columns.</p>
<pre><code class="language-text">Chunking PDFs for RAG
Why boundaries matter Why tables break
Retrieval quality depends on chunk boundaries. When Tables are worse. Flattened into plain text, a row of
one chunk mixes two sections, its embedding blurs both values loses its column headers, so the model cannot
topics. Splitting at headings keeps each chunk about tell which number belongs to which metric.
one thing.
Formats compared
Output Headings Tables
Plain text lost flattened
Markdown # / ## pipe table
</code></pre>
<p>And here's the converter output (tested with Python 3.13, pdfplumber 0.11.10, fpdf2 2.8.9):</p>
<pre><code class="language-text"># Chunking PDFs for RAG

## Why boundaries matter

Retrieval quality depends on chunk boundaries. When one chunk mixes two sections, its embedding blurs both topics. Splitting at headings keeps each chunk about one thing.

## Why tables break

Tables are worse. Flattened into plain text, a row of values loses its column headers, so the model cannot tell which number belongs to which metric.

## Formats compared

| Output | Headings | Tables |
|---|---|---|
| Plain text | lost | flattened |
| Markdown | # / ## | pipe table |

--- chunk 0: '# Chunking PDFs for RAG' (23 chars)
--- chunk 1: '## Why boundaries matter' (196 chars)
--- chunk 2: '## Why tables break' (170 chars)
--- chunk 3: '## Formats compared' (134 chars)
</code></pre>
<p>Each column comes out as one paragraph under its own heading, the table keeps its header row, and the chunker splits on headings. In a real pipeline you'd merge tiny chunks (like the title-only chunk 0) into the next one and add the heading path as metadata.</p>
<h3>Where this toy version breaks</h3>
<p>It works on this page, but real documents will break it quickly:</p>
<ul>
<li><p>Paragraphs: it joins every non-heading line until the next heading. Real code needs to detect paragraph breaks (indents, vertical spacing) and hyphenation.</p>
</li>
<li><p>Borderless "three-line" academic tables aren't detected by <code>find_tables()</code> with default settings.</p>
</li>
<li><p>Rotated margin text (arXiv side stamps), headers/footers and page numbers end up in the body.</p>
</li>
<li><p>Symbol fonts map glyphs into Unicode's Private Use Area, and some fonts give <code>(cid:NN)</code> garbage. You want to detect those pages, not embed them.</p>
</li>
<li><p>Scanned pages have no text layer at all, and you should flag them rather than index empty strings.</p>
</li>
</ul>
<h2>Disclosure: we built a hosted version</h2>
<p>We built <a href="https://apify.com/raspberry_rondel/pdf-to-markdown-converter">PDF to Markdown – Text, Tables &amp; Chinese/CJK for LLMs</a>, an Apify Actor that handles the cases above so you don't have to maintain them. Under the hood it uses the same approach as this post, built out further: pdfplumber + pdfminer.six (both MIT), recursive XY-cut reading order, font-size headings, ruled tables to Markdown, re-joined wrapped lines, CJK inter-character space removal, ligature and Symbol-font cleanup, and dropping rotated stamps. It checks each page for garbled glyphs and leaves bad pages out, and it reports scanned/image-only pages instead of returning junk. We tested it on Traditional and Simplified Chinese government reports and two-column papers (ResNet, BERT). It does <strong>not</strong> do OCR, and borderless tables come out as text.</p>
<p>Pricing is pay-per-use: <strong>USD 0.002 per run + USD 0.0005 per converted page</strong>. A 10-page PDF costs USD 0.007. Scanned, blank and garbled pages and failed downloads aren't charged. A scheduled change on 2026-10-31 lowers the price for pages 21+ of each document to USD 0.00015.</p>
<p>If you'd rather run it yourself, the script above is a reasonable starting point.</p>
<p><em>This article was written with AI assistance. The code was run and the output above is copied from that run.</em></p>
]]></content:encoded></item></channel></rss>