<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: PetrDev</title>
    <description>The latest articles on DEV Community by PetrDev (@petr_savenok).</description>
    <link>https://dev.to/petr_savenok</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3406121%2Ff4dcd54a-b8db-462a-9a9c-5a884b291d98.jpg</url>
      <title>DEV Community: PetrDev</title>
      <link>https://dev.to/petr_savenok</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/petr_savenok"/>
    <language>en</language>
    <item>
      <title>Website Compliance Monitoring Tools Compared (2026)</title>
      <dc:creator>PetrDev</dc:creator>
      <pubDate>Mon, 26 Oct 2026 13:03:16 +0000</pubDate>
      <link>https://dev.to/petr_savenok/website-compliance-monitoring-tools-compared-2026-2m1f</link>
      <guid>https://dev.to/petr_savenok/website-compliance-monitoring-tools-compared-2026-2m1f</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://site2pdf.online/blog/website-compliance-monitoring-tools/" rel="noopener noreferrer"&gt;Site2PDF blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A compliance team does not need to know that a page changed. It needs to show, months later, what the page said on a given day — and that the copy it is showing has not been touched since.&lt;/p&gt;

&lt;p&gt;The tools sold for it fall into four groups. This guide compares website compliance monitoring tools by group: what each records, what it proves, what it costs as of October 2026, and where it stops.&lt;/p&gt;




&lt;h2&gt;
  
  
  Quick answer
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What you need&lt;/th&gt;
&lt;th&gt;Group&lt;/th&gt;
&lt;th&gt;Examples&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A regulator-ready archive of every page, WORM storage, eDiscovery export&lt;/td&gt;
&lt;td&gt;Regulated archiving platforms&lt;/td&gt;
&lt;td&gt;Pagefreezer, Hanzo, MirrorWeb, Smarsh&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;An alert when a disclaimer, price or policy page changes&lt;/td&gt;
&lt;td&gt;Change monitors&lt;/td&gt;
&lt;td&gt;Visualping, ChangeTower, Fluxguard&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A dated image of the same pages every day or week&lt;/td&gt;
&lt;td&gt;Scheduled screenshot archives&lt;/td&gt;
&lt;td&gt;Stillio&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One capture you can hand to a lawyer, with a hash or signature&lt;/td&gt;
&lt;td&gt;Evidence capture&lt;/td&gt;
&lt;td&gt;Page Vault, Browsertrix, Site2PDF&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  What compliance monitoring has to prove
&lt;/h2&gt;

&lt;p&gt;Three questions decide which tool fits, and the first is the easiest to answer.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Did the page change?&lt;/strong&gt; A diff and an alert. Most tools below do some version of this.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What exactly did it say on that date?&lt;/strong&gt; A stored copy of the whole page, dated, kept for the retention period your rules set.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Can anyone show the copy was not edited?&lt;/strong&gt; A hash or a digital signature recorded at capture time, and a way for someone outside your company to check it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Regulated industries spell these out. In US financial services, &lt;a href="https://www.finra.org/rules-guidance/rulebooks/finra-rules/2210" rel="noopener noreferrer"&gt;FINRA Rule 2210&lt;/a&gt; requires firms to keep a copy of each retail communication — a website counts — with "the dates of first and (if applicable) last use", for the period set by &lt;a href="https://www.law.cornell.edu/cfr/text/17/240.17a-4" rel="noopener noreferrer"&gt;SEC Rule 17a-4&lt;/a&gt;. That rule asks for "not less than three years" and for electronic records either "in a non-rewriteable, non-erasable format" or with "a complete time-stamped audit trail."&lt;/p&gt;

&lt;p&gt;Outside finance the rules are looser, but the questions are the same: a price shown on a date, a terms page in force when a customer signed up, a health claim that was or was not on the page.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. Regulated archiving platforms
&lt;/h2&gt;

&lt;p&gt;These are built for questions 2 and 3 at the scale of a whole organisation. None of the four publishes a price — every one is quote or demo only (checked October 2026).&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://www.pagefreezer.com/website-archiving/" rel="noopener noreferrer"&gt;Pagefreezer&lt;/a&gt;&lt;/strong&gt; says "every archived record is stored with a cryptographic hash value, timestamp, and digital signature," exports to "PDF, CSV, and eDiscovery-compatible formats," and targets FOIA, SEC and FINRA work. Its &lt;a href="https://www.pagefreezer.com/security/" rel="noopener noreferrer"&gt;security page&lt;/a&gt; adds a timestamp from an external time-stamp authority.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://hanzo.co/chronicle/" rel="noopener noreferrer"&gt;Hanzo Chronicle&lt;/a&gt;&lt;/strong&gt; stores captures as WARC in "immutable (WORM) storage" with "SHA-256 hash values," exports "auditor-ready PDF packages," and alerts teams "when disclaimers are changed or missing."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://www.mirrorweb.com/platform/website-archiving/" rel="noopener noreferrer"&gt;MirrorWeb&lt;/a&gt;&lt;/strong&gt; archives in "ISO-standard WARC format, stored in a WORM environment," and names SEC 17a-4, FINRA 2210 and FCA COBS 4.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://www.smarsh.com/products/web-archive" rel="noopener noreferrer"&gt;Smarsh Web Archive&lt;/a&gt;&lt;/strong&gt; sets capture frequency "daily, weekly, or custom" per domain, keeps each capture "time-stamped and stored in its original form," and lets you "compare any two dates."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Where they stop:&lt;/strong&gt; verification runs through the vendor. None of these pages publishes a public key or a stand-alone verifier, so a third party checks the record by trusting the vendor's portal, report or affidavit. Expect a sales call and an annual contract.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Change monitors
&lt;/h2&gt;

&lt;p&gt;Change monitors answer question 1 well: they check a page on a schedule, show a visual or text diff, and send an alert.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://visualping.io/pricing" rel="noopener noreferrer"&gt;Visualping&lt;/a&gt;&lt;/strong&gt; — as of October 2026, free for 5 pages and 150 checks a month; Personal plans from $14/mo; Business features need a subscription of at least $140/mo. "Change history is kept for three months by default," one year on Business.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://changetower.com/pricing/" rel="noopener noreferrer"&gt;ChangeTower&lt;/a&gt;&lt;/strong&gt; — as of October 2026, free for 3 pages checked daily with 30 days of history; Business is $78/mo for 200 pages, hourly, six months of history. Full-page archiving on every check and review workflows are Enterprise only, priced on request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://app.fluxguard.com/pricing" rel="noopener noreferrer"&gt;Fluxguard&lt;/a&gt;&lt;/strong&gt; — as of October 2026, free for 3 sites; paid plans $110, $220 and $550 a month, with checks "as frequent as every 5 minutes" on the top plan.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Where they stop:&lt;/strong&gt; the record is the diff and a limited history window. When you need the page as it looked on a date two years ago, the monitor may no longer hold it, and none of these three describes a hash or signature on the stored copy. The workflow for turning alerts into a kept record is in &lt;a href="https://site2pdf.online/blog/how-to-document-website-changes?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=website-compliance-monitoring-tools" rel="noopener noreferrer"&gt;how to document website changes&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Scheduled screenshot archives
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://www.stillio.com/pricing" rel="noopener noreferrer"&gt;Stillio&lt;/a&gt;&lt;/strong&gt; captures the same URLs daily, weekly or monthly and files the screenshots by date. As of October 2026 it starts at $29/mo for up to 5 pages and $79/mo for 25; every plan keeps screenshots for 36 months "provided there is an active subscription," with a 14-day trial.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where it stops:&lt;/strong&gt; a screenshot is an image. It shows the page, but text inside it is not searchable without OCR, and the file carries nothing that proves it was not edited. The trade-off between image and document formats is in &lt;a href="https://site2pdf.online/blog/pdf-vs-screenshot-for-archiving?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=website-compliance-monitoring-tools" rel="noopener noreferrer"&gt;PDF vs screenshot for archiving&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Evidence capture: one page you can defend
&lt;/h2&gt;

&lt;p&gt;This group answers question 3 for a specific page, on demand or on a schedule.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://www.page-vault.com/pricing/" rel="noopener noreferrer"&gt;Page Vault&lt;/a&gt;&lt;/strong&gt; is built for litigation, and &lt;a href="https://www.page-vault.com/webpage-capture-software/" rel="noopener noreferrer"&gt;every capture includes&lt;/a&gt; a timestamp and a SHA-256 hash. Its On Demand service, where Page Vault captures for you, starts at $199 for websites as of October 2026; the browser software is quote only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://webrecorder.net/browsertrix/pricing/" rel="noopener noreferrer"&gt;Browsertrix&lt;/a&gt;&lt;/strong&gt; from Webrecorder crawls sites into WACZ files and says "crawl outputs are digitally signed to ensure a provable chain of custody." It is free for 7 days, then from $30/mo as of October 2026. WACZ opens in archive tools, not in a PDF reader.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://site2pdf.online/?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=website-compliance-monitoring-tools#tool" rel="noopener noreferrer"&gt;Site2PDF&lt;/a&gt;&lt;/strong&gt; captures a page or a whole site to PDF, PNG or JPG. Its &lt;strong&gt;legal capture&lt;/strong&gt; option stamps the source URL and capture time on the PDF and issues a certificate with the file's SHA-256 hash and an RSA-2048 signature. The public key is published at a fixed address, so anyone can check a file with two stock commands — &lt;code&gt;sha256sum&lt;/code&gt; and &lt;code&gt;openssl dgst -verify&lt;/code&gt; — without an account and without trusting Site2PDF. Single-page legal capture is included on every plan, Free included.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Site2PDF also schedules captures (scheduled runs are plain archives; legal capture is a separate, on-demand step). A &lt;strong&gt;watch&lt;/strong&gt; re-captures a page or a fixed list of pages daily, weekly or monthly, compares the page text with the last run, and can email you only when it changed. Free accounts get 3 watches; on Starter and above each capture can also go straight to Google Drive. Plans are on the &lt;a href="https://site2pdf.online/?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=website-compliance-monitoring-tools#pricing" rel="noopener noreferrer"&gt;pricing grid&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where it stops:&lt;/strong&gt; the fastest schedule is daily, so it does not replace a monitor that checks every few minutes. Files are kept for a period that depends on the plan, not in WORM storage, so the long-term copy is yours to store. The capture time is recorded by Site2PDF rather than an external time-stamp authority. And there is no WARC output or eDiscovery load file. The practices that make a capture hold up are in &lt;a href="https://site2pdf.online/blog/website-archiving-for-legal-purposes?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=website-compliance-monitoring-tools" rel="noopener noreferrer"&gt;website archiving for legal purposes&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Side by side
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Change alerts&lt;/th&gt;
&lt;th&gt;Stored copy&lt;/th&gt;
&lt;th&gt;Hash / signature&lt;/th&gt;
&lt;th&gt;Checkable without the vendor&lt;/th&gt;
&lt;th&gt;Published price&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Pagefreezer&lt;/td&gt;
&lt;td&gt;Not stated&lt;/td&gt;
&lt;td&gt;PDF, CSV, eDiscovery&lt;/td&gt;
&lt;td&gt;Hash, signature, TSA timestamp&lt;/td&gt;
&lt;td&gt;Not published&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hanzo&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;WARC, PDF package&lt;/td&gt;
&lt;td&gt;SHA-256&lt;/td&gt;
&lt;td&gt;Not published&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MirrorWeb&lt;/td&gt;
&lt;td&gt;Not stated&lt;/td&gt;
&lt;td&gt;WARC&lt;/td&gt;
&lt;td&gt;Not stated on page&lt;/td&gt;
&lt;td&gt;Not published&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Smarsh&lt;/td&gt;
&lt;td&gt;Date compare&lt;/td&gt;
&lt;td&gt;Screenshot, source&lt;/td&gt;
&lt;td&gt;Not stated on page&lt;/td&gt;
&lt;td&gt;Not published&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Visualping&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Diff, 3–12 months&lt;/td&gt;
&lt;td&gt;Not stated&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ChangeTower&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Diff; full page on Enterprise&lt;/td&gt;
&lt;td&gt;Not stated&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fluxguard&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;3–100 versions per page&lt;/td&gt;
&lt;td&gt;Not stated&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stillio&lt;/td&gt;
&lt;td&gt;Not stated&lt;/td&gt;
&lt;td&gt;Screenshot, 36 months&lt;/td&gt;
&lt;td&gt;Not stated&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Page Vault&lt;/td&gt;
&lt;td&gt;Not stated&lt;/td&gt;
&lt;td&gt;PDF, video&lt;/td&gt;
&lt;td&gt;SHA-256&lt;/td&gt;
&lt;td&gt;Not published&lt;/td&gt;
&lt;td&gt;On Demand only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Browsertrix&lt;/td&gt;
&lt;td&gt;Not stated&lt;/td&gt;
&lt;td&gt;WACZ&lt;/td&gt;
&lt;td&gt;Signed&lt;/td&gt;
&lt;td&gt;Yes (open spec)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Site2PDF&lt;/td&gt;
&lt;td&gt;Text change&lt;/td&gt;
&lt;td&gt;PDF, PNG, JPG&lt;/td&gt;
&lt;td&gt;SHA-256 + RSA&lt;/td&gt;
&lt;td&gt;Yes (public key)&lt;/td&gt;
&lt;td&gt;&lt;a href="https://site2pdf.online/?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=website-compliance-monitoring-tools#pricing" rel="noopener noreferrer"&gt;See pricing&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Which one should you use?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A regulated firm with a recordkeeping rule&lt;/strong&gt; (SEC, FINRA, FCA) → a regulated archiving platform. The WORM storage, retention controls and eDiscovery export are the product; book the demos.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Marketing or legal wants to know when a page changes&lt;/strong&gt; → a change monitor. Pick by page count and check frequency.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You need a dated picture of the same pages for years&lt;/strong&gt; → a scheduled screenshot archive like Stillio.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A dispute, a complaint or an audit about specific pages&lt;/strong&gt; → an evidence capture. A signed PDF someone can verify with &lt;code&gt;openssl&lt;/code&gt;, or a signed WACZ if your counterpart works with web-archive tools.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A small team that needs all three questions answered on a budget&lt;/strong&gt; → combine them: scheduled captures with change emails for the running record, and a signed capture of any page that becomes evidence.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a wider list that includes free public archives and self-hosted tools, see &lt;a href="https://site2pdf.online/blog/best-web-archiving-tools-2026?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=website-compliance-monitoring-tools" rel="noopener noreferrer"&gt;the best web archiving tools of 2026&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is a screenshot enough for a compliance record?
&lt;/h3&gt;

&lt;p&gt;For internal tracking, often yes. For a regulator or a court, a screenshot alone is weak: it carries no proof that it was not edited. A capture with a hash recorded at the time, ideally signed, is stronger.&lt;/p&gt;

&lt;h3&gt;
  
  
  How long do I need to keep website records?
&lt;/h3&gt;

&lt;p&gt;It depends on your rules. For US broker-dealers, SEC Rule 17a-4(b) sets "not less than three years, the first two years in an easily accessible place." Check the rules that apply to you, then check the tool's retention against them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can a third party verify a capture without trusting the tool that made it?
&lt;/h3&gt;

&lt;p&gt;Only if the tool publishes how. Browsertrix signs WACZ files under an open specification, and Site2PDF publishes the public key that checks its signatures. The enterprise platforms above do not publish a key or verifier on their product pages.&lt;/p&gt;




&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;Decide which of the three questions you must answer — what changed, what it said, and that the copy is untouched — before you compare prices. Regulated firms need a platform built for their recordkeeping rule. Everyone else can pair scheduled captures with a signed capture of the pages that matter: &lt;a href="https://site2pdf.online/?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=website-compliance-monitoring-tools#tool" rel="noopener noreferrer"&gt;Site2PDF&lt;/a&gt; does both, and its signatures check against a key anyone can download.&lt;/p&gt;




&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.finra.org/rules-guidance/rulebooks/finra-rules/2210" rel="noopener noreferrer"&gt;FINRA Rule 2210 — Communications with the Public&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.law.cornell.edu/cfr/text/17/240.17a-4" rel="noopener noreferrer"&gt;17 CFR 240.17a-4 — Records to be preserved by brokers and dealers&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.pagefreezer.com/website-archiving/" rel="noopener noreferrer"&gt;Pagefreezer — Website archiving&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.pagefreezer.com/security/" rel="noopener noreferrer"&gt;Pagefreezer — Security&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://hanzo.co/chronicle/" rel="noopener noreferrer"&gt;Hanzo — Chronicle&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.mirrorweb.com/platform/website-archiving/" rel="noopener noreferrer"&gt;MirrorWeb — Website archiving&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.smarsh.com/products/web-archive" rel="noopener noreferrer"&gt;Smarsh — Web Archive&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://visualping.io/pricing" rel="noopener noreferrer"&gt;Visualping — Pricing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://changetower.com/pricing/" rel="noopener noreferrer"&gt;ChangeTower — Pricing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://app.fluxguard.com/pricing" rel="noopener noreferrer"&gt;Fluxguard — Pricing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.stillio.com/pricing" rel="noopener noreferrer"&gt;Stillio — Pricing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.page-vault.com/pricing/" rel="noopener noreferrer"&gt;Page Vault — Pricing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.page-vault.com/webpage-capture-software/" rel="noopener noreferrer"&gt;Page Vault — Webpage capture software&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://webrecorder.net/browsertrix/" rel="noopener noreferrer"&gt;Webrecorder — Browsertrix&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://webrecorder.net/browsertrix/pricing/" rel="noopener noreferrer"&gt;Webrecorder — Browsertrix pricing&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>compliance</category>
      <category>productivity</category>
      <category>webdev</category>
      <category>security</category>
    </item>
    <item>
      <title>How to Save Web Sources for Research (and Cite Them Later)</title>
      <dc:creator>PetrDev</dc:creator>
      <pubDate>Mon, 19 Oct 2026 07:11:17 +0000</pubDate>
      <link>https://dev.to/petr_savenok/how-to-save-web-sources-for-research-and-cite-them-later-16a5</link>
      <guid>https://dev.to/petr_savenok/how-to-save-web-sources-for-research-and-cite-them-later-16a5</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://site2pdf.online/blog/save-web-sources-for-research/" rel="noopener noreferrer"&gt;Site2PDF blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A link in a bibliography that returns 404 a year later is a citation that cannot be checked — and that outcome is routine, not rare. If a web source is going to end up in a paper, a report or a brief, saving it needs to produce three things: a copy you can quote from, a date the reader can trust, and a format that still opens on the day someone wants to check your work.&lt;/p&gt;

&lt;p&gt;The right method depends on which of those you need most. Here are five ways researchers save sources, what each one is good for, and how to cite the result.&lt;/p&gt;




&lt;h2&gt;
  
  
  Quick answer
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What you need&lt;/th&gt;
&lt;th&gt;Use&lt;/th&gt;
&lt;th&gt;What you end up with&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A quick note of what the page said&lt;/td&gt;
&lt;td&gt;Browser &lt;strong&gt;Print → Save as PDF&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;One PDF on disk, searchable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dated proof of what the page showed&lt;/td&gt;
&lt;td&gt;A full-page capture with the date on it&lt;/td&gt;
&lt;td&gt;Searchable PDF, visible capture date&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A citable public URL that will not rot&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Internet Archive — Save Page Now&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;A permanent &lt;code&gt;web.archive.org/...&lt;/code&gt; link&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A library you can cite from inside Word&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Zotero&lt;/strong&gt; with the browser connector&lt;/td&gt;
&lt;td&gt;Snapshot + metadata + citation key&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A whole report, blog or docs site&lt;/td&gt;
&lt;td&gt;A whole-site PDF archiver&lt;/td&gt;
&lt;td&gt;One searchable PDF of every page&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Why saving the link is not enough
&lt;/h2&gt;

&lt;p&gt;A URL is not the source — it is a pointer, and pointers rot. The decay has a name (&lt;a href="https://en.wikipedia.org/wiki/Link_rot" rel="noopener noreferrer"&gt;link rot&lt;/a&gt;): Harvard Law's Perma.cc project measured that half the URLs cited in US Supreme Court opinions no longer pointed to the right content a few years after publication.&lt;/p&gt;

&lt;p&gt;Even when a URL still resolves, the page it serves may have been edited since. "Retrieved on March 4" means nothing unless you can also show what the page said on March 4. That is the gap every method below closes.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. The quickest option: Save As PDF in your browser
&lt;/h2&gt;

&lt;p&gt;For a one-off source you only need a copy of, the browser's own print dialogue is enough. It is free, available on every operating system, and the file opens anywhere.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Chrome / Edge:&lt;/strong&gt; &lt;code&gt;Ctrl+P&lt;/code&gt; (or &lt;code&gt;Cmd+P&lt;/code&gt;), then set &lt;strong&gt;Destination&lt;/strong&gt; to &lt;strong&gt;Save as PDF&lt;/strong&gt; and press Save.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Firefox:&lt;/strong&gt; &lt;code&gt;Ctrl+P&lt;/code&gt;, then &lt;strong&gt;Save to PDF&lt;/strong&gt;. See Mozilla's &lt;a href="https://support.mozilla.org/en-US/kb/how-save-web-page" rel="noopener noreferrer"&gt;how to save a web page&lt;/a&gt; help page for the full options.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Safari:&lt;/strong&gt; File → Export as PDF.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You get a text-selectable PDF of the page as it printed, which usually drops navigation and sidebars. What it does not catch: content behind an interaction (a closed accordion, a map), long pages that paginate oddly, and anything the browser has not finished rendering — see &lt;a href="https://site2pdf.online/blog/save-javascript-website-as-pdf?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=save-web-sources-for-research" rel="noopener noreferrer"&gt;saving JavaScript websites as PDF&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Fine for background reading. Not enough when the exact wording and date are the evidence.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. The dated archive: a searchable PDF of the page
&lt;/h2&gt;

&lt;p&gt;For a source you are going to quote, you want three things in the file:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The page as it rendered, so what you saw is what the reader sees.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;text layer&lt;/strong&gt;, so &lt;code&gt;Ctrl+F&lt;/code&gt; finds a quote and copy-paste works.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;capture date&lt;/strong&gt; inside the file, so the reader is not trusting your hard drive's modification time.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A full-page capture tool renders the page in a real browser, scrolls to the bottom so lazy-loaded images are there, strips the cookie banner, and writes one PDF with the text behind the pixels. In Site2PDF: paste the URL, choose PDF, press Generate. Every PDF ships with an invisible searchable text layer, so quotations stay copyable even though the page looks pixel-exact.&lt;/p&gt;

&lt;p&gt;For high-stakes work — a legal brief, a thesis defence — the file can also carry a SHA-256 hash and an RSA signature of the capture, verifiable with stock &lt;code&gt;openssl&lt;/code&gt; against a public key. That is the difference between "my PDF says March 4" and "the signature proves this PDF was not edited after March 4," covered in &lt;a href="https://site2pdf.online/blog/website-archiving-for-legal-purposes?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=save-web-sources-for-research" rel="noopener noreferrer"&gt;website archiving for legal purposes&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. The permanent URL: Internet Archive's Save Page Now
&lt;/h2&gt;

&lt;p&gt;If what you want is a link your reader can click — ideally one that will still work in 2035 — the free &lt;a href="https://web.archive.org/save" rel="noopener noreferrer"&gt;Save Page Now&lt;/a&gt; service at the Internet Archive is the standard tool. Paste the URL, press &lt;strong&gt;Save Page&lt;/strong&gt;, and in a few seconds you get a dated permalink of the form &lt;em&gt;web.archive.org/web/{timestamp}/{original URL}&lt;/em&gt;. The timestamp is baked into the URL itself — &lt;code&gt;20260319143022&lt;/code&gt; reads as 19 March 2026 at 14:30:22 UTC.&lt;/p&gt;

&lt;p&gt;The help page &lt;a href="https://help.archive.org/help/save-pages-in-the-wayback-machine/" rel="noopener noreferrer"&gt;Save Pages in the Wayback Machine&lt;/a&gt; walks through the options. In short: use it for anything you want to cite as a public source, especially court filings and journal articles where editors require that cited URLs be archived.&lt;/p&gt;

&lt;p&gt;What it does not do well: pages behind a paywall or a login (the archive does not have your session), heavily interactive apps, and PDFs served as blobs. For those, fall back to a local capture.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Keep everything together: Zotero
&lt;/h2&gt;

&lt;p&gt;Zotero is a free, open-source reference manager that researchers use because it keeps the source, the metadata and the citation in one place. The &lt;a href="https://www.zotero.org/download/connectors" rel="noopener noreferrer"&gt;browser connector&lt;/a&gt; saves a snapshot of the page into your library the moment you click it, pulls title, author, date and publisher from the page's metadata, and lets you drag the entry into Word or Google Docs as a formatted citation.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://www.zotero.org/support/quick_start_guide" rel="noopener noreferrer"&gt;quick start guide&lt;/a&gt; and the &lt;a href="https://www.zotero.org/support/adding_items_to_zotero" rel="noopener noreferrer"&gt;adding items&lt;/a&gt; page cover setup. The reason to use it even on a small paper: when you reformat from APA to MLA for a different submission every entry switches at once, and the saved snapshot is still there if the live URL has gone dark.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. The whole site, not just one page
&lt;/h2&gt;

&lt;p&gt;The methods above save &lt;strong&gt;one&lt;/strong&gt; URL. For a government report split across fifteen pages, a product's docs site, or a whole blog, you need a tool that follows the links and saves every page.&lt;/p&gt;

&lt;p&gt;A whole-site capture tool reads the site's sitemap (or follows its links), lists the pages so you can prune, and produces either one combined PDF or one file per page. Each page is rendered in a real browser, so dynamic content and lazy images are captured as they actually appear.&lt;/p&gt;

&lt;p&gt;In Site2PDF this is &lt;strong&gt;Full Website&lt;/strong&gt; mode: pick PDF, press Generate. The output is a single searchable PDF of every page — the format a researcher wants, because &lt;code&gt;Ctrl+F&lt;/code&gt; works across the whole archive. The deeper walkthrough is in &lt;a href="https://site2pdf.online/blog/how-to-archive-a-website?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=save-web-sources-for-research" rel="noopener noreferrer"&gt;how to archive a website&lt;/a&gt;; the format trade-offs are in &lt;a href="https://site2pdf.online/blog/how-to-save-a-website-as-pdf?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=save-web-sources-for-research" rel="noopener noreferrer"&gt;how to save a website as PDF&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;A Free account covers small sites — up to 5 pages per archive, 2 archives per month — enough to try the format on a short report. Larger sites need a paid plan; see &lt;a href="https://site2pdf.online/?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=save-web-sources-for-research#pricing" rel="noopener noreferrer"&gt;pricing&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  How to cite a web source
&lt;/h2&gt;

&lt;p&gt;Once you have saved the page, the citation itself is short. Both of the major style guides handle web sources explicitly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;APA 7th edition&lt;/strong&gt; uses: &lt;em&gt;Author or Organization. (Year, Month Day). Title of page. Site Name. URL&lt;/em&gt;. The APA guide's &lt;a href="https://apastyle.apa.org/style-grammar-guidelines/references/examples/webpage-website-references" rel="noopener noreferrer"&gt;webpage and website references&lt;/a&gt; page lists the exact format, including when to add "Retrieved from" (only for pages designed to change over time, such as dictionary entries or dashboards). If you archived the page, cite the archived URL in place of the live one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MLA 9th edition&lt;/strong&gt; builds every citation from the same nine "core elements." The MLA Style Center's &lt;a href="https://style.mla.org/works-cited-a-quick-guide/" rel="noopener noreferrer"&gt;Works Cited: A Quick Guide&lt;/a&gt; and &lt;a href="https://style.mla.org/works-cited/" rel="noopener noreferrer"&gt;works cited&lt;/a&gt; pages walk through them: author, title of source, title of container (the site), publisher, publication date, location (URL). An access date is optional unless the source has no publication date.&lt;/p&gt;

&lt;p&gt;Rule of thumb for both: if the page can change, cite the archived copy and mention the access date; if the page is a dated article, the archive is still useful as a backup, but cite the live URL first.&lt;/p&gt;




&lt;h2&gt;
  
  
  Which one should you use?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A quick read you may quote once → &lt;strong&gt;browser Save As PDF&lt;/strong&gt;. Seconds, no account.&lt;/li&gt;
&lt;li&gt;A quotation that is going into a published paper → &lt;strong&gt;a searchable, dated PDF&lt;/strong&gt; on disk, archived with Save Page Now as the public link.&lt;/li&gt;
&lt;li&gt;A reference you will reformat later or share with co-authors → &lt;strong&gt;Zotero&lt;/strong&gt;. The snapshot and the citation live together.&lt;/li&gt;
&lt;li&gt;A multi-page source — a whole report, a policy site, a documentation set → &lt;strong&gt;a whole-site PDF&lt;/strong&gt;, one file to search across.&lt;/li&gt;
&lt;li&gt;A disputed or legal source → &lt;strong&gt;a signed capture&lt;/strong&gt; with a verifiable hash on top of all of the above.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most dissertations end up using two of them: Zotero for day-to-day reading, and a dated PDF archive for anything that will show up in a direct quotation.&lt;/p&gt;




&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Can I cite a web source whose URL has gone dead?
&lt;/h3&gt;

&lt;p&gt;Yes, if you archived it. Cite the archived URL (for example, the &lt;code&gt;web.archive.org/web/...&lt;/code&gt; permalink) in place of the live one, and keep the original URL in a note so a reader can see what has changed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need to add an access date to every web citation?
&lt;/h3&gt;

&lt;p&gt;No. In both APA 7 and MLA 9, the access date is only required when the page carries no publication date or is designed to update. For a dated article, the publication date is enough.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does my saved PDF need OCR to be searchable?
&lt;/h3&gt;

&lt;p&gt;Only if the page was captured as an image (a plain screenshot). A PDF produced by a browser Save As, or by a tool that writes the page's text behind the pixels, is already searchable — no OCR step needed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is a screenshot good enough to cite?
&lt;/h3&gt;

&lt;p&gt;For a journalist or a thesis supervisor, usually yes, as long as the capture date is visible in the file. For court or regulatory use, a plain screenshot is weaker evidence than a signed capture because the file has nothing in it that proves it was not edited after the fact.&lt;/p&gt;

&lt;h3&gt;
  
  
  What about paywalled articles?
&lt;/h3&gt;

&lt;p&gt;Save them locally while you are logged in — the browser's Save As PDF captures the version you can see. Public archives like the Wayback Machine cannot see paywalled content, so for a paywalled source, your local dated PDF is the archive.&lt;/p&gt;




&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;Save the page the day you read it, not the day you need to cite it. For a one-off reference the browser's Save As PDF is enough; for a quotation the reader must be able to verify, you want a dated, searchable PDF; for a public link that will outlive the original site, use the Internet Archive. When the source is a whole site rather than one page, &lt;a href="https://site2pdf.online/?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=save-web-sources-for-research#tool" rel="noopener noreferrer"&gt;site2pdf.online&lt;/a&gt; turns it into one searchable PDF in a few minutes.&lt;/p&gt;




&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://apastyle.apa.org/style-grammar-guidelines/references/examples/webpage-website-references" rel="noopener noreferrer"&gt;APA Style — Webpages and websites references&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://style.mla.org/works-cited-a-quick-guide/" rel="noopener noreferrer"&gt;MLA Style Center — Works Cited: A Quick Guide&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://style.mla.org/works-cited/" rel="noopener noreferrer"&gt;MLA Style Center — Works Cited&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://web.archive.org/save" rel="noopener noreferrer"&gt;Internet Archive — Save Page Now&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://help.archive.org/help/save-pages-in-the-wayback-machine/" rel="noopener noreferrer"&gt;Internet Archive help — Save Pages in the Wayback Machine&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.zotero.org/support/quick_start_guide" rel="noopener noreferrer"&gt;Zotero — Quick Start Guide&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.zotero.org/support/adding_items_to_zotero" rel="noopener noreferrer"&gt;Zotero — Adding items to Zotero&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.zotero.org/download/connectors" rel="noopener noreferrer"&gt;Zotero — Browser connectors&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://support.mozilla.org/en-US/kb/how-save-web-page" rel="noopener noreferrer"&gt;Mozilla Support — How to save a web page&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://en.wikipedia.org/wiki/Link_rot" rel="noopener noreferrer"&gt;Wikipedia — Link rot&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>productivity</category>
      <category>writing</category>
      <category>tutorial</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How to Download a Website as a ZIP File</title>
      <dc:creator>PetrDev</dc:creator>
      <pubDate>Sat, 17 Oct 2026 13:40:43 +0000</pubDate>
      <link>https://dev.to/petr_savenok/how-to-download-a-website-as-a-zip-file-ojo</link>
      <guid>https://dev.to/petr_savenok/how-to-download-a-website-as-a-zip-file-ojo</guid>
      <description>&lt;p&gt;"Download the website as a ZIP" sounds like one task. It is three, and the right method depends on which one you mean:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The files of your own site&lt;/strong&gt; — HTML, images, themes, maybe the database — so you can back it up or move it to another host.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A browsable copy of a site you don't control&lt;/strong&gt; — everything a browser would load, saved to your disk so it opens offline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A record of what every page looks like&lt;/strong&gt; — one file per page, for a client, a dispute, or the version before a redesign.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Pick the wrong one and you get a ZIP that does not do what you needed: a folder of PHP files that will not open in a browser, or a "copy" of a modern site where every page is blank. Here is each method, what ends up inside the ZIP, and where it breaks.&lt;/p&gt;




&lt;h2&gt;
  
  
  Quick answer
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What you need&lt;/th&gt;
&lt;th&gt;Use&lt;/th&gt;
&lt;th&gt;What's in the ZIP&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Back up or move your own site&lt;/td&gt;
&lt;td&gt;Hosting file manager, a backup plugin, or your host's deploy download&lt;/td&gt;
&lt;td&gt;Source files (plus a database export, if the site has one)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;An offline copy of any site&lt;/td&gt;
&lt;td&gt;HTTrack, wget or SiteSucker, then compress the folder&lt;/td&gt;
&lt;td&gt;The HTML, CSS, scripts and images the server sends&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A record of how each page looks&lt;/td&gt;
&lt;td&gt;A full-site capture tool that exports per-page files&lt;/td&gt;
&lt;td&gt;One PDF (or PNG/JPG) per page, named after its URL&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  1. Your own site: download the source files
&lt;/h2&gt;

&lt;p&gt;If you own the site, the cleanest copy comes from where it is hosted — not from the outside.&lt;/p&gt;

&lt;h3&gt;
  
  
  Hosting with cPanel or a similar file manager
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Open &lt;strong&gt;File Manager&lt;/strong&gt; and go to the site's folder — usually &lt;code&gt;public_html&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Select everything, choose &lt;strong&gt;Compress&lt;/strong&gt;, and pick &lt;strong&gt;ZIP Archive&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Download the ZIP that appears, then delete it from the server so it is not left publicly reachable.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This gets the files only. A WordPress site keeps its posts, pages and settings in a &lt;strong&gt;database&lt;/strong&gt;, not in files — so also export the database (phpMyAdmin → Export) or the ZIP will restore an empty site.&lt;/p&gt;

&lt;h3&gt;
  
  
  WordPress
&lt;/h3&gt;

&lt;p&gt;A backup plugin does both halves for you. &lt;strong&gt;UpdraftPlus&lt;/strong&gt;, &lt;strong&gt;Duplicator&lt;/strong&gt; and &lt;strong&gt;All-in-One WP Migration&lt;/strong&gt; each package the files and the database into downloadable archives, and each can restore them on another host. This is the right tool when the goal is a move or a backup you can restore.&lt;/p&gt;

&lt;h3&gt;
  
  
  Static sites, Netlify and GitHub
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Netlify:&lt;/strong&gt; open a successful deploy in the Deploys list and download it — you get a ZIP of the built files Netlify actually serves (the output, not your framework source).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub:&lt;/strong&gt; if the site lives in a repository, &lt;strong&gt;Code → Download ZIP&lt;/strong&gt; gives you the source.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Website builders: Webflow, Wix, Squarespace
&lt;/h3&gt;

&lt;p&gt;Builders are the exception, because the site is generated on their servers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Webflow&lt;/strong&gt; can export HTML, CSS, JavaScript and assets as a ZIP from the Designer (&lt;strong&gt;Code export&lt;/strong&gt;, or Shift+E) — on a paid &lt;strong&gt;Workspace&lt;/strong&gt; plan only. CMS content, ecommerce and user accounts are not included.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wix&lt;/strong&gt; does not let you download or export a site's files; Wix staff confirm it is not possible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Squarespace&lt;/strong&gt; has no code export either.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a Wix or Squarespace site, the closest thing to "the whole site in a ZIP" is method 3 below: a capture of every page as it renders.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Someone else's site: use a website copier
&lt;/h2&gt;

&lt;p&gt;A website copier starts at a URL, follows the links, and saves every page and asset it finds into a folder, rewriting links so the copy can be browsed offline. Compress that folder and you have your ZIP (Windows: right-click → &lt;strong&gt;Send to → Compressed (zipped) folder&lt;/strong&gt;; macOS: right-click → &lt;strong&gt;Compress&lt;/strong&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  HTTrack (Windows, Linux)
&lt;/h3&gt;

&lt;p&gt;Free and open source. Create a project, enter the URL, choose &lt;strong&gt;Download web site(s)&lt;/strong&gt;, and let it run. For anything bigger than a few pages, set a depth limit and stay on the same domain, or it will happily start downloading the rest of the internet.&lt;/p&gt;

&lt;h3&gt;
  
  
  wget (any platform, command line)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wget &lt;span class="nt"&gt;--mirror&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--convert-links&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--adjust-extension&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--page-requisites&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--no-parent&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  https://example.com/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(On Windows, write it as one line without the backslashes.) &lt;code&gt;--mirror&lt;/code&gt; follows links recursively, &lt;code&gt;--page-requisites&lt;/code&gt; pulls the images and CSS each page needs, and &lt;code&gt;--convert-links&lt;/code&gt; makes the copy work offline. Then: &lt;code&gt;zip -r example.zip example.com&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  SiteSucker (macOS)
&lt;/h3&gt;

&lt;p&gt;A paid Mac App Store app that does the same job with a friendlier window. Paste the URL, press Download.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where copiers break
&lt;/h3&gt;

&lt;p&gt;Copiers save what the &lt;strong&gt;server sends&lt;/strong&gt;. They do not run JavaScript. That used to be a footnote; now it is the main limitation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sites built with React, Vue or a builder&lt;/strong&gt; often send a nearly empty page and build the content in the browser. The copy opens to a blank page or a loading spinner. (More on why in &lt;a href="https://site2pdf.online/blog/save-javascript-website-as-pdf?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=how-to-download-a-website-as-a-zip" rel="noopener noreferrer"&gt;saving JavaScript websites as PDF&lt;/a&gt;.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anything behind a login&lt;/strong&gt; is out of reach unless you pass the copier your session.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Search, forms and checkout&lt;/strong&gt; stop working — there is no server behind the copy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lazy-loaded images&lt;/strong&gt; that appear only on scroll are often missing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Be considerate, too: limit the crawl speed, respect the site's terms, and keep a copy of someone else's site for your own reference — republishing it is a different matter.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. A ZIP of what every page looks like
&lt;/h2&gt;

&lt;p&gt;Sometimes you do not need the code at all. You need &lt;strong&gt;proof of what the site showed&lt;/strong&gt;: the version a client signed off on, the page before a redesign, a competitor's offer on a given date, or a site that is about to &lt;a href="https://site2pdf.online/blog/save-a-website-before-it-goes-offline?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=how-to-download-a-website-as-a-zip" rel="noopener noreferrer"&gt;go offline&lt;/a&gt;. For that, the useful ZIP holds one rendered file per page — and it is also the only option that works on Wix, Squarespace and JavaScript-heavy sites, because each page is rendered in a real browser before it is saved.&lt;/p&gt;

&lt;p&gt;With Site2PDF:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open the &lt;a href="https://site2pdf.online/?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=how-to-download-a-website-as-a-zip#tool" rel="noopener noreferrer"&gt;tool&lt;/a&gt;, choose &lt;strong&gt;Full Website&lt;/strong&gt;, and paste the site's address.&lt;/li&gt;
&lt;li&gt;It reads the site's sitemap — or follows its links when there is none — and lists the pages. Untick the ones you don't want and drag the rest into order.&lt;/li&gt;
&lt;li&gt;Pick the format: &lt;strong&gt;PNG&lt;/strong&gt; or &lt;strong&gt;JPG&lt;/strong&gt; (images always come as a ZIP, on every plan), or &lt;strong&gt;PDF&lt;/strong&gt; with &lt;strong&gt;Separate files · ZIP&lt;/strong&gt; (a paid-plan option).&lt;/li&gt;
&lt;li&gt;Press &lt;strong&gt;Generate Website Archive&lt;/strong&gt; and download the ZIP when it is ready.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Inside, each page is its own file, numbered in your order and named after its URL — &lt;code&gt;001-home.png&lt;/code&gt;, &lt;code&gt;002-pricing.png&lt;/code&gt;, &lt;code&gt;003-blog-launch-notes.png&lt;/code&gt;. Every page is captured in real Chrome after scrolling to the bottom, so lazy-loaded images are there and cookie banners are removed. Choose PDF and each file also keeps an invisible text layer, so you can search it and copy text even though the page looks pixel-exact.&lt;/p&gt;

&lt;p&gt;Prefer one file? Leave PDF on &lt;strong&gt;Single file&lt;/strong&gt; and every page goes into one combined PDF — handy for &lt;a href="https://site2pdf.online/blog/website-archive-handoff-to-client?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=how-to-download-a-website-as-a-zip" rel="noopener noreferrer"&gt;handing a site over to a client&lt;/a&gt;. A free account covers small sites; larger sites need a paid plan or a one-off pack, and a ZIP of separate PDFs needs a paid plan — see &lt;a href="https://site2pdf.online/?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=how-to-download-a-website-as-a-zip#pricing" rel="noopener noreferrer"&gt;pricing&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;What this ZIP is not: editable source. You cannot upload it to a new host and get a working site back. It is a record — which is exactly what methods 1 and 2 do not give you.&lt;/p&gt;




&lt;h2&gt;
  
  
  Which one should you use?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Moving or backing up your own site&lt;/strong&gt; → method 1. A capture is not a backup you can restore.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reading a site offline, or studying how it is built&lt;/strong&gt; → method 2, if the site is mostly plain HTML.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Showing what a site looked like, keeping a dated record, or saving a builder or JavaScript site&lt;/strong&gt; → method 3.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Often the honest answer is two of them: a backup plugin for the files, and a &lt;a href="https://site2pdf.online/blog/how-to-save-a-website-as-pdf?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=how-to-download-a-website-as-a-zip" rel="noopener noreferrer"&gt;PDF of every page&lt;/a&gt; for the record.&lt;/p&gt;




&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Can I download an entire website as a ZIP for free?
&lt;/h3&gt;

&lt;p&gt;Yes. Your host's file manager, HTTrack and wget are free, and so is a ZIP of page images of a small site in Site2PDF. What you get differs: files from the first ones, rendered pages from the last.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does the ZIP include the database?
&lt;/h3&gt;

&lt;p&gt;Only if you export it. Compressing the site's folder captures files; WordPress content lives in the database, so export it separately or use a backup plugin that bundles both.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why is my downloaded copy blank or broken?
&lt;/h3&gt;

&lt;p&gt;The site builds its pages with JavaScript and the copier does not run it. Use a tool that renders each page in a real browser before saving it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is it legal to download someone else's website?
&lt;/h3&gt;

&lt;p&gt;Saving a copy for your own reference is generally fine; republishing it or reusing its content usually is not. Check the site's terms, and do not hammer the server with a fast crawl.&lt;/p&gt;




&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;Decide what the ZIP is for before you make it. Files to restore come from your host. A browsable copy comes from a copier — if the site is plain HTML. A record of how each page actually looked comes from rendering the pages: &lt;a href="https://site2pdf.online?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=how-to-download-a-website-as-a-zip" rel="noopener noreferrer"&gt;site2pdf.online&lt;/a&gt; turns a whole site into a ZIP of PDFs in a few minutes.&lt;/p&gt;




&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://forum.wixstudio.com/t/how-do-i-export-site-to-html/61256" rel="noopener noreferrer"&gt;Wix Studio Community — "How do I export site to HTML?" (answered by Wix staff)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://help.webflow.com/hc/en-us/articles/33961386739347" rel="noopener noreferrer"&gt;Webflow Help Center — How do I export my Webflow site code?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.netlify.com/blog/2021/12/03/download-build-assets-from-any-of-your-deploys/" rel="noopener noreferrer"&gt;Netlify — Download build assets from any of your deploys&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.gnu.org/software/wget/manual/wget.html#Recursive-Download" rel="noopener noreferrer"&gt;GNU Wget manual — Recursive download&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.httrack.com/" rel="noopener noreferrer"&gt;HTTrack Website Copier&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>tutorial</category>
      <category>productivity</category>
      <category>linux</category>
    </item>
    <item>
      <title>How to Capture Many Web Pages at Once (Bulk URL List to PDF)</title>
      <dc:creator>PetrDev</dc:creator>
      <pubDate>Sat, 17 Oct 2026 13:40:06 +0000</pubDate>
      <link>https://dev.to/petr_savenok/how-to-capture-many-web-pages-at-once-bulk-url-list-to-pdf-dj0</link>
      <guid>https://dev.to/petr_savenok/how-to-capture-many-web-pages-at-once-bulk-url-list-to-pdf-dj0</guid>
      <description>&lt;p&gt;Capturing one web page is a solved problem — Chrome, Firefox and every browser extension do it. The job gets harder when the list is forty URLs: a competitor audit, every page in a client's redesign, a batch of policy pages to show a reviewer. Opening each one, scrolling to the bottom, and saving it by hand is where an afternoon goes.&lt;/p&gt;

&lt;p&gt;Four methods actually work for a list, each for a different kind of person. Here is each one, what the output looks like, and where it falls over.&lt;/p&gt;




&lt;h2&gt;
  
  
  Quick answer
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What you have&lt;/th&gt;
&lt;th&gt;Use&lt;/th&gt;
&lt;th&gt;Output&lt;/th&gt;
&lt;th&gt;Needs code?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A handful of tabs already open&lt;/td&gt;
&lt;td&gt;A browser extension (GoFullPage, FireShot)&lt;/td&gt;
&lt;td&gt;One image or PDF per tab&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hundreds a day, same job every time&lt;/td&gt;
&lt;td&gt;A screenshot API (ScreenshotOne, Urlbox, Browshot)&lt;/td&gt;
&lt;td&gt;Per-URL files via HTTP&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A one-off list, you already code&lt;/td&gt;
&lt;td&gt;A short Puppeteer or Playwright script&lt;/td&gt;
&lt;td&gt;Whatever you tell it to save&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A list of URLs, no scripting&lt;/td&gt;
&lt;td&gt;A bulk-list tool like &lt;a href="https://site2pdf.online/?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=bulk-website-screenshots#tool" rel="noopener noreferrer"&gt;Site2PDF&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;A ZIP of per-page files, or one combined PDF&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  1. A browser extension (one tab at a time)
&lt;/h2&gt;

&lt;p&gt;Extensions like &lt;strong&gt;GoFullPage&lt;/strong&gt; and &lt;strong&gt;FireShot&lt;/strong&gt; capture whatever is in the active tab, scrolling it to the bottom and saving a PNG, JPG or PDF. Both have free tiers; GoFullPage is free with a paid tier for annotation tools, and FireShot's free tier watermarks captures.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For a list, they do not scale.&lt;/strong&gt; You open each URL, press the button, confirm the save dialog, and go to the next tab. FireShot's &lt;strong&gt;Pro&lt;/strong&gt; tier adds "batch capture of multiple URLs" and "batch capture of entire websites" from a list — that is the one feature that turns an extension into a bulk tool, and it is behind a paid licence (sold as a yearly subscription or a one-time lifetime fee).&lt;/p&gt;

&lt;p&gt;Where they break:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;JavaScript-heavy pages.&lt;/strong&gt; An extension runs inside the browser you already opened, so what you see is what you get — but if the page is still loading when you press the button, half the content is missing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pages that need a login.&lt;/strong&gt; An extension sees the session you are logged into; an API or a script needs cookies passed through.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lists longer than your attention.&lt;/strong&gt; Twenty by hand is bearable. Two hundred is a mistake waiting to happen.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Use an extension when the list is short, the pages are already open, and you want zero setup.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. A screenshot or PDF API
&lt;/h2&gt;

&lt;p&gt;Screenshot APIs — &lt;strong&gt;ScreenshotOne&lt;/strong&gt;, &lt;strong&gt;Urlbox&lt;/strong&gt;, &lt;strong&gt;ScreenshotAPI.net&lt;/strong&gt;, &lt;strong&gt;Browshot&lt;/strong&gt;, &lt;strong&gt;ApiFlash&lt;/strong&gt; — render a URL on their servers in a real headless browser and return the file. Most of them expose a batch endpoint on paid tiers: Browshot has &lt;code&gt;/api/v1/batch/create&lt;/code&gt; (thousands of URLs from a text file), ScreenshotAPI.net adds "bulk" to its Startup tier, Urlbox and ScreenshotOne charge per render on a monthly quota.&lt;/p&gt;

&lt;p&gt;A tiny &lt;code&gt;curl&lt;/code&gt; for one URL looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-o&lt;/span&gt; pricing.png &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://api.screenshotone.com/take?url=https://example.com/pricing&amp;amp;access_key=KEY&amp;amp;full_page=true"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Looping that over a list is twenty lines of Bash or Python.&lt;/p&gt;

&lt;p&gt;Where it breaks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It is a developer product.&lt;/strong&gt; The dashboard is for billing, not for "paste this list and get the files." You write code, parse errors, retry failures.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The output is per URL.&lt;/strong&gt; You get &lt;code&gt;pricing.png&lt;/code&gt;, &lt;code&gt;about.png&lt;/code&gt;, &lt;code&gt;blog-post-1.png&lt;/code&gt; — one file per request. Combining them into a single PDF or a tidy ZIP named after the right domain is on you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Legal/integrity features are rare.&lt;/strong&gt; Only Urlbox (Business tier) returns a SHA-256 hash and a timestamp with the file; none of the others. For &lt;a href="https://site2pdf.online/blog/website-archiving-for-legal-purposes?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=bulk-website-screenshots" rel="noopener noreferrer"&gt;legal archiving&lt;/a&gt;, that matters.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Use an API when you are capturing the same kind of page every day and the cost per render matters more than the setup time.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. A short Puppeteer or Playwright script
&lt;/h2&gt;

&lt;p&gt;If you already write a bit of JavaScript, twenty lines of &lt;strong&gt;Puppeteer&lt;/strong&gt; or &lt;strong&gt;Playwright&lt;/strong&gt; do what an API does, for free, on your own machine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;puppeteer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;puppeteer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;urls&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./urls.json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;puppeteer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newPage&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;networkidle0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^https&lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\/\/&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;a-z0-9&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+/gi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;-&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.pdf`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;A4&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;printBackground&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;})();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That gets you a PDF per URL. Add &lt;code&gt;fullPage: true&lt;/code&gt; for a PNG, or use &lt;code&gt;page.screenshot()&lt;/code&gt; for an image. For a &lt;a href="https://site2pdf.online/blog/save-javascript-website-as-pdf?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=bulk-website-screenshots" rel="noopener noreferrer"&gt;whole JavaScript site saved as PDF&lt;/a&gt;, the same loop works — you just feed it the sitemap.&lt;/p&gt;

&lt;p&gt;Where it breaks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You run it.&lt;/strong&gt; When a URL throws a timeout at 3 a.m. nothing retries it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fixed headers, cookie banners and lazy images.&lt;/strong&gt; The browser captures what is on screen; cleaning up all three takes more code than people expect.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No searchable text.&lt;/strong&gt; &lt;code&gt;page.screenshot()&lt;/code&gt; is a picture. &lt;code&gt;page.pdf()&lt;/code&gt; keeps text, but only if the page uses system fonts — anything else needs an embedded font.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Use a script when the list is a one-off and you would rather own the capture than send URLs to a vendor.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. A paste-a-list tool
&lt;/h2&gt;

&lt;p&gt;The non-code answer is a tool that takes a list of URLs and gives back the archive. &lt;a href="https://site2pdf.online/?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=bulk-website-screenshots#tool" rel="noopener noreferrer"&gt;Site2PDF&lt;/a&gt;'s &lt;strong&gt;Bulk URLs&lt;/strong&gt; tab does this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open the tool and switch to &lt;strong&gt;Bulk URLs&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Paste the list — &lt;strong&gt;one URL per line, or separated by commas or spaces&lt;/strong&gt;. The URLs can be from different websites; a competitor audit and an internal content review are the same shape of job here.&lt;/li&gt;
&lt;li&gt;Press &lt;strong&gt;Continue&lt;/strong&gt;. You see the pages, can untick ones you added by mistake, and can drag them into the order you want.&lt;/li&gt;
&lt;li&gt;Pick a format: &lt;strong&gt;PDF&lt;/strong&gt;, &lt;strong&gt;PNG&lt;/strong&gt; or &lt;strong&gt;JPG&lt;/strong&gt;. For PDF, choose &lt;strong&gt;Single file&lt;/strong&gt; (every page in one combined document) or &lt;strong&gt;Separate files · ZIP&lt;/strong&gt; (one PDF per URL, zipped). PNG and JPG always come back as a ZIP because several images cannot be merged into one file.&lt;/li&gt;
&lt;li&gt;Press &lt;strong&gt;Generate&lt;/strong&gt; and download the archive when it is ready.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What ends up inside, when the list spans multiple sites, is a ZIP whose files are named after the domain and the path: &lt;code&gt;001-example.com-pricing.pdf&lt;/code&gt;, &lt;code&gt;002-another-site.com-about-us.pdf&lt;/code&gt;. A single-domain list drops the domain and just numbers the pages, so a 40-page audit of one site reads cleanly: &lt;code&gt;001-home&lt;/code&gt;, &lt;code&gt;002-pricing&lt;/code&gt;, &lt;code&gt;003-features&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Where it breaks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Bulk needs a free account.&lt;/strong&gt; The signup is for your archive history and the plan allowance — a single-page capture does not require it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The list is capped by your plan's per-archive page limit&lt;/strong&gt; — the Free plan takes a short list; paid plans go up from there (see &lt;a href="https://site2pdf.online/?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=bulk-website-screenshots#pricing" rel="noopener noreferrer"&gt;pricing&lt;/a&gt;). A one-off pack covers a single big list without a subscription.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A ZIP of separate PDFs is a paid-plan feature.&lt;/strong&gt; PNG and JPG lists still come back as a ZIP on the free plan; what the free plan cannot do is choose to split a PDF list across files.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Use this when the list is big, mixed-domain, or you would rather paste URLs than write a script.&lt;/p&gt;




&lt;h2&gt;
  
  
  Which one should you use?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A dozen pages already open in tabs&lt;/strong&gt; → an extension. GoFullPage free, FireShot free (watermarked) or FireShot Pro for batch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A pipeline that runs every day&lt;/strong&gt; → an API. Pick the one whose quota matches your volume; expect to write code around it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A one-off list and you already code&lt;/strong&gt; → Puppeteer or Playwright. Twenty lines, zero cost.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A one-off list and you don't code&lt;/strong&gt;, or you want &lt;strong&gt;one combined PDF of the lot, searchable, with URL footers&lt;/strong&gt; → a bulk-list tool. Especially if the list spans different domains or needs a &lt;a href="https://site2pdf.online/blog/website-archive-handoff-to-client?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=bulk-website-screenshots" rel="noopener noreferrer"&gt;dated record for a client handoff&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two of these often live in the same workflow. A SEO team might script monthly competitor captures with Puppeteer and use the paste-a-list tool for one-off audits. An agency uses the extension for live review and the bulk tool to &lt;a href="https://site2pdf.online/blog/how-to-download-a-website-as-a-zip?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=bulk-website-screenshots" rel="noopener noreferrer"&gt;bundle a project&lt;/a&gt; when it ships.&lt;/p&gt;




&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Can I bulk-screenshot websites for free?
&lt;/h3&gt;

&lt;p&gt;Yes, up to a point. A browser extension is free for one tab at a time. A short Puppeteer script is free, on your machine. Several screenshot APIs include a free tier of around 100 renders a month. A bulk paste-a-list tool like Site2PDF has a free tier that fits short lists; longer ones need a paid plan or a one-off pack.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I keep the pages in the order I pasted?
&lt;/h3&gt;

&lt;p&gt;The order you paste is the order the archive uses. Site2PDF numbers each file by position (&lt;code&gt;001-&lt;/code&gt;, &lt;code&gt;002-&lt;/code&gt;, &lt;code&gt;003-&lt;/code&gt;), and the review step lets you drag rows if you need to reorder them. An API or a script keeps the order of the input list — unless you parallelise, in which case you have to sort by index yourself.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I bulk-capture a site that needs a login?
&lt;/h3&gt;

&lt;p&gt;Yes with a script (pass the session cookie to Puppeteer), yes with an extension (it uses the browser session you already have), partially with APIs (some accept cookies as a parameter; many do not). Bulk tools vary — Site2PDF's Bulk URLs is for public pages.&lt;/p&gt;

&lt;h3&gt;
  
  
  What about full-page vs just the visible area?
&lt;/h3&gt;

&lt;p&gt;For a list, you almost always want full-page: scrolled to the bottom, lazy images loaded, cookie banner removed. Every method above supports it; see &lt;a href="https://site2pdf.online/blog/how-to-take-a-full-page-screenshot?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=bulk-website-screenshots" rel="noopener noreferrer"&gt;how full-page screenshots actually work&lt;/a&gt; for the mechanics and the usual gotchas.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the difference between a bulk screenshot and a full-site archive?
&lt;/h3&gt;

&lt;p&gt;A bulk screenshot takes a &lt;strong&gt;list you supplied&lt;/strong&gt;. A &lt;a href="https://site2pdf.online/blog/how-to-archive-a-website?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=bulk-website-screenshots" rel="noopener noreferrer"&gt;full-site archive&lt;/a&gt; &lt;strong&gt;discovers&lt;/strong&gt; the pages — from the sitemap or by following links — and captures them. Bulk is right when you already know which URLs matter; full-site is right when you want "everything the public can see."&lt;/p&gt;




&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;For a short, same-domain list, open each page and use an extension. For a daily pipeline, pay for an API. For a one-off list — mixed domains, forty URLs, something you want as &lt;a href="https://site2pdf.online/blog/pdf-vs-screenshot-for-archiving?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=bulk-website-screenshots" rel="noopener noreferrer"&gt;one searchable PDF or a per-page ZIP&lt;/a&gt; — paste it into &lt;a href="https://site2pdf.online?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=bulk-website-screenshots" rel="noopener noreferrer"&gt;site2pdf.online&lt;/a&gt;, pick the format, and let it run.&lt;/p&gt;




&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://screenshotone.com/pricing" rel="noopener noreferrer"&gt;ScreenshotOne — Pricing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://urlbox.com/pricing" rel="noopener noreferrer"&gt;Urlbox — Pricing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://screenshotapi.net/pricing" rel="noopener noreferrer"&gt;ScreenshotAPI.net — Pricing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://browshot.com/api/documentation" rel="noopener noreferrer"&gt;Browshot — API documentation (batch endpoint)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://getfireshot.com/buy.php" rel="noopener noreferrer"&gt;FireShot — Pro features and pricing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://gofullpage.com/" rel="noopener noreferrer"&gt;GoFullPage — Full-page screenshot extension&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://pptr.dev/api/puppeteer.page.pdf" rel="noopener noreferrer"&gt;Puppeteer — &lt;code&gt;page.pdf()&lt;/code&gt; API&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://playwright.dev/docs/screenshots" rel="noopener noreferrer"&gt;Playwright — screenshots&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>puppeteer</category>
      <category>javascript</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Never run Puppeteer inside a web request: scheduled captures with WP-Cron + a background worker</title>
      <dc:creator>PetrDev</dc:creator>
      <pubDate>Tue, 01 Sep 2026 10:42:51 +0000</pubDate>
      <link>https://dev.to/petr_savenok/never-run-puppeteer-inside-a-web-request-scheduled-captures-with-wp-cron-a-background-worker-5f9n</link>
      <guid>https://dev.to/petr_savenok/never-run-puppeteer-inside-a-web-request-scheduled-captures-with-wp-cron-a-background-worker-5f9n</guid>
      <description>&lt;p&gt;We let people put a URL on a schedule: re-capture this page daily, weekly, or monthly, and email me only when it actually changes. Simple feature, one sharp edge — and we found it in production.&lt;/p&gt;

&lt;p&gt;The naive version runs the capture where the request is: user hits "check now", PHP shells out to headless Chrome, waits for the PDF, responds. It works on your machine. Then a handful of schedules come due in the same window, each capture holds a PHP worker for the 10–40 seconds Chrome takes, and the FPM pool is exhausted. On &lt;strong&gt;2026-08-01&lt;/strong&gt; that's exactly what happened to us: synchronous captures inside web requests starved the pool and the whole site stopped answering.&lt;/p&gt;

&lt;p&gt;The rule that came out of it: &lt;strong&gt;no PHP process — web request or cron — ever waits on headless Chrome.&lt;/strong&gt; Here's the architecture that enforces it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape
&lt;/h2&gt;

&lt;p&gt;Two phases, deliberately split across cron ticks:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Dispatch&lt;/strong&gt; — a cron tick finds due schedules and hands each one to a background worker (the same one the interactive tool already uses). It records a &lt;code&gt;job_id&lt;/code&gt; and returns immediately.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Collect&lt;/strong&gt; — a &lt;em&gt;later&lt;/em&gt; tick picks up finished jobs, diffs the content against the previous run, keeps or discards the archive, and emails the owner.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Nothing blocks. The capture runs detached; cron just bookkeeps.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cron tick
&lt;/h2&gt;

&lt;p&gt;WordPress cron isn't a real cron — it fires on traffic — but for daily-or-slower schedules that's plenty. We register a 15-minute tick (the interval is about &lt;em&gt;collect latency&lt;/em&gt;, not schedule precision: a dispatched job needs a later tick to be noticed, so 15 min bounds how long "Run now" takes to report).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;webtools_watches_run_due&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;get_transient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'webtools_watches_lock'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// a previous tick still running&lt;/span&gt;
    &lt;span class="nf"&gt;set_transient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'webtools_watches_lock'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="no"&gt;MINUTE_IN_SECONDS&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// Phase 1: collect anything that finished since last tick.&lt;/span&gt;
    &lt;span class="nf"&gt;webtools_watches_collect_jobs&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// Phase 2: dispatch what's due — bounded, so a burst can't stall anything.&lt;/span&gt;
    &lt;span class="nv"&gt;$now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;time&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;webtools_watches_all&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$id&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$w&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="k"&gt;empty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$w&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'job_id'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;             &lt;span class="c1"&gt;// already in flight&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$w&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'active'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$w&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'next_run'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nv"&gt;$now&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$due&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// oldest-due first, capped at WEBTOOLS_WATCHES_PER_TICK; the rest wait for the next tick&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
    &lt;span class="nf"&gt;delete_transient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'webtools_watches_lock'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things that matter more than they look:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A transient lock&lt;/strong&gt; so overlapping ticks don't double-dispatch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A per-tick cap&lt;/strong&gt; (&lt;code&gt;WEBTOOLS_WATCHES_PER_TICK&lt;/code&gt;) plus a ceiling on simultaneous Chrome jobs (&lt;code&gt;WEBTOOLS_WATCH_JOBS_MAX&lt;/code&gt;) — each capture is its own headless browser, so unbounded concurrency is just the original outage with extra steps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;job_id&lt;/code&gt; as a mutex.&lt;/strong&gt; A schedule with a job in flight is skipped, so it can never dispatch twice.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Dispatch: advance the schedule &lt;em&gt;now&lt;/em&gt;, not later
&lt;/h2&gt;

&lt;p&gt;The subtle bug we designed out: if you advance &lt;code&gt;next_run&lt;/code&gt; when the job &lt;em&gt;finishes&lt;/em&gt;, a job that dies never advances, and the schedule fires it again every tick forever. So &lt;code&gt;next_run&lt;/code&gt; is bumped at &lt;strong&gt;dispatch&lt;/strong&gt;, and the in-flight &lt;code&gt;job_id&lt;/code&gt; is what prevents a re-dispatch. A dead job costs one missed run, not an infinite loop.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;webtools_watch_dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$id&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$watch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;webtools_watch_get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$id&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nv"&gt;$watch&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="k"&gt;empty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$watch&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'job_id'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="nv"&gt;$job_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;webtools_launch_site_job&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="cm"&gt;/* url, format, device, selectors… */&lt;/span&gt;
        &lt;span class="s1"&gt;'watch_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nv"&gt;$patch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="s1"&gt;'next_run'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;webtools_watch_next_run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$watch&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'frequency'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nb"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$job_id&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;$patch&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'job_id'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$job_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;$patch&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'job_started'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;time&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;webtools_watch_update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$patch&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A single-page schedule is just a one-URL job, so the same path serves "watch this page" and "watch this whole site" — the site variant captures a &lt;strong&gt;fixed&lt;/strong&gt; page list chosen up front, so later runs stay comparable and the cost can't quietly balloon.&lt;/p&gt;

&lt;h2&gt;
  
  
  Collect: diff the text, not the pixels
&lt;/h2&gt;

&lt;p&gt;When a job reports &lt;code&gt;done&lt;/code&gt;, we compare it to the previous run. The comparison is on a &lt;strong&gt;text hash per page&lt;/strong&gt;, not the image — the worker records a &lt;code&gt;textHash&lt;/code&gt; while it has the DOM open, and we diff those:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$prev&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$watch&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'page_hashes'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$fresh&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$job&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'pages'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$p&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'done'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="k"&gt;empty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'textHash'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$fresh&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'url'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'textHash'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nv"&gt;$first&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;empty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$prev&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$changed_pages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$fresh&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$url&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$hash&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="k"&gt;isset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$prev&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$url&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$prev&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$url&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nv"&gt;$hash&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$changed_pages&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$url&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nv"&gt;$changed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$first&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="k"&gt;empty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$changed_pages&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Text hashing sidesteps the classic monitoring false positive: a pixel diff screams every time an ad rotates or a carousel advances. Hashing the extracted text ignores all of that and fires on content that actually changed. The first run is a silent baseline — there's nothing to compare to yet — so you don't get an email announcing that a page you just added "changed."&lt;/p&gt;

&lt;p&gt;Then it's just policy: notify &lt;strong&gt;every&lt;/strong&gt; run, &lt;strong&gt;only on change&lt;/strong&gt;, or &lt;strong&gt;off&lt;/strong&gt; (still archive, never email). In change-only mode an unchanged run's archive is deleted rather than kept as a duplicate.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this bought us
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The site can't be taken down by its own scheduled work — captures are detached, cron only bookkeeps.&lt;/li&gt;
&lt;li&gt;A dead or stuck job self-heals: &lt;code&gt;next_run&lt;/code&gt; already moved, and a timeout closes out the &lt;code&gt;job_id&lt;/code&gt; so the schedule resumes.&lt;/li&gt;
&lt;li&gt;Change detection is cheap and quiet, because it compares meaning (text) not appearance (pixels).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The general lesson travels beyond WordPress: &lt;strong&gt;anything that can take tens of seconds — headless browsers, video transcode, big PDF assembly — does not belong in the request that a user (or cron) is waiting on.&lt;/strong&gt; Dispatch it, record a handle, collect it later.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This runs on every scheduled capture at &lt;a href="https://site2pdf.online" rel="noopener noreferrer"&gt;Site2PDF&lt;/a&gt;. If you want the non-engineering version — how to actually use scheduled snapshots to track a page over time — we wrote &lt;a href="https://site2pdf.online/blog/how-to-document-website-changes/" rel="noopener noreferrer"&gt;how to document website changes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>php</category>
      <category>node</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Making a screenshot PDF searchable — no OCR, because we rendered the page</title>
      <dc:creator>PetrDev</dc:creator>
      <pubDate>Thu, 20 Aug 2026 12:44:24 +0000</pubDate>
      <link>https://dev.to/petr_savenok/making-a-screenshot-pdf-searchable-no-ocr-because-we-rendered-the-page-2poi</link>
      <guid>https://dev.to/petr_savenok/making-a-screenshot-pdf-searchable-no-ocr-because-we-rendered-the-page-2poi</guid>
      <description>&lt;p&gt;We archive whole web pages as PDFs. Under the hood each page is a full-height &lt;strong&gt;screenshot&lt;/strong&gt; dropped onto a PDF page — which looks perfect and is completely useless the moment you want to &lt;em&gt;use&lt;/em&gt; the text. &lt;code&gt;Ctrl+F&lt;/code&gt; finds nothing. You can't copy a sentence. A screen reader opens the document and sees… an empty page with one big image.&lt;/p&gt;

&lt;p&gt;The fix is the same trick a "searchable scan" uses: draw the real text &lt;strong&gt;invisibly&lt;/strong&gt;, on top of the image, at the exact coordinates where each word appears. The difference is that a scanner needs OCR to guess the text — we &lt;em&gt;rendered the page ourselves&lt;/em&gt;, so we already have the ground truth. No OCR, no guessing.&lt;/p&gt;

&lt;p&gt;Here's how we built it with &lt;a href="https://pdf-lib.js.org/" rel="noopener noreferrer"&gt;&lt;code&gt;pdf-lib&lt;/code&gt;&lt;/a&gt; and &lt;code&gt;@pdf-lib/fontkit&lt;/code&gt;, and the one part that turned out to be genuinely hard.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of it
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;While the page is still open in the headless browser, ask the DOM where every word is.&lt;/li&gt;
&lt;li&gt;Assemble the PDF: embed the screenshot as the page background.&lt;/li&gt;
&lt;li&gt;For each word, &lt;code&gt;drawText&lt;/code&gt; it at its coordinates with &lt;code&gt;opacity: 0&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Steps 1 and 3 are easy. The trap is in &lt;em&gt;which&lt;/em&gt; words you're allowed to draw.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1 — ask the browser where the words are
&lt;/h2&gt;

&lt;p&gt;Running inside the page (Puppeteer's &lt;code&gt;page.evaluate&lt;/code&gt;), we walk every text node and measure each word with a &lt;code&gt;Range&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;walker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createTreeWalker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;NodeFilter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SHOW_TEXT&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// ...for each word in each text node:&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;range&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createRange&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;range&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setStart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;range&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setEnd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rects&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;range&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getClientRects&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;rects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;           &lt;span class="c1"&gt;// display:none or empty line box&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rects&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;                    &lt;span class="c1"&gt;// first rect = where the word starts&lt;/span&gt;
&lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;t&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;word&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;left&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scrollX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;// document coordinates, not viewport&lt;/span&gt;
  &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;top&lt;/span&gt;  &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scrollY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;w&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;h&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;parseFloat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;getComputedStyle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;fontSize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;getClientRects()&lt;/code&gt; gives viewport coordinates, so we add &lt;code&gt;scrollX/scrollY&lt;/code&gt; to get &lt;strong&gt;document&lt;/strong&gt; coordinates — the ones that line up with a full-page screenshot. A word wrapped across two lines returns several rects; the first is where a search should land, so that's the one we keep.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2 — the hidden-text trap (the hard part)
&lt;/h2&gt;

&lt;p&gt;The naive version above shipped a bug immediately. On a real page, search started highlighting words that &lt;strong&gt;weren't in the picture&lt;/strong&gt; — items inside a collapsed dropdown menu, off-canvas nav, &lt;code&gt;text-indent: -9999px&lt;/code&gt; SR-only text. The image showed them collapsed; the text layer had them anyway.&lt;/p&gt;

&lt;p&gt;The tempting assumption is "hidden text has no client rects." It's false. Only &lt;code&gt;display: none&lt;/code&gt; produces no rects. Every &lt;em&gt;other&lt;/em&gt; way of hiding something still reports perfectly good coordinates:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;visibility: hidden&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;opacity: 0&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;max-height: 0&lt;/code&gt; + &lt;code&gt;overflow: hidden&lt;/code&gt; (the collapsed accordion / submenu)&lt;/li&gt;
&lt;li&gt;parked offscreen with &lt;code&gt;text-indent: -9999px&lt;/code&gt; or a negative position&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So each word has to be checked against its &lt;strong&gt;whole ancestor chain&lt;/strong&gt;, not just its own box. We memoise one walk up the tree per element (a deep DOM would otherwise re-read computed styles thousands of times):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;chainInfo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;above&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;parentElement&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;chainInfo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;parentElement&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                                 &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;visible&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;clips&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;info&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;above&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;visible&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;info&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;visible&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;clips&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;above&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clips&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cs&lt;/span&gt;      &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getComputedStyle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;opacity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;above&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;opacity&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;parseFloat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// multiplied down the chain&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;visible&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;visibility&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;visible&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;opacity&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;clips&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b(&lt;/span&gt;&lt;span class="sr"&gt;hidden|clip&lt;/span&gt;&lt;span class="se"&gt;)\b&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;overflow&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;above&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clips&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;concat&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getBoundingClientRect&lt;/span&gt;&lt;span class="p"&gt;()])&lt;/span&gt;
      &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;above&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clips&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;info&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;visible&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;clips&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;info&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;info&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A word survives only if its chain is visible, its multiplied opacity is above a floor, it &lt;strong&gt;intersects&lt;/strong&gt; every clipping ancestor (a zero-height collapsed container clips its contents to nothing), and it isn't parked entirely outside the document box. Clipping is judged on &lt;em&gt;zero&lt;/em&gt; intersection, not partial — a word half-cut by a container is still half in the picture, and dropping it would lose real text.&lt;/p&gt;

&lt;p&gt;That single ancestor-walk is the difference between "searchable PDF" and "PDF that lies about what's in it."&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3 — flip the Y axis
&lt;/h2&gt;

&lt;p&gt;The DOM measures from the top-left; PDF space is bottom-left. So when we draw, Y inverts, plus a small nudge so the selection highlight sits on the glyphs rather than below them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;imageH&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 4 — the font problem
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;pdf-lib&lt;/code&gt;'s built-in fonts are &lt;strong&gt;WinAnsi&lt;/strong&gt;. They can't encode Cyrillic, Greek or CJK — and they even choke on curly typographic quotes, of which there are four on our own English blog. Draw an unencodable glyph and the call throws.&lt;/p&gt;

&lt;p&gt;So we embed a real TrueType font, &lt;strong&gt;subset to only the glyphs actually used&lt;/strong&gt;, via fontkit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fontkit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@pdf-lib/fontkit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;registerFontkit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fontkit&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;font&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;embedFont&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fontPath&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;subset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;subset: true&lt;/code&gt; is what keeps the cost near zero — only the handful of glyphs on the page get embedded, not the whole 300 KB face. We probe a small candidate list and take the first that exists on the box (DejaVu Sans on our Linux servers covers Latin + Cyrillic + Greek; Arial/Segoe locally). If none is found, we ship the image &lt;strong&gt;without&lt;/strong&gt; a text layer rather than failing the capture — losing search is not losing the archive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5 — draw it invisibly, and don't let one glyph kill the archive
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;boxes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;imageH&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;pdfPage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drawText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;font&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nx"&gt;drawn&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;skipped&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// a glyph even this font can't encode — skip the word, keep the PDF&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;opacity: 0&lt;/code&gt; is the whole point: the image is what you see, the text is what you search. And the per-word &lt;code&gt;try/catch&lt;/code&gt; matters — a single exotic glyph should cost you one unsearchable word, not the entire multi-page document.&lt;/p&gt;

&lt;h2&gt;
  
  
  Did it actually work?
&lt;/h2&gt;

&lt;p&gt;Measured on a Cyrillic page before/after:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;626 of 626&lt;/strong&gt; visible words got coordinates and a text entry&lt;/li&gt;
&lt;li&gt;file grew &lt;strong&gt;7086 KB → 7147 KB — +0.9%&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/ToUnicode&lt;/code&gt; is present in the output, so the text is genuinely searchable &lt;em&gt;and&lt;/em&gt; copyable, not just present&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That +0.9% buys you &lt;code&gt;Ctrl+F&lt;/code&gt;, copy-paste, and a document a screen reader can actually read — on what is still, pixel for pixel, an exact image of the page.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;We do this on every capture at &lt;a href="https://site2pdf.online" rel="noopener noreferrer"&gt;Site2PDF&lt;/a&gt; — it archives a page (or a whole site) as a PDF/PNG/JPG, and the PDF comes out searchable by default. If you want the end-user version of this rather than the engineering one, we wrote up &lt;a href="https://site2pdf.online/blog/how-to-save-a-website-as-pdf/" rel="noopener noreferrer"&gt;how to save a website as PDF&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>pdf</category>
      <category>node</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Converting a JavaScript-Rendered Web Page to PDF</title>
      <dc:creator>PetrDev</dc:creator>
      <pubDate>Sat, 01 Aug 2026 09:58:41 +0000</pubDate>
      <link>https://dev.to/petr_savenok/converting-a-javascript-rendered-web-page-to-pdf-4nd5</link>
      <guid>https://dev.to/petr_savenok/converting-a-javascript-rendered-web-page-to-pdf-4nd5</guid>
      <description>&lt;p&gt;If you've ever tried to turn a modern web page into a PDF programmatically, you've probably hit the wall: the file comes out blank, half-empty, or frozen on a loading spinner. The page looks perfect in the browser, so what gives?&lt;/p&gt;

&lt;p&gt;The answer is timing. Most PDF approaches grab the HTML &lt;em&gt;before&lt;/em&gt; the JavaScript has rendered the content. On a server-rendered page that's fine — the markup is already there. On a React/Vue/Angular app, the server sends an near-empty shell and the browser builds the DOM afterward. Capture too early and you save the shell.&lt;/p&gt;

&lt;p&gt;Here's how to do it properly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the naive approaches fail
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;wkhtmltopdf&lt;/code&gt;&lt;/strong&gt; is the classic Google answer. It's fast and it's been around forever, but it uses an ancient WebKit build with effectively no modern JavaScript support. For a static page it's fine. For anything client-rendered, it captures the empty state.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Browser &lt;code&gt;window.print()&lt;/code&gt; / Ctrl+P&lt;/strong&gt; works because it &lt;em&gt;is&lt;/em&gt; a real browser — but it's manual, single-page, and impossible to automate cleanly at scale.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hitting the raw HTML with an HTTP client&lt;/strong&gt; (axios/fetch then pipe to a PDF lib) has the same fatal flaw as &lt;code&gt;wkhtmltopdf&lt;/code&gt;: no JS execution, no rendered content.&lt;/p&gt;

&lt;p&gt;What you actually need is a real browser engine that runs the page's JavaScript, waits for it to settle, and &lt;em&gt;then&lt;/em&gt; prints. That's Puppeteer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Puppeteer approach
&lt;/h2&gt;

&lt;p&gt;Puppeteer drives a headless Chromium. It executes the page exactly like a normal Chrome tab, so whatever renders on screen is what you capture.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;puppeteer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;puppeteer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;pageToPdf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;outPath&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;puppeteer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;--no-sandbox&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;--disable-setuid-sandbox&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="c1"&gt;// needed in most containers&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newPage&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;networkidle0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60000&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;outPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;A4&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;printBackground&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// otherwise CSS backgrounds/colors are dropped&lt;/span&gt;
    &lt;span class="na"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;top&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;20px&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;bottom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;20px&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;left&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;16px&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;right&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;16px&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;pageToPdf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;out.pdf&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two options here matter more than they look:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;waitUntil: 'networkidle0'&lt;/code&gt;&lt;/strong&gt; tells Puppeteer to consider navigation finished only when there have been no network connections for 500ms. For most SPAs that's the signal the data has loaded and the DOM is built. (&lt;code&gt;networkidle2&lt;/code&gt; allows up to 2 connections — useful for pages with long-polling or analytics beacons that never fully go quiet.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;printBackground: true&lt;/code&gt;&lt;/strong&gt; — forget this and every background color, gradient, and image disappears, because Chrome's print path strips them by default.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The gotchas that still bite you
&lt;/h2&gt;

&lt;p&gt;Getting a non-blank PDF is step one. Getting a &lt;em&gt;correct&lt;/em&gt; one is where the edges show up.&lt;/p&gt;

&lt;h3&gt;
  
  
  Lazy-loaded content
&lt;/h3&gt;

&lt;p&gt;Images and sections that load on scroll won't be in the PDF, because Puppeteer never scrolled. Force them in by auto-scrolling before you print:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;step&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;timer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setInterval&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scrollBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scrollHeight&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;clearInterval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scrollTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Web fonts not loaded yet
&lt;/h3&gt;

&lt;p&gt;Text renders in a fallback font because the web font hadn't finished loading at print time. Wait for it explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluateHandle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;document.fonts.ready&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Print CSS overriding your layout
&lt;/h3&gt;

&lt;p&gt;If the site ships a &lt;code&gt;@media print&lt;/code&gt; stylesheet, Chrome applies it. Sometimes that's what you want; sometimes it nukes the layout. Force screen rendering with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;emulateMediaType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;screen&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Fixed headers repeating or covering content
&lt;/h3&gt;

&lt;p&gt;Sticky/fixed elements can duplicate on every PDF page or overlap the body. The reliable fix is to neutralize them before printing — set their position to &lt;code&gt;static&lt;/code&gt; (or hide them) via &lt;code&gt;page.addStyleTag()&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  When self-hosting Puppeteer isn't worth it
&lt;/h2&gt;

&lt;p&gt;Puppeteer is the right tool when you need PDFs inside a pipeline you control. But it comes with a real operational tail: a headless Chromium is heavy (200MB+), it needs the right system libraries in your container, it leaks memory if you don't manage browser instances carefully, and it falls over under concurrency unless you pool pages and cap parallelism. For a side feature — "let users export this page" — that's a lot of infrastructure to babysit.&lt;/p&gt;

&lt;p&gt;If you just need the output and not the plumbing, a hosted service does the same headless-render-then-capture under the hood without the ops burden. I run &lt;a href="https://site2pdf.online" rel="noopener noreferrer"&gt;site2pdf.online&lt;/a&gt; for exactly this — it renders each page in real Chrome (so JS content comes through), and it can also follow a site's internal links to capture multiple pages in one pass, exporting to PDF, PNG, or ZIP. Handy when the alternative is standing up and maintaining your own Chromium fleet.&lt;/p&gt;

&lt;p&gt;Rule of thumb: if PDF generation is core to your product, self-host Puppeteer and own it. If it's a convenience feature at the edge, offload it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;The blank-PDF problem is almost always a timing problem: capture happens before JavaScript renders. Use a real browser engine, wait for the network (and fonts) to settle, scroll to trigger lazy content, and enable &lt;code&gt;printBackground&lt;/code&gt;. Do that and dynamic pages convert cleanly.&lt;/p&gt;

&lt;p&gt;The full, copy-pasteable version of this — plus the fixed-header and multi-page handling — is worth keeping in a snippet somewhere. You'll reach for it more often than you'd expect.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>node</category>
      <category>puppeteer</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I shipped a website-to-PDF SaaS on WordPress — here's the stack</title>
      <dc:creator>PetrDev</dc:creator>
      <pubDate>Tue, 05 May 2026 09:09:28 +0000</pubDate>
      <link>https://dev.to/petr_savenok/i-shipped-a-website-to-pdf-saas-on-wordpress-heres-the-stack-c45</link>
      <guid>https://dev.to/petr_savenok/i-shipped-a-website-to-pdf-saas-on-wordpress-heres-the-stack-c45</guid>
      <description>&lt;p&gt;Last month I launched &lt;a href="https://site2pdf.online" rel="noopener noreferrer"&gt;Site2PDF&lt;/a&gt; — converts any website to PDF, PNG, JPG or ZIP. Here's the entire tech stack, because the choices might be useful if you're building something similar.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;p&gt;Paste a URL → get a PDF of the whole page (or the whole site). Free plan gives 5 archives/month, all formats, cookie banner removal. Paid starts at $9/mo for 15 archives and 200 pages per site.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Frontend:&lt;/strong&gt; a custom WordPress theme. Yes, WordPress. Hear me out.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Native user accounts, sessions, roles&lt;/li&gt;
&lt;li&gt;Native admin panel for analytics and user management&lt;/li&gt;
&lt;li&gt;Customizer for site branding&lt;/li&gt;
&lt;li&gt;Gutenberg-free, just PHP templates + SCSS + vanilla JS&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Rendering:&lt;/strong&gt; Node.js + Puppeteer running on the same VPS, called from PHP via &lt;code&gt;proc_open&lt;/code&gt;. A bash-like pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$command&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s1"&gt;'%s %s %s %s --options-file %s'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nb"&gt;escapeshellarg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$node_path&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nb"&gt;escapeshellarg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$script&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nb"&gt;escapeshellarg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$url&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nb"&gt;escapeshellarg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$output&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nb"&gt;escapeshellarg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$options_json&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nb"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$command&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;' 2&amp;gt;&amp;amp;1'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$output&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PHP passes options (cookie hide, selectors to expand, etc.) as a temp JSON file the Node script reads.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PDF assembly:&lt;/strong&gt; &lt;a href="https://pdf-lib.js.org/" rel="noopener noreferrer"&gt;pdf-lib&lt;/a&gt; for multi-page PDFs (each page becomes its own PDF page). &lt;a href="https://github.com/dompdf/dompdf" rel="noopener noreferrer"&gt;DOMPDF&lt;/a&gt; for single-page fallback.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Image processing:&lt;/strong&gt; &lt;code&gt;sharp&lt;/code&gt; for PNG→JPG conversion. &lt;code&gt;archiver&lt;/code&gt; for ZIP.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Billing:&lt;/strong&gt; Paddle Billing (started with Stripe, had to migrate — Stripe doesn't support my country). Paddle handles tax, EU VAT, Canadian GST etc. — a huge win for a solo founder.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Auth:&lt;/strong&gt; Google OAuth 2.0 without any plugin. A REST API endpoint redirects to Google, callback sets a one-time login token, cookie is set on the next regular page load. Why? Because iOS Safari blocks cookies set during REST API redirects (ITP).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Infra:&lt;/strong&gt; Contabo VPS ($5/mo). Nginx, PHP-FPM, MariaDB. Chrome installed as &lt;code&gt;google-chrome-stable&lt;/code&gt;, Puppeteer launches it with &lt;code&gt;executablePath&lt;/code&gt; override.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why WordPress (not Next.js or Rails)
&lt;/h2&gt;

&lt;p&gt;The honest answer: I've shipped WP for 10 years. Familiar tools win. The theme is under 4000 lines of PHP, ~2000 lines of SCSS. I didn't need a full framework for ~5 pages and a wizard UI.&lt;/p&gt;

&lt;p&gt;WordPress also gives me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Zero-effort admin for users, plans, payment history&lt;/li&gt;
&lt;li&gt;Free page hierarchy (&lt;code&gt;/pdf-tools/full-page-screenshot/&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Built-in Yoast SEO and Site Icon features&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The hot take: WordPress is a perfectly fine backend for a simple SaaS if you disable the bloat and write real code. Most of the "WordPress is slow" complaints come from bloated themes and 40 plugins.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things that bit me
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Puppeteer fullPage + fixed headers&lt;/strong&gt; — writeup in my other post
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cookie banners blocking the screenshot&lt;/strong&gt; — had to intercept requests to &lt;code&gt;policy.cookiereports.com&lt;/code&gt; before page load&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;iOS Safari OAuth&lt;/strong&gt; — first attempt set the auth cookie inside the callback redirect. Safari's ITP killed it. Had to use one-time tokens + set cookie on a regular page navigation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;iCloud Private Relay breaking CSRF state&lt;/strong&gt; — used IP hash as the state key, which broke when Apple rotates IPs between redirects. Switched to state-as-key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Paddle sandbox vs production&lt;/strong&gt; — separate accounts, separate keys, separate price IDs. Wrote a helper that picks the right IDs based on &lt;code&gt;WEBTOOLS_PADDLE_ENV&lt;/code&gt; constant.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The architecture in one diagram
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Browser (vanilla JS)
  → AJAX → PHP (screenshot-tools.php)
          → proc_open → Node worker (Puppeteer)
          → writes PNG to /uploads/screenshot-temp/
          → PHP assembles PDF/ZIP if needed
          → returns base64 or job ID
  ← polls status / downloads blob
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Full-site mode uses a different flow (background worker + status polling). Single-page mode is one synchronous AJAX call.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Real API (the pricing page still says "API — Soon")&lt;/li&gt;
&lt;li&gt;Subscription cancel button in-app (currently "email us")&lt;/li&gt;
&lt;li&gt;Blog with the 2-3 dev.to posts ported over for SEO&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://site2pdf.online" rel="noopener noreferrer"&gt;site2pdf.online&lt;/a&gt;. Free plan doesn't need signup. If you try it and something breaks, the Contact form works.&lt;/p&gt;

&lt;p&gt;Happy to answer any questions about the stack in the comments.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>saas</category>
      <category>wordpress</category>
      <category>pdf</category>
    </item>
    <item>
      <title>Cleaning up web pages for screenshots — a practical Puppeteer guide</title>
      <dc:creator>PetrDev</dc:creator>
      <pubDate>Wed, 29 Apr 2026 10:42:41 +0000</pubDate>
      <link>https://dev.to/petr_savenok/cleaning-up-web-pages-for-screenshots-a-practical-puppeteer-guide-16c1</link>
      <guid>https://dev.to/petr_savenok/cleaning-up-web-pages-for-screenshots-a-practical-puppeteer-guide-16c1</guid>
      <description>&lt;p&gt;If you've ever taken a screenshot of a real-world webpage with Puppeteer, you've seen the ugly truth: cookie banners, sticky headers frozen at weird positions, collapsed FAQ accordions, and "Subscribe!" popups.&lt;/p&gt;

&lt;p&gt;Here are four small Puppeteer helpers I use to get clean, presentable screenshots. Each is one function, drop-in ready.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Kill cookie banners
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;hideCookieBanners&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;customSelectors&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;selectors&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Known cookie-banner elements&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;CookieReportsPanel&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;CookieReportsOverlay&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;CookieReportsBannerAZ&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

        &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[class*="wscr"],[id*="CookieReport"]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

        &lt;span class="c1"&gt;// Reset body scroll locks the banner often sets&lt;/span&gt;
        &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;overflow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;documentElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;overflow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="c1"&gt;// User-defined selectors (if they know their site)&lt;/span&gt;
        &lt;span class="nx"&gt;selectors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sel&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sel&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;display&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;none&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;important&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                &lt;span class="p"&gt;});&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;customSelectors&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  2. Hide arbitrary elements
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;hideElements&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;selectors&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;selectors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sels&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;sels&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sel&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sel&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;display&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;none&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;important&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                &lt;span class="p"&gt;});&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;selectors&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Example: hide chat widgets, "cookies" bars, floating "Get started" buttons:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;hideElements&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#intercom-container&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.crisp-client&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[class*="newsletter-popup"]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3. Unfix sticky headers
&lt;/h2&gt;

&lt;p&gt;Sticky headers repeat on every "screen" of a fullPage screenshot — ugly. Convert them to &lt;code&gt;position: relative&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;unfixSticky&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;selectors&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sels&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;sels&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sel&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sel&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;position&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;relative&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;important&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;top&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bottom&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;left&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;right&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;z-index&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
                        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;auto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;important&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
                    &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;width&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;100%&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;important&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                &lt;span class="p"&gt;});&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;selectors&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. Expand FAQs / accordions
&lt;/h2&gt;

&lt;p&gt;Most FAQs use classes like &lt;code&gt;_active&lt;/code&gt; or attributes like &lt;code&gt;open&lt;/code&gt;. Give the function a CSS selector + action + value:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;expandAccordions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pairs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;pairs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rules&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(({&lt;/span&gt; &lt;span class="nx"&gt;selector&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;selector&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;class&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                        &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;_active&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                        &lt;span class="c1"&gt;// Also unhide next sibling (common FAQ pattern)&lt;/span&gt;
                        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nextElementSibling&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
                        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                            &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;display&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;block&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
                            &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxHeight&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;none&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
                            &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hidden&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                        &lt;span class="p"&gt;}&lt;/span&gt;
                    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;attribute&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;val&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                        &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;val&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;style&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                        &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rule&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                            &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                            &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                                &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;important&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                            &lt;span class="p"&gt;}&lt;/span&gt;
                        &lt;span class="p"&gt;});&lt;/span&gt;
                    &lt;span class="p"&gt;}&lt;/span&gt;
                &lt;span class="p"&gt;});&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;pairs&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// Wait for CSS transitions&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Example usage:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;expandAccordions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.faq__question&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;class&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;_active&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;details&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;attribute&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;open=true&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.accordion-body&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;style&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;display: block&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Putting it together
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;domcontentloaded&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;hideCookieBanners&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;hideElements&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.chat-widget&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;unfixSticky&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.sticky-nav&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;expandAccordions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.faq__q&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;class&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;_open&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}]);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;screenshot&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;clean.png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;fullPage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Where I use this
&lt;/h2&gt;

&lt;p&gt;All four helpers ship in production on &lt;a href="https://site2pdf.online" rel="noopener noreferrer"&gt;Site2PDF&lt;/a&gt; — a website-to-PDF tool I'm building. Users paste a URL, tick what to hide/expand, and get a clean archive.&lt;/p&gt;

&lt;p&gt;If you're doing any kind of web scraping or automated screenshots, grab these — they'll save you a weekend of CSS archaeology.&lt;/p&gt;

</description>
      <category>puppeteer</category>
      <category>automation</category>
      <category>node</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Why your fixed header disappears in Puppeteer fullPage screenshots (and how to fix it)</title>
      <dc:creator>PetrDev</dc:creator>
      <pubDate>Thu, 23 Apr 2026 15:54:52 +0000</pubDate>
      <link>https://dev.to/petr_savenok/why-your-fixed-header-disappears-in-puppeteer-fullpage-screenshots-and-how-to-fix-it-3f13</link>
      <guid>https://dev.to/petr_savenok/why-your-fixed-header-disappears-in-puppeteer-fullpage-screenshots-and-how-to-fix-it-3f13</guid>
      <description>&lt;p&gt;I spent two evenings debugging this. Sharing so you don't have to.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;When you call &lt;code&gt;page.screenshot({ fullPage: true })&lt;/code&gt; on a site with a &lt;code&gt;position: fixed&lt;/code&gt; header, one of three things usually happens:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The header renders in the &lt;strong&gt;middle&lt;/strong&gt; of the page (not at the top)&lt;/li&gt;
&lt;li&gt;The header area is &lt;strong&gt;blank&lt;/strong&gt; at the top of the PDF/PNG&lt;/li&gt;
&lt;li&gt;Only the first page shows the header — subsequent "pages" don't&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You open the site in a real browser: looks perfect. You open it in Puppeteer's viewport mode: also fine. Only &lt;code&gt;fullPage: true&lt;/code&gt; breaks it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this happens
&lt;/h2&gt;

&lt;p&gt;Puppeteer's &lt;code&gt;fullPage: true&lt;/code&gt; uses Chrome DevTools Protocol's &lt;code&gt;captureBeyondViewport&lt;/code&gt;. Under the hood, Chrome rasterizes the whole document beyond the viewport — but &lt;code&gt;position: fixed&lt;/code&gt; elements are painted &lt;strong&gt;relative to the current viewport&lt;/strong&gt;, not the document. So if your script scrolled to the bottom of the page before taking the screenshot, the fixed header is captured at the bottom.&lt;/p&gt;

&lt;p&gt;There's also a second issue: many sites have &lt;code&gt;scroll-behavior: smooth&lt;/code&gt; on &lt;code&gt;html&lt;/code&gt;. When you call &lt;code&gt;window.scrollTo(0, 0)&lt;/code&gt;, the scroll is &lt;strong&gt;animated&lt;/strong&gt; and doesn't complete instantly. If Puppeteer captures before the animation finishes, header ends up in a weird intermediate position.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Three things need to happen before the screenshot:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Kill smooth scrolling&lt;/strong&gt; so &lt;code&gt;scrollTo&lt;/code&gt; is instant&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reset any hide-on-scroll transforms&lt;/strong&gt; that JS scroll handlers may have applied&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Actually scroll to &lt;code&gt;(0, 0)&lt;/code&gt;&lt;/strong&gt; and wait a tick for paint&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here's the code I use in production:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;prepareForScreenshot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Disable smooth scrolling — make scrollTo instant&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;style&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;style&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;html { scroll-behavior: auto !important; }&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;head&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// Reset hide-on-scroll state on common header selectors&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;header, .header, [class*="header" i], nav[class*="header" i]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getComputedStyle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;position&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fixed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;cs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;position&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sticky&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;transform&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;none&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;important&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;opacity&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;important&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;visibility&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;visible&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;important&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;display&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;none&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;display&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;flex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;important&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
                &lt;span class="c1"&gt;// Remove common "hidden on scroll down" classes&lt;/span&gt;
                &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hidden&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;is-hidden&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;scroll-up&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;scroll-down&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;header--hidden&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
                    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;

        &lt;span class="c1"&gt;// Scroll to top — now instant&lt;/span&gt;
        &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scrollTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="c1"&gt;// Give the browser one paint frame to settle&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Call it right before &lt;code&gt;page.screenshot&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;prepareForScreenshot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;screenshot&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;out.png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;fullPage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  When the simple fix isn't enough
&lt;/h2&gt;

&lt;p&gt;If the site has a transparent header overlaid on a hero (common modern pattern), the above works great — the header just renders on top of the hero like in a browser.&lt;/p&gt;

&lt;p&gt;If the site has a hide-on-scroll JS library that listens to scroll events &lt;strong&gt;after&lt;/strong&gt; your reset, you may need to dispatch a synthetic scroll event to let it re-evaluate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scrollTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dispatchEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;scroll&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What I'm building
&lt;/h2&gt;

&lt;p&gt;I'm working on &lt;a href="https://site2pdf.online" rel="noopener noreferrer"&gt;Site2PDF&lt;/a&gt; — a tool that converts any website to PDF, PNG, JPG or ZIP. This exact fix is running in production there. If you want to try it, the free plan is 5 archives/month with all formats and advanced options (cookie banner removal, sticky header unfix, accordion expansion).&lt;/p&gt;

&lt;p&gt;Feedback welcome!&lt;/p&gt;

</description>
      <category>pdf</category>
      <category>puppeteer</category>
      <category>node</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
