<?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: shaojie gong</title>
    <description>The latest articles on DEV Community by shaojie gong (@shaojie).</description>
    <link>https://dev.to/shaojie</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%2F4015883%2Ff495cde7-b3bb-432b-8da3-ff6aa5217ebe.png</url>
      <title>DEV Community: shaojie gong</title>
      <link>https://dev.to/shaojie</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/shaojie"/>
    <language>en</language>
    <item>
      <title>NotebookBloom: Why NotebookLM can find the passage but not the PDF page number</title>
      <dc:creator>shaojie gong</dc:creator>
      <pubDate>Thu, 03 Sep 2026 15:10:49 +0000</pubDate>
      <link>https://dev.to/shaojie/notebookbloom-why-notebooklm-can-find-the-passage-but-not-the-pdf-page-number-11aj</link>
      <guid>https://dev.to/shaojie/notebookbloom-why-notebooklm-can-find-the-passage-but-not-the-pdf-page-number-11aj</guid>
      <description>&lt;p&gt;NotebookBloom: Why NotebookLM can find the passage but not the PDF page number&lt;/p&gt;

&lt;p&gt;I spent more time than I expected trying to answer a simple question: if NotebookLM can point to the exact supporting passage, why can’t it give me a reliable page number?&lt;/p&gt;

&lt;p&gt;The short answer is that a passage location and a PDF page are not the same thing.&lt;/p&gt;

&lt;p&gt;NotebookLM appears to flatten a source into a text stream. Its citation can jump to a span of that text, which is enough to highlight the supporting words. But the data available to the page doesn’t consistently include the original PDF page boundary.&lt;/p&gt;

&lt;p&gt;That matters because it would be easy for an extension to make the output look more academic by guessing. It could search the PDF, find a similar sentence, and print “p. 12.” The problem is that PDF extraction is messy: headers repeat, columns get reordered, OCR changes words, and NotebookLM may normalize the passage. A confident-looking page number can still be wrong.&lt;/p&gt;

&lt;p&gt;For NotebookBloom, I chose the less impressive but safer behavior. It keeps the source title and the quoted passage so you can trace the claim back to the source. It does not invent a page number the underlying data cannot support.&lt;/p&gt;

&lt;p&gt;If you are citing a PDF in a paper, the responsible workflow is still:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Use the NotebookLM citation to find the supporting passage.&lt;/li&gt;
&lt;li&gt;Open the original PDF.&lt;/li&gt;
&lt;li&gt;Verify the wording and page number there.&lt;/li&gt;
&lt;li&gt;Build the bibliography entry from checked metadata.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It adds a step, but it is better than turning uncertainty into a precise-looking mistake.&lt;/p&gt;

&lt;p&gt;NotebookBloom: &lt;a href="https://notebookbloom.com" rel="noopener noreferrer"&gt;https://notebookbloom.com&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  NotebookLM #researchtools #citations #buildinpublic
&lt;/h1&gt;

</description>
      <category>notebooklm</category>
      <category>citations</category>
      <category>researchtools</category>
      <category>responsibleai</category>
    </item>
    <item>
      <title>Help: could my Gmail tracking extension break privacy or anti-spam law?</title>
      <dc:creator>shaojie gong</dc:creator>
      <pubDate>Thu, 03 Sep 2026 02:17:00 +0000</pubDate>
      <link>https://dev.to/shaojie/help-could-my-gmail-tracking-extension-break-privacy-or-anti-spam-law-b20</link>
      <guid>https://dev.to/shaojie/help-could-my-gmail-tracking-extension-break-privacy-or-anti-spam-law-b20</guid>
      <description>&lt;p&gt;I'm building a Gmail extension that can track opens and clicks, schedule follow-ups, and generate an AI-assisted draft when the user explicitly asks for one.&lt;/p&gt;

&lt;p&gt;The code is getting close to submission. That created a question I don't think enough extension developers ask:&lt;/p&gt;

&lt;p&gt;If Chrome approves the extension, does that mean the product is legally safe to operate?&lt;/p&gt;

&lt;p&gt;I honestly don't know. That is why I'm asking publicly before launch instead of pretending I already have a compliance answer.&lt;/p&gt;

&lt;p&gt;My assumption is that Chrome Web Store review checks things like declared permissions, deceptive behavior, data-use disclosures, security, and Google's own policies, while Google OAuth verification separately reviews sensitive Google user data. But is that all those approvals mean? Do they provide any meaningful legal assurance, or is the operator still fully responsible for every law that applies where a user or recipient lives?&lt;/p&gt;

&lt;p&gt;That distinction matters more for EmailKnow than for a normal productivity extension, because there are two people in the workflow: the Gmail user who installs the product, and the recipient whose interaction may be measured.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the product actually does
&lt;/h3&gt;

&lt;p&gt;I went back through the code instead of relying on the marketing copy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Open and click tracking are off by default. A sender must turn tracking on for a single-recipient email and confirm that the recipient gave prior consent. Group-message tracking is disabled.&lt;/li&gt;
&lt;li&gt;Tracked messages contain a visible privacy-preferences link. Recipients can stop tracking, or stop tracking and future follow-ups from that sender. Follow-up messages also include a confirmation-based unsubscribe link.&lt;/li&gt;
&lt;li&gt;The Gmail grant is limited to &lt;code&gt;gmail.send&lt;/code&gt; and &lt;code&gt;gmail.metadata&lt;/code&gt;. Normal tracking and follow-up logic cannot read message bodies.&lt;/li&gt;
&lt;li&gt;AI is a separate, user-triggered action. Before content is sent to the Cloudflare-hosted model, the user sees what will be processed, confirms authority to submit it, and is warned not to send passwords, payment data, medical records, identity documents, or trade secrets. AI input and generated drafts are not persistently stored by EmailKnow.&lt;/li&gt;
&lt;li&gt;Refresh tokens are encrypted at rest. Users can disconnect Gmail, export account data, and permanently delete the account. Tracking and follow-up workflow records are purged after 180 days.&lt;/li&gt;
&lt;li&gt;Stripe hosts payment collection, so the extension never receives card numbers.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I hope those controls reduce risk, but I don't know whether they are sufficient, whether some are legally irrelevant, or whether I have designed any of them around the wrong legal assumption.&lt;/p&gt;

&lt;h3&gt;
  
  
  The questions I need help answering
&lt;/h3&gt;

&lt;p&gt;Depending on where the sender, recipient, company, or infrastructure is located, could the product trigger rules about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;privacy and electronic-communications rules for tracking pixels and redirected links;&lt;/li&gt;
&lt;li&gt;whether recipient consent is required, what “prior consent” must look like, and what proof must be retained;&lt;/li&gt;
&lt;li&gt;anti-spam and direct-marketing rules for the original email and automated follow-ups;&lt;/li&gt;
&lt;li&gt;workplace-monitoring rules when an employer tracks employees, candidates, customers, or vendors;&lt;/li&gt;
&lt;li&gt;controller/processor roles, data-processing agreements, subprocessors, retention, and international transfers;&lt;/li&gt;
&lt;li&gt;whether temporarily sending selected email text to an AI processor creates additional notice, contractual, confidentiality, or sector-specific obligations;&lt;/li&gt;
&lt;li&gt;subscription renewal, cancellation, refund, tax, and consumer-protection requirements;&lt;/li&gt;
&lt;li&gt;special restrictions involving children, healthcare, finance, education, government, or other regulated data.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A checkbox that says “I have consent” is a product control, not proof that every user's consent is valid in every jurisdiction. A privacy policy describes a practice; it does not cure an unlawful practice. An unsubscribe link helps; it does not make an unlawful first message lawful.&lt;/p&gt;

&lt;h3&gt;
  
  
  What could happen if the product gets this wrong?
&lt;/h3&gt;

&lt;p&gt;This is the part I most need help understanding. Depending on the facts and jurisdiction, could an operator face complaints, deletion or stop-processing orders, audits, contractual claims, refunds, civil damages, regulatory penalties, or an obligation to notify affected people? Which outcomes are realistic for a small extension, and which depend on scale, intent, type of data, or failure to respond?&lt;/p&gt;

&lt;p&gt;I also don't understand the platform consequences well enough. If the extension later violates Chrome Web Store or Google API policy, could Google reject an update, remove only this listing, restrict its API access, or suspend the entire developer account? If the account is suspended, would unrelated extensions owned by the same account also be removed or become impossible to update? Can enforcement reach associated accounts, and what normally determines whether action is listing-level or account-level?&lt;/p&gt;

&lt;p&gt;I'm not looking for a way to evade enforcement or hide risky behavior behind another account. I want to understand the legitimate way to isolate products operationally while keeping ownership, disclosures, and platform relationships transparent.&lt;/p&gt;

&lt;h3&gt;
  
  
  What I need advice on before launch
&lt;/h3&gt;

&lt;p&gt;Here is the checklist I currently think I need. Please tell me what is missing or misguided:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Keep tracking and follow-ups off by default.&lt;/li&gt;
&lt;li&gt;Keep consent per recipient and per tracked message, not hidden in a global setting.&lt;/li&gt;
&lt;li&gt;Make recipient controls visible and usable without an account.&lt;/li&gt;
&lt;li&gt;Keep Google scopes and stored data to the minimum the feature needs.&lt;/li&gt;
&lt;li&gt;Make AI processing explicit, temporary, and human-reviewed.&lt;/li&gt;
&lt;li&gt;Document subprocessors, retention, export, deletion, and cross-border processing accurately.&lt;/li&gt;
&lt;li&gt;Add abuse handling and a way to suspend users who send unsolicited bulk mail or use deceptive tracking.&lt;/li&gt;
&lt;li&gt;Get qualified legal advice before offering the product globally or calling it “compliant” — but with users and recipients potentially located anywhere in the world, what type of lawyer and jurisdiction-by-jurisdiction review should I actually request?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I'm not posting this as legal advice. I'm posting it because I want the uncomfortable questions in public before there are real users depending on my assumptions.&lt;/p&gt;

&lt;p&gt;If you work in privacy law, Chrome extensions, email deliverability, or SaaS compliance: what am I still missing? Which risk would you resolve before submitting to Chrome, and which one before accepting the first paying customer?&lt;/p&gt;

</description>
    </item>
    <item>
      <title>EmailKnow:My extension sits between you and "Send." That scared me into one rule.</title>
      <dc:creator>shaojie gong</dc:creator>
      <pubDate>Tue, 01 Sep 2026 13:24:48 +0000</pubDate>
      <link>https://dev.to/shaojie/emailknowmy-extension-sits-between-you-and-send-that-scared-me-into-one-rule-4pl4</link>
      <guid>https://dev.to/shaojie/emailknowmy-extension-sits-between-you-and-send-that-scared-me-into-one-rule-4pl4</guid>
      <description>&lt;p&gt;EmailKnow's extension rewrites your email as you send it — swapping links for tracked ones, injecting a pixel. Which means it's standing directly between you and the Send button. And that terrifies me in exactly the right way.&lt;/p&gt;

&lt;p&gt;Because here's the failure I will not ship: you hit Send on something important, my tracking code throws, and your email doesn't go out. A tracker that eats your email is infinitely worse than one that occasionally misses an open.&lt;/p&gt;

&lt;p&gt;So the rule across the whole extension is fail-open: if anything in the tracking path breaks — the rewrite, the pixel, a network call to my backend, a follow-up rule that won't load — the code falls back to Gmail's native send. You lose the tracking on that one email. You never lose the email.&lt;/p&gt;

&lt;p&gt;This flips the usual instinct. Most defensive code fails closed: if you're unsure, stop. That's right for a payment or a delete. It's wrong here. The user's email is sacred; my feature is a bonus riding on top. When they conflict, the bonus loses, every time.&lt;/p&gt;

&lt;p&gt;It changes how I write the code, too. Every tracking step is wrapped so its failure is contained. The follow-up engine treats a single network error as "keep this pending, retry next cycle" — not "give up." When a follow-up is due but a precondition isn't met (already replied, quota exhausted, not authorized), it skips safely instead of doing something dumb.&lt;/p&gt;

&lt;p&gt;There's one deliberate exception, and it fails the OTHER way: the backend. If it boots in production with security secrets still set to dev placeholders, it refuses to serve at all — a hard 503. Because a backend running with a fake signing key isn't "degraded," it's unsafe, and serving anyway would be the real failure. Fail-open for the user's email; fail-closed for the user's security.&lt;/p&gt;

&lt;p&gt;Knowing which way to fail is a product decision disguised as an error handler. Get it backwards and your "safety" code is the thing that hurts people.&lt;/p&gt;

&lt;p&gt;Where do you fail open vs. closed in your own stuff? I'd bet most of us haven't made it a conscious choice.&lt;/p&gt;

&lt;p&gt;— building EmailKnow in public, #6&lt;/p&gt;

</description>
      <category>softwareengineering</category>
      <category>webdev</category>
      <category>buildinpublic</category>
      <category>chrome</category>
    </item>
    <item>
      <title>NotebookBloom: Shipping analytics without collecting user content</title>
      <dc:creator>shaojie gong</dc:creator>
      <pubDate>Mon, 31 Aug 2026 13:26:31 +0000</pubDate>
      <link>https://dev.to/shaojie/notebookbloom-shipping-analytics-without-collecting-user-content-3ln3</link>
      <guid>https://dev.to/shaojie/notebookbloom-shipping-analytics-without-collecting-user-content-3ln3</guid>
      <description>&lt;p&gt;NotebookBloom: Shipping analytics without collecting user content&lt;/p&gt;

&lt;p&gt;I needed a real activation funnel for a Chrome extension that works around research notes. The easiest implementation would have been to attach whatever context was available to every event. I chose a much smaller boundary.&lt;/p&gt;

&lt;p&gt;The extension sends event names such as panel_open, feature_selected, action_started, action_success and action_failed. Parameters are limited to extension version, feature, method, mode and item counts.&lt;/p&gt;

&lt;p&gt;It does not send search queries, URLs, notebook names, flashcard text, citations or document content.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F86rp997cq3awcpm7bih5.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F86rp997cq3awcpm7bih5.gif" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A random anonymous session ID lasts 30 minutes. Its only job is to connect steps from the same short visit so the funnel can be calculated. It is not an account ID and is not used to build a long-term profile.&lt;/p&gt;

&lt;p&gt;I also kept the previous success event names while adding the new funnel. That avoids breaking historical GA4 reports during the transition.&lt;/p&gt;

&lt;p&gt;The design rule was: collect enough to decide what to improve, and nothing merely because it is available. “Which feature failed in version 1.1.2?” is actionable. “What exactly did this person search for?” is not necessary for the current product decision.&lt;/p&gt;

&lt;p&gt;Analytics quality is not proportional to payload size.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://notebookbloom.com" rel="noopener noreferrer"&gt;https://notebookbloom.com&lt;/a&gt;&lt;/p&gt;

</description>
      <category>privacyengineering</category>
      <category>webextensions</category>
      <category>analytics</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>NotebookBloom: Three CTAs, one GIF, and a cleaner way to measure intent</title>
      <dc:creator>shaojie gong</dc:creator>
      <pubDate>Sun, 30 Aug 2026 14:45:01 +0000</pubDate>
      <link>https://dev.to/shaojie/notebookbloom-three-ctas-one-gif-and-a-cleaner-way-to-measure-intent-elj</link>
      <guid>https://dev.to/shaojie/notebookbloom-three-ctas-one-gif-and-a-cleaner-way-to-measure-intent-elj</guid>
      <description>&lt;p&gt;NotebookBloom: Three CTAs, one GIF, and a cleaner way to measure intent&lt;/p&gt;

&lt;p&gt;My product blog had a familiar conversion problem: useful articles, one generic “Get the extension” button, and no way to tell which page or message created intent.&lt;/p&gt;

&lt;p&gt;I changed each high-intent article to use three contextual moments:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Above the fold, after the problem is clear.&lt;/li&gt;
&lt;li&gt;After the workflow demo, when the reader has seen the result.&lt;/li&gt;
&lt;li&gt;At the end, after objections and edge cases are answered.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The copy is specific to the job. A Zotero page says “Export your NotebookLM sources to Zotero.” An Anki page says “Send these NotebookLM flashcards to Anki.”&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk6br6xl8rghubxobb0m2.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk6br6xl8rghubxobb0m2.gif" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every Chrome Store click now sends three non-personal parameters:&lt;br&gt;
• blog_slug — which article&lt;br&gt;
• cta_position — hero, inline or end&lt;br&gt;
• feature — Anki, Zotero, search, etc.&lt;/p&gt;

&lt;p&gt;The event is chrome_store_click, marked as a key event in GA4. UTM values identify the owned website referral consistently.&lt;/p&gt;

&lt;p&gt;The goal is to move website-to-store CTR from about 9.2% toward 15%. More importantly, the setup will tell me why it moved: page, placement or feature—not just that total clicks changed.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://notebookbloom.com" rel="noopener noreferrer"&gt;https://notebookbloom.com&lt;/a&gt;&lt;/p&gt;

</description>
      <category>cro</category>
      <category>webanalytics</category>
      <category>ga4</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>EmailKnow:How I stopped my own inbox from inflating my open rate — with a hash, not an IP</title>
      <dc:creator>shaojie gong</dc:creator>
      <pubDate>Thu, 27 Aug 2026 05:51:46 +0000</pubDate>
      <link>https://dev.to/shaojie/emailknowhow-i-stopped-my-own-inbox-from-inflating-my-open-rate-with-a-hash-not-an-ip-bo0</link>
      <guid>https://dev.to/shaojie/emailknowhow-i-stopped-my-own-inbox-from-inflating-my-open-rate-with-a-hash-not-an-ip-bo0</guid>
      <description>&lt;p&gt;Small, annoying problem with a nice constraint-driven answer.&lt;/p&gt;

&lt;p&gt;When you send a tracked email and then open it yourself — to check it looks right — that fires my tracking pixel. Now your "someone read it!" stat is inflated by... you. Every honest tracker has to solve this, or the numbers lie.&lt;/p&gt;

&lt;p&gt;The obvious fix: record the opener's IP and ignore opens from the sender's IP. But storing recipients' IP addresses is exactly the kind of creepy-tracker behavior EmailKnow exists to avoid. An IP is personal data. I don't want it in my database at all.&lt;/p&gt;

&lt;p&gt;So the constraint became the design: never store an IP, but still tell "this open is the sender" from "this open is someone else."&lt;/p&gt;

&lt;p&gt;The trick is a one-way hash. At send time, I hash the sender's IP with a secret salt — SHA-256(ip + salt) — and store only that hash. When a pixel later fires, I hash that opener's IP the same way and compare. Match → it's the sender opening their own email → label it "self" and exclude it from the real-open count. No match → a genuine third party.&lt;/p&gt;

&lt;p&gt;What's in the database is a hash, never an address. I literally cannot reverse it into "this person is in this city." I can only answer one yes/no question: is this the same source that sent the mail? That's all I need, and it's all I keep.&lt;/p&gt;

&lt;p&gt;This is the pattern I keep reaching for on this project: figure out the single bit of information you actually need, and store the minimum that answers it — nothing that could answer more. "Is this the sender?" needs one comparison, not a location history.&lt;/p&gt;

&lt;p&gt;The self-open exclusion is invisible when it works — your stats just quietly stop counting your own refreshes. The best privacy features are the ones nobody notices.&lt;/p&gt;

&lt;p&gt;If you store any user identifiers: could you replace the raw value with a hash and still answer your actual question? I'm finding the answer is "yes" more often than I expected.&lt;/p&gt;

&lt;p&gt;— building EmailKnow in public, #5&lt;/p&gt;

</description>
      <category>privacy</category>
      <category>webdev</category>
      <category>buildinpublic</category>
      <category>emailknow</category>
    </item>
    <item>
      <title>NotebookBloom: What 559 Google impressions taught me about page-two SEO</title>
      <dc:creator>shaojie gong</dc:creator>
      <pubDate>Wed, 26 Aug 2026 13:54:57 +0000</pubDate>
      <link>https://dev.to/shaojie/notebookbloom-what-559-google-impressions-taught-me-about-page-two-seo-22i1</link>
      <guid>https://dev.to/shaojie/notebookbloom-what-559-google-impressions-taught-me-about-page-two-seo-22i1</guid>
      <description>&lt;p&gt;NotebookBloom: What 559 Google impressions taught me about page-two SEO&lt;/p&gt;

&lt;p&gt;My site’s first meaningful Search Console sample was small: 559 impressions, 7 clicks and a 1.25% CTR. The aggregate number was not very actionable. The page-level view was.&lt;/p&gt;

&lt;p&gt;Three high-intent pages were already close enough to matter:&lt;br&gt;
• NotebookLM → Zotero: 130 impressions, 5 clicks, 3.85% CTR, average position 16.48&lt;br&gt;
• Cross-notebook search: 82 impressions, 0 clicks, position 17.18&lt;br&gt;
• NotebookLM → Anki: 62 impressions, 0 clicks, position 16.4&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9naira7ijsilhkxetln3.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9naira7ijsilhkxetln3.gif" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;These pages are not invisible. They are sitting on page two for queries that match actual product jobs. That changed my SEO plan from “publish more topics” to “finish the pages Google is already testing.”&lt;/p&gt;

&lt;p&gt;The work included tightening titles and descriptions, matching the opening paragraph to the search job, adding a real workflow GIF, improving internal links, adding FAQ/article/video structured data, fixing canonical URL consistency and making each CTA specific to the page.&lt;/p&gt;

&lt;p&gt;The rule I’m using now: before creating a new article, check whether an existing page at positions 11–20 can be made decisively better.&lt;/p&gt;

&lt;p&gt;I’ll compare the next 28 days by page—not just total traffic—to see whether the changes move impressions into clicks.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://notebookbloom.com" rel="noopener noreferrer"&gt;https://notebookbloom.com&lt;/a&gt;&lt;/p&gt;

</description>
      <category>seo</category>
      <category>buildinpublic</category>
      <category>searchconsole</category>
      <category>contentstrategy</category>
    </item>
    <item>
      <title>NotebookBloom: A 40% store conversion rate can still hide the real bottleneck</title>
      <dc:creator>shaojie gong</dc:creator>
      <pubDate>Tue, 25 Aug 2026 13:33:18 +0000</pubDate>
      <link>https://dev.to/shaojie/notebookbloom-a-40-store-conversion-rate-can-still-hide-the-real-bottleneck-34e5</link>
      <guid>https://dev.to/shaojie/notebookbloom-a-40-store-conversion-rate-can-still-hide-the-real-bottleneck-34e5</guid>
      <description>&lt;p&gt;NotebookBloom: A 40% store conversion rate can still hide the real bottleneck&lt;/p&gt;

&lt;p&gt;I combined three small data sources for my Chrome extension: Google Search Console, the product website’s GA4 property, and the Chrome Web Store export.&lt;/p&gt;

&lt;p&gt;The store funnel looked like this:&lt;br&gt;
191 impressions → 82 listing visitors (42.9%) → 33 installs (40.2% of listing visitors)&lt;/p&gt;

&lt;p&gt;The website told a different story:&lt;br&gt;
76 users → 7 Chrome Web Store clicks (about 9.2%)&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fswlqdzij1u6jan5csncn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fswlqdzij1u6jan5csncn.png" alt=" " width="800" height="320"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;With a tiny sample, 40.2% is not a benchmark or a victory lap. But it is enough to challenge my original assumption. Rewriting the store listing again was unlikely to be the highest-leverage move. The larger leaks were before and after it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Not enough qualified search exposure.&lt;/li&gt;
&lt;li&gt;Too few website visitors clicking through to the store.&lt;/li&gt;
&lt;li&gt;No complete activation funnel after installation.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That changed the work list: improve high-intent SEO pages and their CTAs, measure CTA position and page intent, then measure first successful action in the extension.&lt;/p&gt;

&lt;p&gt;The lesson was not “the store page is solved.” It was “read the entire journey before optimizing the easiest screen to see.”&lt;/p&gt;

&lt;p&gt;NotebookBloom: &lt;a href="https://notebookbloom.com" rel="noopener noreferrer"&gt;https://notebookbloom.com&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  buildinpublic #conversionrate #chromeextension #analytics
&lt;/h1&gt;

</description>
      <category>buildinpublic</category>
      <category>notebookbloom</category>
      <category>conversionrate</category>
      <category>chromeextension</category>
    </item>
    <item>
      <title>EmailKnow:I hold the keys to send email as my users. Here's how I made that as un-scary as I could.</title>
      <dc:creator>shaojie gong</dc:creator>
      <pubDate>Tue, 25 Aug 2026 06:25:11 +0000</pubDate>
      <link>https://dev.to/shaojie/emailknowi-hold-the-keys-to-send-email-as-my-users-heres-how-i-made-that-as-un-scary-as-i-could-3cn6</link>
      <guid>https://dev.to/shaojie/emailknowi-hold-the-keys-to-send-email-as-my-users-heres-how-i-made-that-as-un-scary-as-i-could-3cn6</guid>
      <description>&lt;p&gt;To send a follow-up on your behalf, EmailKnow needs a Gmail refresh token — a long-lived key that lets my backend get permission to send as you, over and over, without you being present. Let that sink in: I'm holding something that can send email as you.&lt;/p&gt;

&lt;p&gt;I did not want that token sitting in my database in plaintext. A database leak shouldn't equal "attacker can now email everyone's contacts as them." So a few decisions:&lt;/p&gt;

&lt;p&gt;1/ Encrypt the refresh token at rest with AES-256-GCM. The database stores ciphertext, never the raw token. The encryption key lives in a secret, separate from the data. Leak the DB alone and you get noise.&lt;/p&gt;

&lt;p&gt;2/ The client secret never leaves the server. The OAuth flow exchanges an authorization code for tokens, and that exchange needs Google's client secret. It's tempting to do more in the extension — but the extension is untrusted; anyone can unpack it. So the secret only ever exists on the backend. The extension never sees it.&lt;/p&gt;

&lt;p&gt;3/ Access tokens (the short-lived ones) get cached in KV with their real expiry, so I'm not constantly re-minting them — but they're short-lived by design, so the blast radius is small.&lt;/p&gt;

&lt;p&gt;4/ Revoke means revoke. When a user disconnects Gmail, I delete the stored refresh token AND clear the cached access token. No zombie access.&lt;/p&gt;

&lt;p&gt;There's a broader principle I've started applying everywhere in this project: assume the database will leak someday, and design so that a leak is embarrassing, not catastrophic. Bodies never stored. IPs only as salted hashes. Tokens only as ciphertext. If the worst happens, the loot is worthless.&lt;/p&gt;

&lt;p&gt;None of this is exotic crypto — it's AES-GCM and keeping a secret on the server. The hard part isn't the algorithm; it's deciding, before you ship, that "plaintext for now, encrypt later" is not acceptable when the thing you're storing can send email as another human.&lt;/p&gt;

&lt;p&gt;For those who've built OAuth backends: do you encrypt refresh tokens at rest, or avoid storing them entirely? I went with encrypt — curious where others land.&lt;/p&gt;

&lt;p&gt;— building EmailKnow in public, #4&lt;/p&gt;

</description>
      <category>security</category>
      <category>oauth</category>
      <category>webdev</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>NotebookBloom: Why I stopped treating “installed” as activation</title>
      <dc:creator>shaojie gong</dc:creator>
      <pubDate>Mon, 24 Aug 2026 13:10:42 +0000</pubDate>
      <link>https://dev.to/shaojie/notebookbloom-why-i-stopped-treating-installed-as-activation-28f1</link>
      <guid>https://dev.to/shaojie/notebookbloom-why-i-stopped-treating-installed-as-activation-28f1</guid>
      <description>&lt;p&gt;NotebookBloom: Why I stopped treating “installed” as activation&lt;/p&gt;

&lt;p&gt;I used to watch an install count move and assume the product was growing. That number is comforting, but it does not tell me whether anyone reached the useful part of the extension.&lt;/p&gt;

&lt;p&gt;For NotebookBloom, a real first-use journey looks more like this:&lt;/p&gt;

&lt;p&gt;extension_open → panel_open → feature_selected → action_started → action_success / action_failed&lt;/p&gt;

&lt;p&gt;The old analytics mostly recorded successful actions. That created a blind spot: if ten people opened the extension and only two finished an export, I could see the two successes but not the eight people who got lost, changed their mind, or hit an error.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F66y1efzoify8soz2uw6t.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F66y1efzoify8soz2uw6t.png" alt=" " width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The next version records each step with a small set of parameters: extension version, feature, method, mode, item count, successful items and failed items. It still does not record notebook titles, search queries, URLs or document content.&lt;/p&gt;

&lt;p&gt;This changes what I can improve. A drop between extension_open and panel_open points to onboarding or compatibility. A drop after feature_selected points to confusing controls. A high action_failed rate points to reliability. “More installs” cannot tell those stories.&lt;/p&gt;

&lt;p&gt;My new definition of activation is simple: the user completes one useful action, not merely installs the extension.&lt;/p&gt;

&lt;p&gt;NotebookBloom: &lt;a href="https://notebookbloom.com" rel="noopener noreferrer"&gt;https://notebookbloom.com&lt;/a&gt;&lt;br&gt;
Chrome Web Store: &lt;a href="https://chromewebstore.google.com/detail/fmnhogknjhlaicceielblddajjabjlha" rel="noopener noreferrer"&gt;https://chromewebstore.google.com/detail/fmnhogknjhlaicceielblddajjabjlha&lt;/a&gt;&lt;/p&gt;

</description>
      <category>buildinpublic</category>
      <category>webextensions</category>
      <category>productanalytics</category>
      <category>notebookbloom</category>
    </item>
    <item>
      <title>EmailKnow:The follow-up that never fired: a race condition with Gmail I didn't see coming</title>
      <dc:creator>shaojie gong</dc:creator>
      <pubDate>Fri, 21 Aug 2026 04:19:47 +0000</pubDate>
      <link>https://dev.to/shaojie/emailknowthe-follow-up-that-never-fired-a-race-condition-with-gmail-i-didnt-see-coming-3e09</link>
      <guid>https://dev.to/shaojie/emailknowthe-follow-up-that-never-fired-a-race-condition-with-gmail-i-didnt-see-coming-3e09</guid>
      <description>&lt;p&gt;My headline feature — automatic follow-ups in the same email thread — was quietly a no-op for every brand-new email. It took me a while to see why, because nothing errored. It just... didn't follow up.&lt;/p&gt;

&lt;p&gt;Here's the trap.&lt;/p&gt;

&lt;p&gt;To send a follow-up in the SAME conversation, I need Gmail's thread ID. My extension intercepts the email at send time and grabs what it can. Problem: at that exact moment, Gmail hasn't assigned a thread ID yet — it's assigned after the message is actually delivered. So for a fresh email, the thread ID I captured was empty.&lt;/p&gt;

&lt;p&gt;My old logic saw an empty thread ID and permanently skipped that email with reason "no_thread." Which means: the feature worked fine in tests (where I fed it a thread ID) and was dead on arrival for every real first email a user sends. The worst kind of bug — green tests, broken product.&lt;/p&gt;

&lt;p&gt;The fix had to respect a hard privacy constraint, which made it interesting. EmailKnow only holds the gmail.metadata scope — read email HEADERS, never bodies. And that scope explicitly forbids two things: the &lt;code&gt;q&lt;/code&gt; search parameter and reading message content. So I couldn't just search "find the message I just sent."&lt;/p&gt;

&lt;p&gt;So the backend backfills the thread ID the honest, allowed way, on the Cron that runs every 17 minutes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Take emails that still have no thread ID, aren't replied yet, still have a pending follow-up, and were sent within the last 24h.&lt;/li&gt;
&lt;li&gt;List the SENT mailbox with labelIds=SENT and format=metadata (headers only — no q param, no body, ever).&lt;/li&gt;
&lt;li&gt;Match by recipient + subject + a time window. If several match, take the one closest to the send moment.&lt;/li&gt;
&lt;li&gt;Write the thread ID back (idempotently: only where it's still null).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the key behavior change: an email that's due for follow-up but still has no thread ID no longer gets thrown away as "no_thread." It stays pending and retries next cycle, until it either resolves or ages out of the 24h window. Failing safe, not failing silent.&lt;/p&gt;

&lt;p&gt;The lesson I keep relearning: a test that passes because YOU supplied the perfect input isn't testing the real world. The real world hands you an empty string at the worst possible moment.&lt;/p&gt;

&lt;p&gt;What's your best "green tests, dead feature" story?&lt;/p&gt;

&lt;p&gt;— building EmailKnow in public, #3&lt;/p&gt;

</description>
      <category>gmail</category>
      <category>buildinpublic</category>
      <category>emailknow</category>
    </item>
    <item>
      <title>NotebookBloom: How to load an entire YouTube playlist into NotebookLM and ask questions across the whole course</title>
      <dc:creator>shaojie gong</dc:creator>
      <pubDate>Thu, 20 Aug 2026 13:31:33 +0000</pubDate>
      <link>https://dev.to/shaojie/notebookbloom-how-to-load-an-entire-youtube-playlist-into-notebooklm-and-ask-questions-across-the-bid</link>
      <guid>https://dev.to/shaojie/notebookbloom-how-to-load-an-entire-youtube-playlist-into-notebooklm-and-ask-questions-across-the-bid</guid>
      <description>&lt;p&gt;NotebookLM (now also called Gemini Notebook) is great at one thing people underuse: you can add a YouTube video as a source and then ask questions across everything you've added. The catch is it takes one URL at a time — fine for a couple of videos, rough for a whole course.&lt;/p&gt;

&lt;p&gt;Here's the free manual way, and the shortcut at the end.&lt;/p&gt;

&lt;p&gt;The manual method&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open the playlist on YouTube.&lt;/li&gt;
&lt;li&gt;For each video, copy its link.&lt;/li&gt;
&lt;li&gt;In NotebookLM: Add source -&amp;gt; paste the URL.&lt;/li&gt;
&lt;li&gt;Repeat for every video.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fygzxxv0alwqz030w8wm6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fygzxxv0alwqz030w8wm6.png" alt=" " width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That's genuinely all there is to it — no integration, no export. It just doesn't scale past a handful because it's the same four clicks over and over.&lt;/p&gt;

&lt;p&gt;One thing worth knowing&lt;br&gt;
NotebookLM works off the video's transcript, not the visuals. So playlists where people talk — lectures, conference talks, tutorials — give it a lot to work with. Silent footage or pure music won't.&lt;/p&gt;

&lt;p&gt;Why bother at all&lt;br&gt;
Once a playlist is in, you can ask across the whole thing: "what are the main arguments in this course," "which video covers deployment," and each answer tells you which video it came from. That per-source attribution is the part that makes it trustworthy instead of a summary you take on faith.&lt;/p&gt;

&lt;p&gt;The shortcut&lt;br&gt;
The only painful step is adding 30 URLs by hand. That's the exact step I automated in my extension, NotebookBloom (&lt;a href="https://notebookbloom.com" rel="noopener noreferrer"&gt;https://notebookbloom.com&lt;/a&gt;) — paste a single playlist or channel link and it adds them all as sources. But the manual method above is free and works today; the extension just removes the tedium.&lt;/p&gt;

&lt;p&gt;Get it on the Chrome Web Store: &lt;a href="https://chromewebstore.google.com/detail/fmnhogknjhlaicceielblddajjabjlha" rel="noopener noreferrer"&gt;https://chromewebstore.google.com/detail/fmnhogknjhlaicceielblddajjabjlha&lt;/a&gt;&lt;/p&gt;

</description>
      <category>notebookbloom</category>
      <category>buildinpublic</category>
    </item>
  </channel>
</rss>
