<?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: Nora Bennett</title>
    <description>The latest articles on DEV Community by Nora Bennett (@norabennett_music).</description>
    <link>https://dev.to/norabennett_music</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%2F4050425%2F0cf29c64-3db4-434d-aaa7-b8df996e0b94.jpg</url>
      <title>DEV Community: Nora Bennett</title>
      <link>https://dev.to/norabennett_music</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/norabennett_music"/>
    <language>en</language>
    <item>
      <title>I ran 22 directory submissions with Playwright in one day. Here is what actually blocked me.</title>
      <dc:creator>Nora Bennett</dc:creator>
      <pubDate>Tue, 08 Sep 2026 09:23:05 +0000</pubDate>
      <link>https://dev.to/norabennett_music/i-ran-22-directory-submissions-with-playwright-in-one-day-here-is-what-actually-blocked-me-1egh</link>
      <guid>https://dev.to/norabennett_music/i-ran-22-directory-submissions-with-playwright-in-one-day-here-is-what-actually-blocked-me-1egh</guid>
      <description>&lt;p&gt;Submitting a product to SaaS and AI directories is the kind of task that looks perfect for browser automation: same five fields, same "Submit" button, fifty different sites. I spent a day driving 22 of them with Playwright for &lt;a href="https://songstory.ai/" rel="noopener noreferrer"&gt;SongStory&lt;/a&gt;, a personalized song generator, and only 5 submissions actually went through.&lt;/p&gt;

&lt;p&gt;The other 17 failed. Almost none of them failed for the reason I expected.&lt;/p&gt;

&lt;p&gt;Here is the real distribution, because I think the failure modes are more useful than another "how to automate forms" tutorial.&lt;/p&gt;

&lt;h2&gt;
  
  
  The scoreboard
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Outcome&lt;/th&gt;
&lt;th&gt;Count&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Submitted successfully&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;3 fully automated, 1 needed a human for reCAPTCHA, 1 by email&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hard paywall&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;No free tier at all, or free tier explicitly strips the link&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Platform changed shape&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Registration closed, site became app-only, field removed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Site-side bug or hard block&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Broken form, Cloudflare&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bad neighborhood / wrong category&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Would have hurt more than helped&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Note that "the automation broke" is not a row in that table. Playwright handled every form I pointed it at. What killed submissions was the state of the sites themselves.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure mode 1: the free tier that quietly removes your link
&lt;/h2&gt;

&lt;p&gt;This was the most common paywall pattern, and the most interesting one.&lt;/p&gt;

&lt;p&gt;One directory, &lt;code&gt;bai.tools&lt;/code&gt;, offers a free listing with the line "Submit without backlink for free!" — a paid tier at $19 gets you the dofollow link. My first instinct was to skip it. Free tier, no link, no point.&lt;/p&gt;

&lt;p&gt;That instinct was wrong, and it is worth explaining why. I checked an existing listing on that same site with two different user agents: a normal browser and a Googlebot UA fetched via &lt;code&gt;page.request.get&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;r&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;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;request&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;listingUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;headers&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;User-Agent&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;Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)&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="nx"&gt;html&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="c1"&gt;// pull every outbound anchor and its rel attribute&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;links&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;matchAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;a&lt;/span&gt;&lt;span class="se"&gt;([^&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;&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;href="&lt;/span&gt;&lt;span class="se"&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="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="se"&gt;([^&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;&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;&amp;gt;/gi&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;attrs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&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="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&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="p"&gt;}));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The outbound links on live listings came back as bare URLs with &lt;code&gt;rel="dofollow"&lt;/code&gt;, visible in the server-rendered HTML that Googlebot receives. The marketing copy and the actual markup did not agree.&lt;/p&gt;

&lt;p&gt;The lesson generalizes: &lt;strong&gt;verify what the page renders, not what the pricing page claims.&lt;/strong&gt; A site's own description of its free tier is marketing, not a measurement. Fetching one existing listing takes ten seconds and settles the question.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure mode 2: rendered DOM vs. what a crawler sees
&lt;/h2&gt;

&lt;p&gt;The inverse trap is worse, because it looks like success.&lt;/p&gt;

&lt;p&gt;I registered on a large music platform (2.3M monthly visits, 20 years old — on paper an excellent link) and got to a profile page where the outbound website links showed &lt;code&gt;rel&lt;/code&gt; empty in DevTools. Dofollow, apparently.&lt;/p&gt;

&lt;p&gt;Then I fetched the same URL with a Googlebot UA and found this in the raw HTML:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"lhr2 text-white"&lt;/span&gt; &lt;span class="na"&gt;ng-href=&lt;/span&gt;&lt;span class="s"&gt;"{{:: website.url}}"&lt;/span&gt; &lt;span class="na"&gt;target=&lt;/span&gt;&lt;span class="s"&gt;"_blank"&lt;/span&gt;
   &lt;span class="na"&gt;ng-repeat=&lt;/span&gt;&lt;span class="s"&gt;"website in ctrl.websites | limitTo:(ctrl.limit || 4)"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An unrendered AngularJS template. The &lt;code&gt;href&lt;/code&gt; attribute does not exist in the server response — it is produced client-side. Google does render JavaScript, but "probably fine" is not the same as "verified," and I had no way to confirm it.&lt;/p&gt;

&lt;p&gt;That platform ended up failing for an unrelated reason anyway: the current version of its profile editor only accepts Spotify and Apple Music URLs. There is no free-form website field anymore, and the "Public Profile" menu item reads &lt;em&gt;Coming Soon&lt;/em&gt; for new accounts. The dofollow section I had been looking at lives on &lt;code&gt;legacy.&lt;/code&gt; subdomain profiles created years ago.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you check &lt;code&gt;rel&lt;/code&gt; only in the rendered DOM, you will report links that may not exist for a crawler.&lt;/strong&gt; Check both. It costs one extra request.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure mode 3: forms that nobody can submit
&lt;/h2&gt;

&lt;p&gt;One directory consumed thirty minutes before I understood it was unwinnable.&lt;/p&gt;

&lt;p&gt;Every field filled cleanly. Clicking Submit did nothing — no error, no toast, no redirect. Following the "dump the response instead of guessing" rule, I attached a listener before clicking:&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="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;request&lt;/span&gt;&lt;span class="dl"&gt;'&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="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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;method&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&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="nf"&gt;url&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="nf"&gt;postData&lt;/span&gt;&lt;span class="p"&gt;());&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="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;response&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="nx"&gt;r&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;request&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;method&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&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="nf"&gt;status&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;slice&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;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;Zero POST requests. The click never produced a network call at all, which meant client-side validation was rejecting the form silently.&lt;/p&gt;

&lt;p&gt;The cause turned out to be in the markup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"radio"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"form-name"&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"audio_generators"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;  &lt;span class="c"&gt;&amp;lt;!-- category --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"radio"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"form-name"&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"freemium"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;          &lt;span class="c"&gt;&amp;lt;!-- pricing --&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two logically separate radio groups sharing one &lt;code&gt;name&lt;/code&gt;. Browsers enforce mutual exclusivity within a name, so selecting a pricing model deselects your category and vice versa. Both are required. The form can never be completed — not by a script, not by a person. I confirmed by watching &lt;code&gt;document.querySelectorAll('input[type=radio]:checked')&lt;/code&gt; flip back and forth.&lt;/p&gt;

&lt;p&gt;Worth internalizing: when a button produces no network activity, stop retrying and start listening. Retrying a click ten times tells you nothing; one request listener tells you everything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure mode 4: the link neighborhood
&lt;/h2&gt;

&lt;p&gt;Two sites had genuinely good metrics — decent traffic, reasonable domain age, healthy organic share — and I walked away from both.&lt;/p&gt;

&lt;p&gt;One had roughly twenty outbound links to Vietnamese gambling sites in its footer. Whatever that domain's metrics say, it is selling links to a gambling PBN, and a link from it sits in that neighborhood.&lt;/p&gt;

&lt;p&gt;Another was a clean, free, no-login submission via Typeform. I got to step two before reading the category list: all 35 options were B2B SaaS operations tools — Analytics, DevOps, HR, Sales (B2B), QA Testing. A consumer gift product has no honest home there. Forcing it in would produce either a rejection or a listing filed under something irrelevant.&lt;/p&gt;

&lt;p&gt;Both of those are judgment calls a script cannot make for you, and both require actually opening the page. No amount of metadata would have caught either one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually worked
&lt;/h2&gt;

&lt;p&gt;Five submissions landed. The three fully automated ones shared a shape: a real free tier, a website field, and either no captcha or an invisible one that passes on its own.&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%2F70jia83rnszf8lez9bdl.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%2F70jia83rnszf8lez9bdl.png" alt="The SongStory five-step creation flow: occasion, style, story, lyrics, song" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A few implementation notes that saved time:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Upload files by targeting the input directly.&lt;/strong&gt; Do not click the upload button and handle a native file chooser — if the page navigates mid-dialog you lose every field you filled.&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;input&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;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&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;input[type="file"]&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;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setInputFiles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/path/to/screenshot.png&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;Watch the preview &lt;code&gt;src&lt;/code&gt;. On WordPress listing themes it flips to something like &lt;code&gt;/wp-content/uploads/listing-uploads/...&lt;/code&gt; once the async upload registers. If &lt;code&gt;input.files&lt;/code&gt; has an entry but no preview appears, the front-end state never registered the file and it will not be submitted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rich text editors need their own API.&lt;/strong&gt; &lt;code&gt;fill()&lt;/code&gt; on the underlying textarea does nothing when TinyMCE is mounted on top:&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="nx"&gt;html&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="nx"&gt;tinymce&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;job_description&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;setContent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;descriptionHtml&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Select2 and similar widgets need real mouse events.&lt;/strong&gt; Dispatching a synthetic &lt;code&gt;click()&lt;/code&gt; inside &lt;code&gt;page.evaluate&lt;/code&gt; will not register the selection. Use Playwright's locator click, which drives the actual input pipeline:&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;searchBox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;music&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;waitForTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;           &lt;span class="c1"&gt;// async option load&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;locator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.select2-results__option&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;filter&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;hasText&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/^Music$/&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;                                 &lt;span class="c1"&gt;// real click, not evaluate&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Watch for re-renders that wipe your work.&lt;/strong&gt; One registration form re-rendered when I toggled an account-type radio, clearing every field I had filled and then rejecting the submit with "Please provide a valid email address." Order matters: set the option that causes the re-render &lt;em&gt;first&lt;/em&gt;, then fill.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;React controlled inputs ignore &lt;code&gt;.value =&lt;/code&gt;.&lt;/strong&gt; Use the native setter so React's onChange fires:&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;setter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getOwnPropertyDescriptor&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="nx"&gt;HTMLInputElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prototype&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;value&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="kd"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;setter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&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;value&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;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;input&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;bubbles&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;
  
  
  The part that stays manual
&lt;/h2&gt;

&lt;p&gt;One submission required a human to tick a reCAPTCHA v2 checkbox. Clicking &lt;code&gt;#recaptcha-anchor&lt;/code&gt; inside the anchor iframe left &lt;code&gt;aria-checked="false"&lt;/code&gt; and produced no token, which is exactly what should happen.&lt;/p&gt;

&lt;p&gt;The workable pattern is not to defeat it. Fill every other field first, scroll the widget into view, hand the window over, and submit the moment the token appears. Polling for it is trivial:&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;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&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="p"&gt;()&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;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#g-recaptcha-response&lt;/span&gt;&lt;span class="dl"&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="nx"&gt;length&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Filling first matters more than it sounds. Captcha tokens expire, and some forms invalidate them on validation failure — solve it before the form is complete and you may burn it for nothing.&lt;/p&gt;

&lt;p&gt;One more manual-ish case: a directory whose only submission channel was an email address. Its "Submit a tool" button did nothing and every guessable path 404'd, so the submission went out as a plain email with the name, URL, category and description. Not everything needs a form.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell myself before starting
&lt;/h2&gt;

&lt;p&gt;The automation was never the hard part. Playwright filled every form correctly on the first or second attempt. What consumed the day was the gap between what sites advertise and what they actually do — free tiers that turn out to include the link, "dofollow" that only exists after JavaScript runs, forms shipped broken, and directories whose metrics look fine until you read the footer.&lt;/p&gt;

&lt;p&gt;So: open the page. Fetch one existing listing with a crawler UA. Attach a request listener before you click. And when a site's copy disagrees with its markup, believe the markup.&lt;/p&gt;

&lt;p&gt;If you want to see the product all this was for, it is &lt;a href="https://songstory.ai/" rel="noopener noreferrer"&gt;SongStory&lt;/a&gt; — you describe a person and an occasion, it writes the lyrics for you to edit, then sings them. Which, unlike directory submission, turned out to be the easy part of the day.&lt;/p&gt;

</description>
      <category>automationplaywrightwebdevseo</category>
    </item>
    <item>
      <title>I turned a birthday story into a finished song with AI — here's the workflow</title>
      <dc:creator>Nora Bennett</dc:creator>
      <pubDate>Tue, 28 Jul 2026 03:49:17 +0000</pubDate>
      <link>https://dev.to/norabennett_music/i-turned-a-birthday-story-into-a-finished-song-with-ai-heres-the-workflow-3a8p</link>
      <guid>https://dev.to/norabennett_music/i-turned-a-birthday-story-into-a-finished-song-with-ai-heres-the-workflow-3a8p</guid>
      <description>&lt;p&gt;My mum turned 60 last month, and I am the kind of person who forgets to buy a card until the shop is closed. A mug felt lazy. Flowers die. What she actually loves is old music — the stuff that was on the radio when she met my dad in a launderette in 1986. So I tried something I had been putting off for months: writing her an actual song about that story, using an AI song generator instead of a producer I could not afford.&lt;/p&gt;

&lt;p&gt;This is a write-up of the workflow I landed on after two evenings of trial and error, what broke, and the parts that genuinely surprised me. If you have ever wanted to give someone a song but assumed you needed to play an instrument, this is for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a "story to song" tool, not a lyric generator
&lt;/h2&gt;

&lt;p&gt;My first instinct was to paste a prompt into a chatbot and ask for lyrics. That failed fast. The lyrics were technically rhyming and completely hollow — "through the years we grew so strong" type filler that could be about anyone. The problem is that a generic model has nothing to work with. It does not know the launderette, or that my dad still cannot fold a fitted sheet.&lt;/p&gt;

&lt;p&gt;What changed the result was switching to a &lt;strong&gt;story-first&lt;/strong&gt; flow. Instead of asking for a song, I wrote three short paragraphs of the actual memory: names, the year, the in-jokes, the specific detail about the fitted sheet. A tool built for this — I used &lt;a href="https://songstory.ai/" rel="noopener noreferrer"&gt;SongStory&lt;/a&gt; — takes that story and writes the lyrics &lt;em&gt;from&lt;/em&gt; it, then produces the vocal and instrumentation as a finished track. The mental model is closer to briefing a songwriter than prompting a chatbot.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Feed it the messy, specific story
&lt;/h2&gt;

&lt;p&gt;The single biggest lever on quality was how specific my input was. My first attempt was vague ("my mum is kind and loves music") and the output was forgettable. My second attempt included:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Her name and the fact everyone calls her "Sal"&lt;/li&gt;
&lt;li&gt;The launderette in 1986&lt;/li&gt;
&lt;li&gt;The fitted-sheet joke&lt;/li&gt;
&lt;li&gt;That she sang "Dancing Queen" badly at every party&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every one of those details showed up in the finished lyrics, and that is what made my sister cry when she heard it. Lesson: treat the input like you are telling a friend the story, not writing ad copy. Concrete nouns beat adjectives.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Read the lyrics before anything gets sung
&lt;/h2&gt;

&lt;p&gt;The part I did not expect to matter: the good tools show you the lyrics &lt;em&gt;before&lt;/em&gt; they generate audio. This is the difference between a black box and something you can actually control.&lt;/p&gt;

&lt;p&gt;On my mum's song, the first lyric draft put the fitted-sheet joke in the chorus, which was too much — a punchline works once, not four times. I moved it to the second verse, asked for a rewrite of the bridge to be less saccharine, and only then let it sing. Being able to edit line by line is what takes the result from "cute AI gimmick" to "thing I would actually give someone." If a generator hides the lyrics and hands you a finished MP3, you have no way to fix a line that lands wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Pick a style that fits the person, not the trend
&lt;/h2&gt;

&lt;p&gt;I went with a warm acoustic pop arrangement because that is what my mum listens to. The temptation is to pick whatever sounds most impressive — a big cinematic build, an EDM drop — but a personalized song lives or dies on whether it sounds like &lt;em&gt;them&lt;/em&gt;. Most story to song generators give you 20+ styles; the skill is matching the style to the recipient, not to what shows off the model.&lt;/p&gt;

&lt;p&gt;One practical note: I generated two full takes of the same lyrics and they were meaningfully different — different melody in the chorus, slightly different phrasing. I almost kept the first out of laziness, but the second take nailed the emotional beat in the bridge. If your tool gives you more than one version, listen to both properly before deciding.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: The delivery matters more than you think
&lt;/h2&gt;

&lt;p&gt;Here is the thing nobody tells you about giving a song as a gift: a raw MP3 in a text message is anticlimactic. What worked was a dedicated share page — cover art, a player, and a short note — that I could send as a single link. My mum opened it on her phone at the dinner table and the whole thing had a moment instead of getting lost in a group chat.&lt;/p&gt;

&lt;p&gt;If you are building something similar yourself, the takeaway is that the &lt;em&gt;packaging&lt;/em&gt; is part of the product. A personalized song generator that stops at the audio file is only doing half the job.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do differently
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Write more story than you think you need.&lt;/strong&gt; I trimmed my input the first time and regretted it. Give it the boring specifics.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do not skip the lyric-editing step.&lt;/strong&gt; It is tempting to accept the first draft. Don't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test on one person before a big occasion.&lt;/strong&gt; I made a throwaway song about my flatmate's cat first, purely to learn the flow, before touching the real gift.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The honest verdict
&lt;/h2&gt;

&lt;p&gt;I am not a musician and I never will be. Two evenings of tinkering got me a three-minute song that made a 60-year-old woman cry at her own birthday dinner, for less than the cost of the flowers I did not buy. The tech is not magic — the quality is entirely downstream of how specific and honest your story is — but as a way to turn a memory into something someone can actually keep, it genuinely works.&lt;/p&gt;

&lt;p&gt;If you want to try the same workflow, the story-to-song approach I used is at &lt;a href="https://songstory.ai/" rel="noopener noreferrer"&gt;songstory.ai&lt;/a&gt;. Feed it a real memory, edit the lyrics, and give yourself the extra ten minutes to listen to both takes. That is the whole trick.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>music</category>
      <category>productivity</category>
    </item>
  </channel>
</rss>
