<?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" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
    <channel>
        
        <title>
            <![CDATA[ playwright - freeCodeCamp.org ]]>
        </title>
        <description>
            <![CDATA[ Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice. ]]>
        </description>
        <link>https://www.freecodecamp.org/news/</link>
        <image>
            <url>https://cdn.freecodecamp.org/universal/favicons/favicon.png</url>
            <title>
                <![CDATA[ playwright - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Tue, 28 Jul 2026 03:51:41 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/tag/playwright/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ How I Tested Malaysia's Open Data Portals with Plain English ]]>
                </title>
                <description>
                    <![CDATA[ Most end-to-end test suites drive a real browser and click through an app like a user. They check whether a page renders and whether elements appear. But they don't check whether the numbers on those  ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-i-tested-malaysia-s-open-data-portals-with-plain-english/</link>
                <guid isPermaLink="false">69eaad32904b915438ce46f9</guid>
                
                    <category>
                        <![CDATA[ postmark ]]>
                    </category>
                
                    <category>
                        <![CDATA[ playwright ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Testing ]]>
                    </category>
                
                    <category>
                        <![CDATA[ automation testing  ]]>
                    </category>
                
                    <category>
                        <![CDATA[ breakingappshackathon ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Tech With RJ ]]>
                </dc:creator>
                <pubDate>Thu, 23 Apr 2026 23:37:22 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/605584805f8d5121697263ca/d4859bd4-15d5-4bb7-ba9e-d4693c90163d.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Most end-to-end test suites drive a real browser and click through an app like a user. They check whether a page renders and whether elements appear.</p>
<p>But they don't check whether the numbers on those elements are correct. A data-pipeline bug that shows Malaysia's population as 3.4 million instead of the real 34 million slips past every selector test in the suite.</p>
<p>The element still exists. A number still renders. The page still looks right. But the bug ships and sits there until a human notices.</p>
<p>I work as a full-stack engineer. Writing end-to-end (E2E) tests with <a href="https://playwright.dev">Playwright</a> and unit tests with <a href="https://jestjs.io">Jest</a> is part of my day job. I also use <a href="https://github.com/microsoft/playwright-mcp">Playwright MCP</a>, the bridge between AI assistants like Claude and a running browser, when I need to generate first-draft test code or debug a flow.</p>
<p>None of that tooling closes the maintenance tax on selector-based suites. Every E2E suite I keep alive at work accumulates <code>data-testid</code> selectors, <code>waitForSelector</code> calls, and tests that break because someone renamed a button.</p>
<p>Bug0's <a href="https://hashnode.com/hackathons/breaking-things">Breaking Apps Hackathon</a> gave me a pretext to try something different. Over a weekend, <a href="https://github.com/LeeRenJie/passmark-hackathon">I built an automated regression suite</a> for Malaysia's three public open data portals, <a href="https://data.gov.my">data.gov.my</a>, <a href="https://open.dosm.gov.my">OpenDOSM</a>, and <a href="https://data.moh.gov.my">KKMNow</a>, using <a href="https://github.com/bug0inc/passmark">Passmark</a>, Bug0's open-source AI-driven Playwright library.</p>
<p>The tests are written in plain English. Two AI models verify each assertion. A third arbitrates disagreements.</p>
<h3 id="heading-what-youll-find-below">What You'll Find Below:</h3>
<ul>
<li><p>How to write an E2E test that checks whether a dashboard's numbers are correct, not only whether the page renders</p>
</li>
<li><p>A specific assertion pattern (range-bounded KPIs) that catches an entire class of data-pipeline bug that selector tests miss, with working examples ready to copy</p>
</li>
<li><p>A cross-field math assertion that takes one sentence in Passmark and around a hundred lines of code without it</p>
</li>
<li><p>How Passmark's own failure explanations became my debugging loop (the single biggest shift in how I'll write E2E tests going forward)</p>
</li>
<li><p>The real limits: a 14% cache-hit rate, a dependency on OpenRouter, and what two-model voting fails to catch</p>
</li>
</ul>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-why-malaysias-open-data-portals">Why Malaysia's Open Data Portals</a>?</p>
</li>
<li><p><a href="#heading-what-is-passmark">What Is Passmark</a>?</p>
</li>
<li><p><a href="#heading-the-hero-spec-range-bounded-assertions">The Hero Spec: Range-Bounded Assertions</a></p>
<ul>
<li><a href="#heading-what-two-model-voting-doesnt-catch">What Two-Model Voting Doesn't Catch</a></li>
</ul>
</li>
<li><p><a href="#heading-going-further-cross-field-math">Going Further: Cross-Field Math</a></p>
</li>
<li><p><a href="#heading-what-i-found-across-three-runs">What I Found Across Three Runs</a></p>
<ul>
<li><p><a href="#heading-the-debugging-loop">The Debugging Loop</a></p>
</li>
<li><p><a href="#heading-the-two-specs-that-still-fail-are-the-most-interesting">The Two Specs That Still Fail Are the Most Interesting</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-what-it-cost-and-why-cache-rate-is-cost-rate">What It Cost, and Why Cache Rate Is Cost Rate</a></p>
</li>
<li><p><a href="#heading-the-pattern-worth-stealing">The Pattern Worth Stealing</a></p>
</li>
<li><p><a href="#heading-honest-verdict">Honest Verdict</a></p>
</li>
<li><p><a href="#heading-resources">Resources</a></p>
</li>
</ul>
<h2 id="heading-why-malaysias-open-data-portals">Why Malaysia's Open Data Portals?</h2>
<p>The hackathon suggested targets like Vercel Commerce, Cal.com, and Hashnode. These all would've been solid picks.</p>
<p>But I wanted to test something local and closer to my day-to-day work instead. I also wanted a data-heavy site where the numbers on screen have to be accurate, as I work with numbers too on a daily basis.</p>
<p>Malaysia has three public open-data portals:</p>
<ul>
<li><p><a href="https://data.gov.my">data.gov.my</a>, run by MAMPU, the government's digital transformation agency</p>
</li>
<li><p><a href="https://open.dosm.gov.my">OpenDOSM</a>, run by the Department of Statistics</p>
</li>
<li><p><a href="https://data.moh.gov.my">KKMNow</a>, run by the Ministry of Health</p>
</li>
</ul>
<p>They're public, no authentication required, with documented APIs. Seemed like a good fit for an automated test suite. The data on them is what Malaysians use every day, so accuracy isn't optional.</p>
<h2 id="heading-what-is-passmark">What Is Passmark?</h2>
<p>Passmark is a Playwright library where the tests read like specs. Here's an example:</p>
<pre><code class="language-typescript">await runSteps({
  page,
  userFlow: "population dashboard smoke",
  steps: [
    { description: "Navigate to https://data.gov.my/dashboard/kawasanku" },
    {
      description: "Wait for the country-level Malaysia view to render",
      waitUntil: "A headline population number is visible",
    },
  ],
  assertions: [
    {
      assertion:
        "The page shows Malaysia's total population as a number greater than 20 million and less than 40 million",
    },
  ],
  test,
  expect,
});
</code></pre>
<p>There are no selectors, no <code>data-testid</code>, and no <code>page.locator()</code>. The assertion expresses what I care about, in the words I would use with a colleague.</p>
<p>On the first run, an AI agent drives the page and caches the resolved Playwright action to Redis. Every run after that replays at native Playwright speed with zero model calls.</p>
<p>When the UI changes and a cached action fails, the AI re-engages only for that step. Two assertion models (Claude and Gemini) vote. A third model arbitrates disagreements.</p>
<h2 id="heading-the-hero-spec-range-bounded-assertions">The Hero Spec: Range-Bounded Assertions</h2>
<p>Range-bounded assertions were the first shape of test I wrote, and the one I came back to most across the suite.</p>
<p>The idea is straightforward: check that a number on the page falls inside a sensible range, not that a specific element exists.</p>
<p>The image below is the Playwright report from the population spec, with all four range-bounded assertions passing.</p>
<img src="https://cdn.hashnode.com/uploads/covers/605584805f8d5121697263ca/4a5f70e6-8a75-489b-8d0d-7d4226653b1a.png" alt="Playwright HTML report detail for the population spec. Passmark's annotation reads: &quot;Total Population (2025) with a value of 34.2 million, which is between 20 million and 40 million.&quot; All four range-bounded assertions pass." style="display:block;margin:0 auto" width="1019" height="1569" loading="lazy">

<p>The range-bounded population test is the one that shows Passmark's real value.</p>
<p>Traditional Playwright asserts DOM structure. It confirms that an element with class <code>kpi-total</code> contains the text <code>34.2 million</code>. That tells you the page rendered, not whether the number makes sense.</p>
<p>A bug that shows Malaysia's population as <code>3.42 million</code> sails past any selector test. The DOM is correct. The number renders. Nothing breaks in the conventional sense.</p>
<p>Passmark reads the page, evaluates the claim, and fails because <code>3.42 million</code> falls outside the sane range. Two models vote. A hallucination by one model alone produces no false pass.</p>
<h3 id="heading-what-two-model-voting-doesnt-catch">What Two-Model Voting Doesn't Catch</h3>
<p>Voting defends against one model misreading the page. It doesn't defend against both models misreading the page the same way. If Claude and Gemini both parse "32.4 million" as "3.24 million" because of the same unusual spacing in the DOM, they agree, they vote pass, and the bug ships.</p>
<p>The mitigation is assertion design. Write assertions that are hard to misread. A range check ("between 20 million and 40 million") is harder for a model to get wrong than a prose check ("roughly 34 million"). Numerical bounds leave less room for interpretation than adjectives. The more your assertion looks like a unit test written in English, the less room the models have to disagree.</p>
<h2 id="heading-going-further-cross-field-math">Going Further: Cross-Field Math</h2>
<p>Range-bounded assertions are a good first step. They catch "is this number in the right ballpark?" But they don't catch "do these numbers agree with each other?"</p>
<p>For that, you need cross-field math. If a dashboard shows a total population and a breakdown by gender, those two things are supposed to agree. Male plus female should equal total. Ethnicity breakdown percentages should sum to 100.</p>
<pre><code class="language-typescript">test("Cross-field math: sex breakdown sums to total population", async ({ page }) =&gt; {
  test.setTimeout(180_000);
  await runSteps({
    page,
    userFlow: "population sex breakdown consistency",
    steps: [
      { description: "Navigate to https://data.gov.my/dashboard/kawasanku" },
      {
        description: "Wait for the Malaysia country-level view with breakdown data",
        waitUntil:
          "A headline total population figure is visible and a breakdown by sex is shown on the page",
      },
    ],
    assertions: [
      {
        assertion:
          "The male and female population values shown on the page add up to approximately the headline total population, within a 5% margin",
      },
      {
        assertion:
          "Any percentage-based breakdowns visible on the page (by sex, age, or ethnicity) sum to approximately 100% within a 2 percentage-point margin",
      },
      {
        assertion: "No breakdown value is negative or greater than the headline total",
      },
    ],
    test,
    expect,
  });
});
</code></pre>
<p>Try writing that in vanilla Playwright. You need selectors for the headline number, selectors for the breakdown components, number parsing with a comma-aware regex, and a margin calculation. Seventy to a hundred lines of code to verify three invariants a primary school student would call obvious.</p>
<p>The Passmark version is one spec. I ran it against <a href="https://data.gov.my/dashboard/kawasanku">Kawasanku's</a> live country view. All three assertions passed in 1.4 minutes. Passmark's annotation, verbatim:</p>
<blockquote>
<p><em>"The headline total population figure 'Malaysia has a population of 32,447,385 people.' is visible, and 'Gender And Age Distribution' is shown, which implies a breakdown by sex (male, female) will be available."</em></p>
</blockquote>
<p>Two models read the page, extract the numbers, do the arithmetic, and agree. When the dashboard changes layout in three months, the same assertion still works, because it never named a selector.</p>
<p>This is the class of test I want running against every dashboard product that I touch. Financial totals matching their line items. Percentages that sum to 100. Inventory counts equal to the sum of warehouse locations. This rarely gets checked today, because writing the check by hand outweighs the perceived value of running it.</p>
<h2 id="heading-what-i-found-across-three-runs">What I Found Across Three Runs</h2>
<table>
<thead>
<tr>
<th>Run</th>
<th>Passed</th>
<th>Key change</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>4 of 13 (31%)</td>
<td>Baseline. Wrote specs without looking at the target pages</td>
</tr>
<tr>
<td>2</td>
<td>8 of 13 (62%)</td>
<td>Rewrote five over-specified assertions using Passmark's own feedback</td>
</tr>
<tr>
<td>3</td>
<td>12 of 13 (92%)</td>
<td>Dropped one more wrong assertion, bumped timeouts, added retry, installed WebKit</td>
</tr>
</tbody></table>
<img src="https://cdn.hashnode.com/uploads/covers/605584805f8d5121697263ca/0181e92e-3341-4f1a-9830-cd4acd305e1c.png" alt="Playwright HTML report overview page showing the final run. 11 tests passed, 2 failed, total time 21.1 minutes across 13 specs." style="display:block;margin:0 auto" width="1012" height="1460" loading="lazy">

<p>Every passing spec after run 1 came from Passmark telling me, in plain English, why my assertion didn't match the page.</p>
<p>Here are three examples from run 1:</p>
<p>For <code>dataset-detail.spec.ts</code>, I asserted "an API usage snippet (curl or JS) is shown." knowingly that the page is using Python and I wanted to see what the result was. Passmark replied:</p>
<blockquote>
<p><em>"The page contains API usage snippets, but they are specifically for Python using the requests library. There are no snippets provided in curl or JavaScript formats."</em></p>
</blockquote>
<p>The page had snippets. I asked for the wrong languages. Fix: accept any language.</p>
<p>For <code>dashboard-population.spec.ts</code>, I asserted "a chart visualizing population by age or ethnicity is rendered." Passmark replied:</p>
<blockquote>
<p><em>"The current page displays charts for vital statistics such as Live Births, Deaths, and Natural Increase over time, but there is no chart visualizing population specifically by age groups or ethnicity."</em></p>
</blockquote>
<p>The charts are there. Not the slice I guessed. Fix: accept any chart about population.</p>
<p>For <code>kkmnow/hospital-utilisation.spec.ts</code>, I asserted a "headline bed-utilisation percentage." Passmark replied:</p>
<blockquote>
<p><em>"While there are multiple bed-utilisation percentages listed in tables and rankings further down the page, there is no prominent, top-level headline KPI figure displaying the overall bed-utilisation percentage."</em></p>
</blockquote>
<p>The numbers are there. I had asked for a layout the designers didn't build.</p>
<p><strong>This is the killer feature:</strong> Passmark's failure messages aren't stack traces. They're explanations. The AI read the page, compared it against my words, and pointed me at the fix. Nothing like a selector-based test throwing <code>TimeoutError: waiting for locator</code>.</p>
<h3 id="heading-the-debugging-loop">The Debugging Loop</h3>
<p>Once I saw the pattern, the loop became my main technique. Here's the procedure:</p>
<ol>
<li><p>Read the failure message word for word. Don't skim.</p>
</li>
<li><p>Trust it as a description of what is on the page. The AI has read the page. Your assertion has not.</p>
</li>
<li><p>Rewrite the assertion so it matches what's on the page. Broaden, narrow, or restate.</p>
</li>
<li><p>Run it again.</p>
</li>
</ol>
<p>The discipline is to not argue with the tool. The page is what the page is. Your assertion is what is wrong. Every time I tried to "fix" the page (convinced my assertion was right and the site was broken), I lost some time. Every time I took the failure message at face value and rewrote, the test passed on the next run.</p>
<p>This is the one of the changes in how I'll write E2E tests going forward. The feedback loop is the tool. Every failed assertion is a draft of the correct one.</p>
<h3 id="heading-the-two-specs-that-still-fail-are-the-most-interesting">The Two Specs That Still Fail Are the Most Interesting</h3>
<h4 id="heading-1-the-two-models-disagreed-and-the-arbiter-call-failed">1. The two models disagreed and the arbiter call failed.</h4>
<p>On <code>catalogue-search.spec.ts</code>, Claude voted fail (72% confidence) and Gemini voted pass (100% confidence) on the same assertion. I had written the assertion in a way that read two ways.</p>
<p>Passmark escalated to an arbiter model through OpenRouter. The call came back with a 504 from Cloudflare. The arbiter never ran. The suite failed the spec.</p>
<p>This is an honest limit, not a fluke. Any CI that runs Passmark depends on OpenRouter's availability. External gateway errors happen. My fix for the final run was a global retry wrapper around the OpenRouter call, and the 504 stopped being a problem in practice.</p>
<p>If you bring this to production CI, plan for retries and treat OpenRouter outages as a first-class failure mode in your runbook.</p>
<img src="https://cdn.hashnode.com/uploads/covers/605584805f8d5121697263ca/9e08d49a-00e6-4803-a86e-decfa0534308.png" alt="Playwright HTML report detail for the catalogue-search failure. Shows Claude and Gemini returning different verdicts on the same assertion, Passmark escalating to an arbiter model, and the arbiter call aborting with a 504 from Cloudflare." style="display:block;margin:0 auto" width="995" height="1259" loading="lazy">

<p>This failure taught me something about assertion design: my wording was ambiguous. Claude's reading was reasonable. Gemini's reading was reasonable. When you write tests in English, being precise about what you mean is part of writing a good test.</p>
<h4 id="heading-2-the-wait-condition-fired-too-early">2. The wait condition fired too early.</h4>
<p>On the KKMNow spec, I had <code>waitUntil: "A utilisation metric is visible"</code>. The page showed the section label "Hospital Bed Utilisation (%)" before the numbers finished loading. The wait step saw the label, decided the condition was met, and moved on. By the time the numbers rendered, the test had run out of time. Once the page was fully loaded, the range assertions would have passed on content.</p>
<blockquote>
<p><em>"The page displays multiple bed-utilisation percentages within the specified range (0% to 120%). For example, the ranked list shows Perlis at 93.1% and Melaka at 88.2%."</em></p>
</blockquote>
<img src="https://cdn.hashnode.com/uploads/covers/605584805f8d5121697263ca/2a9c8ade-f2ab-4c58-a56b-f7714bcabef5.png" alt="Playwright HTML report detail for the KKMNow spec. The test times out on the initial waitUntil, but Passmark's annotations show the range and state-selector assertions passed on content once the dashboard hydrated. Example quoted: &quot;Perlis at 93.1% and Melaka at 88.2%.&quot;" style="display:block;margin:0 auto" width="1000" height="1152" loading="lazy">

<p>The lesson: your <code>waitUntil</code> wording needs the same care as your assertion wording. Both are read by AI. A vague wait is as bad as a vague assertion.</p>
<h2 id="heading-what-it-cost-and-why-cache-rate-is-cost-rate">What It Cost, and Why Cache Rate Is Cost Rate</h2>
<p>Each of the three runs took about 20 minutes on 13 specs with a single worker. The hackathon's pooled OpenRouter key covered the AI costs, so I have no personal dollar figure to report.</p>
<p>The more useful cost finding is what gets cached.</p>
<pre><code class="language-bash">$ docker exec passmark-redis redis-cli DBSIZE
5
</code></pre>
<p>Five steps out of roughly 35 were cached across three runs. A 14% cache-hit rate. The Passmark README explains why:</p>
<blockquote>
<p><em>Only steps that produced a single tool call get cached. Multi-step sequences are considered non-deterministic.</em></p>
</blockquote>
<p>Most of my steps described multi-tool sequences. "Open the area selector and choose Selangor, then wait for navigation" becomes click, wait, verify. Those don't cache by design.</p>
<p>This matters for your budget. An 86% miss rate means 86% of your steps call a model on every run. The cost model is per-tool-call via OpenRouter.</p>
<p>To estimate your own bill: count non-atomic steps in your suite, multiply by your chosen model's per-call price at current OpenRouter rates, and the product is your recurring cost per run. Cache rate is cost rate.</p>
<p>The fix is authoring discipline. Split compound descriptions into atomic steps. Treat cache fill rate as a metric you track, not an implementation detail to ignore. A suite with 80% atomic steps costs a fifth of a suite with 14%.</p>
<h2 id="heading-the-pattern-worth-stealing">The Pattern Worth Stealing</h2>
<p>The idea here is bigger than Passmark.</p>
<p><strong>Check that the numbers on your dashboards make sense.</strong> Most teams don't. They should.</p>
<p>A one-line assertion like "the headline number is between 20 million and 40 million" catches several classes of bug regular tests miss.</p>
<p>Here are four common ones:</p>
<ul>
<li><p>The data pipeline divided by the wrong thing, so the number on screen is ten times too small.</p>
</li>
<li><p>A timezone bug made yesterday's total show up under tomorrow's date.</p>
</li>
<li><p>The data never refreshed, so users are looking at last week's numbers.</p>
</li>
<li><p>A locale flip swapped commas and decimals, so 1,234,567 is now reading as 1.234567.</p>
</li>
</ul>
<p>Civic portals were my target. The pattern applies anywhere a dashboard shows numbers. Fintech reports, SaaS analytics, healthcare metrics, e-commerce admin panels. Any screen where a number is supposed to mean something.</p>
<p>Most of these numbers never get tested. Writing the check by hand is tedious. You need a selector to find the number, code to parse it, code to handle units, and a margin calculation. Fifty lines for one check. Nobody bothers.</p>
<p>You don't need Passmark to steal the idea. The same check works in plain Playwright with <code>page.evaluate</code> and number parsing. The Passmark version is just more efficient to write and readable by anyone on the team, not only engineers.</p>
<h2 id="heading-honest-verdict">Honest Verdict</h2>
<p>Passmark works. Across three runs I went from 4 of 13 passing to 12 of 13 without touching a selector, guided by the tool's own feedback.</p>
<p>Still, the caveats are real:</p>
<ul>
<li><p>On a cold cache, every step waits for a model. Budget more wall-clock time than a selector suite.</p>
</li>
<li><p>In my suite only 14% of steps cached. The other 86% pays model cost on every run. Authoring discipline (atomic steps) is the difference between cents and dollars per run.</p>
</li>
<li><p>Two-model voting doesn't protect against both models misreading the same way. Write assertions that are hard to misread.</p>
</li>
<li><p>Every assertion depends on OpenRouter's availability. External gateway errors need a retry strategy before this runs in CI.</p>
</li>
</ul>
<p>What stuck with me: Passmark didn't make me better at Playwright. It made me write tests I would have skipped otherwise.</p>
<p>What I imagine myself doing at work:</p>
<ul>
<li><p>Run a small nightly Passmark suite against the critical dashboards, focused on range and freshness checks.</p>
</li>
<li><p>Keep traditional Playwright and Jest for everything that has to be fast and deterministic.</p>
</li>
<li><p>Treat every Passmark failure message as a specification of the page, not an error to argue with.</p>
</li>
</ul>
<p>Try this, even if you never touch Passmark. Pick a number on a dashboard you work with. Write a test that fails if the number is outside a sane range. See what breaks. That is the whole pattern and purpose of this article.</p>
<h2 id="heading-resources">Resources</h2>
<ul>
<li><p>Repo: <a href="https://github.com/LeeRenJie/passmark-hackathon">github.com/LeeRenJie/passmark-hackathon</a></p>
</li>
<li><p>Passmark: <a href="https://github.com/bug0inc/passmark">github.com/bug0inc/passmark</a></p>
</li>
<li><p>Breaking Apps Hackathon: <a href="https://hashnode.com/hackathons/breaking-things">hashnode.com/hackathons/breaking-things</a></p>
</li>
<li><p>Test targets: <a href="https://data.gov.my">data.gov.my</a>, <a href="https://open.dosm.gov.my">OpenDOSM</a>, <a href="https://data.moh.gov.my">KKMNow</a></p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Dynamic Web Scraper App with Playwright and React: A Step-by-Step Guide ]]>
                </title>
                <description>
                    <![CDATA[ Today we are going to build a small web scraper app. This application will scrape data from the Airbnb website and display it in a nice grid view. We will also add a Refresh button that will be able to trigger a new scraping round and update the resu... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/build-a-dynamic-web-scraper-app-with-playwright-and-react/</link>
                <guid isPermaLink="false">6787cb727ee491a35577e9d5</guid>
                
                    <category>
                        <![CDATA[ React ]]>
                    </category>
                
                    <category>
                        <![CDATA[ webscraping  ]]>
                    </category>
                
                    <category>
                        <![CDATA[ playwright ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Mihail Gaberov ]]>
                </dc:creator>
                <pubDate>Wed, 15 Jan 2025 14:51:30 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1736952641440/0aa6255b-45eb-4ae8-b5cb-d87648590e18.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Today we are going to build a small web scraper app. This application will scrape data from the Airbnb website and display it in a nice grid view. We will also add a Refresh button that will be able to trigger a new scraping round and update the results.</p>
<p>In order to make our app a bit more performant, we will utilize the browser’s local storage to store already scraped data so that we don’t trigger new scraping requests every time the browser is refreshed.</p>
<p>Here’s what it will look like:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1736256647942/56c176ad-2615-478a-94f8-e33b3b437b92.png" alt="Web Scrapper app interface" class="image--center mx-auto" width="2516" height="1708" loading="lazy"></p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a class="post-section-overview" href="#heading-how-to-spin-up-the-app-with-vite">How to Spin Up the App with Vite</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-build-the-server">How to Build the Server</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-build-the-front-end">How to Build the Front End</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-heading-how-to-deploy-to-rendercom">How to Deploy to</a> <a target="_blank" href="http://render.com">render.com</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<p>If you want to jump straight into the code, <a target="_blank" href="https://github.com/mihailgaberov/web-scraper">here</a> <a target="_blank" href="https://orderbook-mihailgaberov.vercel.app/">💁</a> is the GitHub repository with a detailed <a target="_blank" href="https://github.com/mihailgaberov/web-scraper/blob/main/README.md">README 🙌,</a> and <a target="_blank" href="https://scraper-fe.onrender.com/">here</a> you can see the live demo.</p>
<p>Now if you’re ready, let’s go step by step and see how to build and the deploy the app.</p>
<p>First, let’s get everything set up and ready to go.</p>
<h2 id="heading-how-to-spin-up-the-app-with-vite">How to Spin Up the App with Vite</h2>
<p>We will use the <a target="_blank" href="https://vite.dev/">Vite</a> build tool to quickly spin up a bare bones React application, equipped with TailwindCSS for styling. In order to do that, run this in your terminal app:</p>
<pre><code class="lang-bash">npm create vite@latest web-scraper -- --template react
</code></pre>
<p>And then install and configure TailwindCSS as follows:</p>
<pre><code class="lang-bash">npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
</code></pre>
<p>Add the paths to all of your template files in your <code>tailwind.config.js</code> file like this:</p>
<pre><code class="lang-javascript"><span class="hljs-comment">/** <span class="hljs-doctag">@type <span class="hljs-type">{import('tailwindcss').Config}</span> </span>*/</span>
<span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> {
  <span class="hljs-attr">content</span>: [
    <span class="hljs-string">"./index.html"</span>,
    <span class="hljs-string">"./src/**/*.{js,ts,jsx,tsx}"</span>,
  ],
  <span class="hljs-attr">theme</span>: {
    <span class="hljs-attr">extend</span>: {},
  },
  <span class="hljs-attr">plugins</span>: [],
}
</code></pre>
<p>Now you should have a brand new React application with Tailwind installed and configured.</p>
<p>Let’s start our work with the server.</p>
<h2 id="heading-how-to-build-the-server">How to Build the Server</h2>
<p>Since we are building a full stack application, the bare minimum we need to have in place is a server, a client, and an API. The API will live in the server world and the client app will call the endpoints it exposes in order to fetch the data we need to display on the front end.</p>
<h3 id="heading-set-up-the-http-server-with-expressjs">Set Up the HTTP Server with Express.js</h3>
<p>We are going to use the Express.js library to spin up an HTTP server that will handle our API requests. To do so, follow these steps:</p>
<p>First, install the necessary packages with this command:</p>
<pre><code class="lang-bash">npm install express cors playwright
</code></pre>
<p>Then create an empty <code>server.js</code> file in the project's root folder and add the following code:</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">import</span> express <span class="hljs-keyword">from</span> <span class="hljs-string">"express"</span>;
<span class="hljs-keyword">import</span> { chromium } <span class="hljs-keyword">from</span> <span class="hljs-string">"playwright"</span>;
<span class="hljs-keyword">import</span> cors <span class="hljs-keyword">from</span> <span class="hljs-string">"cors"</span>;
<span class="hljs-keyword">import</span> { scrapeListings } <span class="hljs-keyword">from</span> <span class="hljs-string">"./utils/scraper.js"</span>;

<span class="hljs-keyword">const</span> app = express();
<span class="hljs-keyword">const</span> PORT = <span class="hljs-number">5001</span>;

app.use(cors());

app.get(<span class="hljs-string">"/scrape"</span>, <span class="hljs-keyword">async</span> (req, res) =&gt; {
  <span class="hljs-keyword">let</span> browser;
  <span class="hljs-keyword">try</span> {
    browser = <span class="hljs-keyword">await</span> chromium.launch();
    <span class="hljs-keyword">const</span> listings = <span class="hljs-keyword">await</span> scrapeListings({ browser, <span class="hljs-attr">retryCount</span>: <span class="hljs-number">3</span> });
    res.json(listings);
  } <span class="hljs-keyword">catch</span> (error) {
    res.status(<span class="hljs-number">500</span>).json({ <span class="hljs-attr">error</span>: error.message });
  }
});

app.listen(PORT, <span class="hljs-function">() =&gt;</span> {
  <span class="hljs-built_in">console</span>.log(<span class="hljs-string">`Scraper server running on http://localhost:<span class="hljs-subst">${PORT}</span>`</span>);
});
</code></pre>
<p>Before we continue with the scraper, let me first explain what we are doing here.</p>
<p>This is a pretty simple setup of an Express server that is exposing an endpoint called “scrape“. Our client-side application (the front end) can send GET requests to this endpoint and receive the data returned as a result.</p>
<p>What’s important here is the async callback function that we pass to the <code>app.get</code> method. This is where we call our scraping function within a try/catch block. It will return the scraped data or an error if something goes wrong.</p>
<p>The last few lines indicate that our server will listen on the specified PORT, which is set to 5001 here, and display a message in the terminal to show that the server is running.</p>
<h3 id="heading-what-is-web-scraping">What is Web Scraping?</h3>
<p>Before diving into the code, I want to briefly explain web scraping if you’re unfamiliar with it. Web scraping involves automatically reading content from websites using a piece of software. This software is called a “web scraper“. In our case, the scraper is what’s in the <code>scrapeListing</code> function.</p>
<p>An essential part of the scraping process is finding something in the DOM tree of the target website that you can use to select the data we want to scrape. This something is known as a <strong>selector</strong>. Selectors can be different HTML elements, such as tags (h3, p, table) or attributes, like class names or IDs.</p>
<p>You can use various programming techniques or features of the programming language you’re using to create the scraper, aiming for better results when implementing the selecting part of the scraper.</p>
<p>In our case, we’re using <code>[itemprop="itemListElement"]</code> as the selector. But you might wonder, how did we figure this out and decide to use that? How do you know which selector to use?</p>
<p>This is where it gets tricky. You have to manually inspect the DOM tree of the target website and determine what would work best. That’s the case unless the site provides an API specifically designed for scrapers.</p>
<p>Here is how this looks like in practice. This is a screenshot from the Airbnb website:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1736588995918/fe2eb87f-e1cb-4474-894f-169fffb8a216.png" alt="Finding a selector from Airbnb website DOM tree" class="image--center mx-auto" width="2348" height="1258" loading="lazy"></p>
<p>Usually, you’ll need the information you’re scraping for some particular purpose, which means you’ll need to store it somewhere and then process it. This processing often involve some kind of visualization of the data. This is where our client application comes into play.</p>
<p>We will store the results of our scraping in the browser's local storage. Then, we will easily display those results in a grid layout using React and TailwindCSS. But before we get to all this, let’s go back to the code to understand how the scraping process is done.</p>
<h3 id="heading-set-up-playwright">Set Up Playwright</h3>
<p>For the scraping functionality, we will use another library that has gotten pretty famous over the last few years: <a target="_blank" href="https://playwright.dev/">Playwright</a>. It mainly serves as an e2e testing solution, but, as you’ll see now, we can use it for scraping the web as well.</p>
<p>We will put the scraping function in a separate file so that we have it all in order and keep the separation of concerns in place.</p>
<p>Create a new folder in the root directory and name it utils. Inside this folder, add a new file named scraper.js and include the following code:</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">const</span> MAX_RETRIES = <span class="hljs-number">3</span>;

<span class="hljs-keyword">const</span> validateListing = <span class="hljs-function">(<span class="hljs-params">listing</span>) =&gt;</span> {
  <span class="hljs-keyword">return</span> (
    <span class="hljs-keyword">typeof</span> listing.title === <span class="hljs-string">"string"</span> &amp;&amp;
    <span class="hljs-keyword">typeof</span> listing.price === <span class="hljs-string">"string"</span> &amp;&amp;
    <span class="hljs-keyword">typeof</span> listing.link === <span class="hljs-string">"string"</span>
  );
};

<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> scrapeListings = <span class="hljs-keyword">async</span> ({ browser, retryCount }) =&gt; {
  <span class="hljs-keyword">try</span> {
    <span class="hljs-keyword">const</span> page = <span class="hljs-keyword">await</span> browser.newPage();

    <span class="hljs-keyword">try</span> {
      <span class="hljs-keyword">await</span> page.goto(<span class="hljs-string">"https://www.airbnb.com/"</span>, { <span class="hljs-attr">waitUntil</span>: <span class="hljs-string">"load"</span> });

      <span class="hljs-keyword">await</span> page.waitForSelector(<span class="hljs-string">'[itemprop="itemListElement"]'</span>, {
        <span class="hljs-attr">timeout</span>: <span class="hljs-number">10000</span>,
      });

      <span class="hljs-keyword">const</span> listings = <span class="hljs-keyword">await</span> page.$$eval(
        <span class="hljs-string">'[itemprop="itemListElement"]'</span>,
        <span class="hljs-function">(<span class="hljs-params">elements</span>) =&gt;</span> {
          <span class="hljs-keyword">return</span> elements.slice(<span class="hljs-number">0</span>, <span class="hljs-number">10</span>).map(<span class="hljs-function">(<span class="hljs-params">element</span>) =&gt;</span> {
            <span class="hljs-keyword">const</span> title =
              element.querySelector(<span class="hljs-string">".t1jojoys"</span>)?.innerText || <span class="hljs-string">"N/A"</span>;
            <span class="hljs-keyword">const</span> price =
              element.querySelector(<span class="hljs-string">"._11jcbg2"</span>)?.innerText || <span class="hljs-string">"N/A"</span>;
            <span class="hljs-keyword">const</span> link = element.querySelector(<span class="hljs-string">"a"</span>)?.href || <span class="hljs-string">"N/A"</span>;
            <span class="hljs-keyword">return</span> { title, price, link };
          });
        }
      );

      <span class="hljs-keyword">const</span> validListings = listings.filter(validateListing);

      <span class="hljs-keyword">if</span> (validListings.length === <span class="hljs-number">0</span>) {
        <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-built_in">Error</span>(<span class="hljs-string">"No listings found"</span>);
      }

      <span class="hljs-keyword">return</span> validListings;
    } <span class="hljs-keyword">catch</span> (pageError) {
      <span class="hljs-keyword">if</span> (retryCount &lt; MAX_RETRIES) {
        <span class="hljs-built_in">console</span>.log(<span class="hljs-string">`Retrying... (<span class="hljs-subst">${retryCount + <span class="hljs-number">1</span>}</span>/<span class="hljs-subst">${MAX_RETRIES}</span>)`</span>);
        <span class="hljs-keyword">return</span> <span class="hljs-keyword">await</span> scrapeListings(retryCount + <span class="hljs-number">1</span>);
      } <span class="hljs-keyword">else</span> {
        <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-built_in">Error</span>(
          <span class="hljs-string">`Failed to scrape data after <span class="hljs-subst">${MAX_RETRIES}</span> attempts: <span class="hljs-subst">${pageError.message}</span>`</span>
        );
      }
    } <span class="hljs-keyword">finally</span> {
      <span class="hljs-keyword">await</span> page.close();
    }
  } <span class="hljs-keyword">catch</span> (browserError) {
    <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-built_in">Error</span>(<span class="hljs-string">`Failed to launch browser: <span class="hljs-subst">${browserError.message}</span>`</span>);
  } <span class="hljs-keyword">finally</span> {
    <span class="hljs-keyword">if</span> (browser) {
      <span class="hljs-keyword">await</span> browser.close();
    }
  }
};
</code></pre>
<h3 id="heading-retry-mechanism">Retry Mechanism</h3>
<p>At the top of the file, there's a constant called <code>MAX_RETRIES</code> used to implement a <strong>retry mechanism</strong>. This tactic is often used by web scrapers to bypass or overcome protections that some websites have against scraping. We will see how to use it below.</p>
<p>It's important to mention the legal aspect here as well. Always respect the terms and conditions along with the privacy policy of the website you plan to scrape. Use these techniques only to handle technical challenges, not to break the law.</p>
<p>A small helper function follows that you can use to validate the received data. Nothing interesting here.</p>
<p>Next is the main scraping function. We’re passing the browser object, provided by Playwright, and the number of retry attempts as arguments to the function.</p>
<p>There are two try/catch blocks to handle possible failures: one for launching the browser (in headless mode, meaning you won't see anything) and one for the scraping process. In the latter, we’ll use Playwright's features to request the website, wait until the page is fully loaded, and then locate the selector we defined.</p>
<p>In the callback function we pass to <code>$$eval</code>, we access the elements returned by the scraping, allowing us to process them and get the data we want. In this case, I’m using three selectors to fetch the title, price, and link of the property. The first two are class names, and the last is the HTML tag <code>&lt;a&gt;</code>.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1736589673793/79cd8e05-eef7-4887-b3cb-6d2d95e9d5dc.png" alt="Selecting the price" class="image--center mx-auto" width="2360" height="1244" loading="lazy"></p>
<p>Then we return an object, { title, price, link }, with the fetched data, that is the values of the three properties. And in the end of the try part, we validate the results before returning them to the front end.</p>
<p>What follows in the catch part is the implementation of the retry mechanism we talked about a minute ago:</p>
<pre><code class="lang-javascript"> } <span class="hljs-keyword">catch</span> (pageError) {
      <span class="hljs-keyword">if</span> (retryCount &lt; MAX_RETRIES) {
        <span class="hljs-built_in">console</span>.log(<span class="hljs-string">`Retrying... (<span class="hljs-subst">${retryCount + <span class="hljs-number">1</span>}</span>/<span class="hljs-subst">${MAX_RETRIES}</span>)`</span>);
        <span class="hljs-keyword">return</span> <span class="hljs-keyword">await</span> scrapeListings(retryCount + <span class="hljs-number">1</span>);
      } <span class="hljs-keyword">else</span> {
        <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-built_in">Error</span>(
          <span class="hljs-string">`Failed to scrape data after <span class="hljs-subst">${MAX_RETRIES}</span> attempts: <span class="hljs-subst">${pageError.message}</span>`</span>
        );
      }
    }
</code></pre>
<p>If an error occurs during the reading process, we enter the catch phase and check if the retry count is below the maximum limit we set. If it is, we try again by running the function recursively. Otherwise, we throw an error indicating that the scraping failed and the maximum retry attempts have been reached.</p>
<p>That's all you need to set up basic web scraping of the Airbnb homepage.</p>
<p>You can see all this in the <a target="_blank" href="https://github.com/mihailgaberov/web-scraper">Github repo</a> of the project so don’t worry if you missed something here.</p>
<h2 id="heading-how-to-build-the-front-end">How to Build the Front End</h2>
<p>Now it's time to put the scraped data to use.</p>
<p>Let's display the last 10 properties in a grid layout, allowing you (or anyone) to open them by clicking on their links. We will also add a <code>Refresh</code> feature that lets you perform a new scrape to get the most up-to-date data.</p>
<p>This is how the structure of the front end part of the project looks:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1736591529574/5cf5c62f-283b-4cfc-afa4-3b2b92a3ddae.png" alt="Project structure" class="image--center mx-auto" width="410" height="452" loading="lazy"></p>
<p>We have a simple app structure: one main container (App.jsx) that holds all the components and includes some logic for making requests to the API and storing the data in local storage.</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">import</span> { useEffect, useState } <span class="hljs-keyword">from</span> <span class="hljs-string">"react"</span>;
<span class="hljs-keyword">import</span> { useLocalStorage } <span class="hljs-keyword">from</span> <span class="hljs-string">"@uidotdev/usehooks"</span>;
<span class="hljs-keyword">import</span> axios <span class="hljs-keyword">from</span> <span class="hljs-string">"axios"</span>;
<span class="hljs-keyword">import</span> Footer <span class="hljs-keyword">from</span> <span class="hljs-string">"./components/Footer"</span>;
<span class="hljs-keyword">import</span> Header <span class="hljs-keyword">from</span> <span class="hljs-string">"./components/Header"</span>;
<span class="hljs-keyword">import</span> RefreshButton <span class="hljs-keyword">from</span> <span class="hljs-string">"./components/RefreshButton"</span>;
<span class="hljs-keyword">import</span> Grid <span class="hljs-keyword">from</span> <span class="hljs-string">"./components/Grid"</span>;
<span class="hljs-keyword">import</span> Loader <span class="hljs-keyword">from</span> <span class="hljs-string">"./components/Loader"</span>;

<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">App</span>(<span class="hljs-params"></span>) </span>{
  <span class="hljs-keyword">const</span> [listings, setListings] = useLocalStorage(<span class="hljs-string">"properties"</span>, []);
  <span class="hljs-keyword">const</span> [loading, setLoading] = useState(<span class="hljs-literal">false</span>);
  <span class="hljs-keyword">const</span> [error, setError] = useState(<span class="hljs-string">""</span>);

  <span class="hljs-keyword">const</span> fetchListings = <span class="hljs-keyword">async</span> () =&gt; {
    setLoading(<span class="hljs-literal">true</span>);
    setError(<span class="hljs-string">""</span>);
    setListings([]);

    <span class="hljs-keyword">try</span> {
      <span class="hljs-keyword">const</span> response = <span class="hljs-keyword">await</span> axios.get(<span class="hljs-string">"http://localhost:5001/scrape"</span>);
      <span class="hljs-keyword">if</span> (response.data.length === <span class="hljs-number">0</span>) {
        <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-built_in">Error</span>(<span class="hljs-string">"No listings found"</span>);
      }
      setListings(response.data);
    } <span class="hljs-keyword">catch</span> (err) {
      setError(
        err.response?.data?.error ||
          <span class="hljs-string">"Failed to fetch listings. Please try again."</span>
      );
    } <span class="hljs-keyword">finally</span> {
      setLoading(<span class="hljs-literal">false</span>);
    }
  };

  useEffect(<span class="hljs-function">() =&gt;</span> {
    <span class="hljs-keyword">if</span> (listings.length === <span class="hljs-number">0</span>) {
      fetchListings();
    }
  }, []);

  <span class="hljs-keyword">return</span> (
    <span class="xml"><span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">className</span>=<span class="hljs-string">"flex flex-col items-center justify-center min-h-screen bg-gray-100"</span>&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">Header</span> /&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">RefreshButton</span> <span class="hljs-attr">callback</span>=<span class="hljs-string">{fetchListings}</span> <span class="hljs-attr">loading</span>=<span class="hljs-string">{loading}</span> /&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">main</span> <span class="hljs-attr">className</span>=<span class="hljs-string">"flex flex-col items-center justify-center flex-1 w-full px-4 relative"</span>&gt;</span>
        {error &amp;&amp; <span class="hljs-tag">&lt;<span class="hljs-name">p</span> <span class="hljs-attr">className</span>=<span class="hljs-string">"text-red-500"</span>&gt;</span>{error}<span class="hljs-tag">&lt;/<span class="hljs-name">p</span>&gt;</span>}
        {loading ? <span class="hljs-tag">&lt;<span class="hljs-name">Loader</span> /&gt;</span> : <span class="hljs-tag">&lt;<span class="hljs-name">Grid</span> <span class="hljs-attr">listings</span>=<span class="hljs-string">{listings}</span> /&gt;</span>}
      <span class="hljs-tag">&lt;/<span class="hljs-name">main</span>&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">Footer</span> /&gt;</span>
    <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span></span>
  );
}

<span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> App;
</code></pre>
<p>All components are placed in the <a target="_blank" href="https://github.com/mihailgaberov/web-scraper/tree/main/src/components">components</a> directory (this is what I call a surprise, ah:)). Most of the components are quite simple, and I included them to give the app a more complete appearance.</p>
<p>The <a target="_blank" href="https://github.com/mihailgaberov/web-scraper/blob/main/src/components/Header.jsx">Header</a> displays the top bar. The <a target="_blank" href="https://github.com/mihailgaberov/web-scraper/blob/main/src/components/RefreshButton.jsx">RefreshButton</a> is used to send a new request and get the latest data. In the <code>&lt;main&gt;</code> section, we either show an error message if fetching fails, or we display a <a target="_blank" href="https://github.com/mihailgaberov/web-scraper/blob/main/src/components/Loader.jsx">Loader</a> component and a <a target="_blank" href="https://github.com/mihailgaberov/web-scraper/blob/main/src/components/Grid.jsx">Grid</a> component.</p>
<p>The loading part is straightforward. The Grid component is the interesting one. We pass the scraping results to it using a prop called 'listings'. Inside, we use a simple map() function to go through them and display the properties. We use Tailwind to style the grid, ensuring the properties are neatly listed and look good on both desktop and mobile screens.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1736593771753/0f56930c-2944-452c-a2cd-4c6b69c1d996.png" alt="The app looks good on smaller screens as well." class="image--center mx-auto" width="1338" height="1732" loading="lazy"></p>
<p>And in the end with we have the <a target="_blank" href="https://github.com/mihailgaberov/web-scraper/blob/main/src/components/Footer.jsx">Footer</a> component that renders a simple bar with text. Again, I’ve added it just for completeness.</p>
<h2 id="heading-how-to-deploy-to-rendercom">How to Deploy to render.com</h2>
<p>Maybe a little over a year ago, I needed a place to deploy full-stack applications, ideally for free, since they were just for educational purposes.</p>
<p>After some research, I found a platform called <a target="_blank" href="https://dashboard.render.com/">Render</a> and managed to deploy an app with both client and server parts, getting it to work online. I left it there until now. Since our scraper requires both parts to function properly, we will deploy it there and have it working online, as you can see <a target="_blank" href="https://scraper-fe.onrender.com/">here</a>.</p>
<p>To do this, you need to create an account with Render and use their dashboard application. The process is simple, but I'll include a few screenshots below for clarity.</p>
<p>This is the Overview page where you can see all your projects:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1736594350231/eac21d82-f43d-44be-8027-6ac25c86f740.png" alt="Render platform overview page" class="image--center mx-auto" width="2494" height="784" loading="lazy"></p>
<p>Here is the Project page where you can view and manage your projects. In our case, we can see both the server and the client app as separate services.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1736594435708/a4329d74-f529-46a0-a193-d6a1ade145ad.png" alt="a4329d74-f529-46a0-a193-d6a1ade145ad" class="image--center mx-auto" width="1874" height="1130" loading="lazy"></p>
<p>You can click on each service to open its page, where you can view the deployments and the commits that triggered them. You can find even more details if you explore further.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1736594566165/39e78a87-d8e7-4df3-8e1b-02eb0eee799c.png" alt="39e78a87-d8e7-4df3-8e1b-02eb0eee799c" class="image--center mx-auto" width="1846" height="1690" loading="lazy"></p>
<p>You should be able to manage the deployment process on your own, as everything is clearly explained. But if you need help, feel free to reach out.</p>
<p>I should mention that I am not affiliated with Render in any way and I am not receiving any benefits for mentioning them here. I just found it to be a useful tool and wanted to share it with you – so I’ve used it here.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>A web scraper app can be a powerful tool for gathering data, but there are several areas for improvement and important considerations to keep in mind here.</p>
<p>Firstly, you can enhance the app's performance and efficiency by optimizing the scraping process and ensuring that the data is stored and processed effectively. You can also implement more robust error handling and retry mechanisms to improve the reliability of the scraper.</p>
<p>Also, keep in mind that ethical scraping is crucial, and it's important to always respect the terms of service and privacy policies of the websites you are scraping. This includes not overloading the website with requests and ensuring that the data is used responsibly. Always seek permission from the site if required and consider using APIs provided by the website as a more ethical and reliable alternative.</p>
<p>Lastly, respecting the law is paramount. Ensure that your scraping activities comply with legal regulations and guidelines to avoid any potential legal issues. By focusing on these aspects, you can build a more effective, ethical, and legally compliant web scraper app.</p>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
