{"slug": "i-ran-22-directory-submissions-with-playwright-in-one-day-here-is-what-actually", "title": "I ran 22 directory submissions with Playwright in one day. Here is what actually blocked me.", "summary": "A developer who attempted to submit SongStory, a personalized song generator, to 22 SaaS and AI directories using Playwright found that only 5 submissions succeeded, with 17 failing due to site-side issues rather than automation problems. The failures included hard paywalls, platform changes, and site bugs, and the developer highlighted the importance of verifying actual rendered HTML versus marketing claims, as some directories' free tiers did not provide the expected dofollow links.", "body_md": "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 [SongStory](https://songstory.ai/), a personalized song generator, and only 5 submissions actually went through.\n\nThe other 17 failed. Almost none of them failed for the reason I expected.\n\nHere is the real distribution, because I think the failure modes are more useful than another \"how to automate forms\" tutorial.\n\n| Outcome | Count | Notes | \n|---|---|---|\n| Submitted successfully | 5 | 3 fully automated, 1 needed a human for reCAPTCHA, 1 by email | \n| Hard paywall | 9 | No free tier at all, or free tier explicitly strips the link | \n| Platform changed shape | 5 | Registration closed, site became app-only, field removed | \n| Site-side bug or hard block | 2 | Broken form, Cloudflare | \n| Bad neighborhood / wrong category | 3 | Would have hurt more than helped | \n\nNote 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.\n\nThis was the most common paywall pattern, and the most interesting one.\n\nOne directory, `bai.tools`, 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.\n\nThat 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 `page.request.get`:\n\n``` js\nconst r = await page.request.get(listingUrl, {\n  headers: { 'User-Agent': 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)' }\n});\nconst html = await r.text();\n// pull every outbound anchor and its rel attribute\nconst links = [...html.matchAll(/<a([^>]*?)href=\"(https?:\\/\\/[^\"]+)\"([^>]*)>/gi)]\n  .map(m => ({ href: m[2], attrs: (m[1] + m[3]).trim() }));\n```\n\nThe outbound links on live listings came back as bare URLs with `rel=\"dofollow\"`, visible in the server-rendered HTML that Googlebot receives. The marketing copy and the actual markup did not agree.\n\nThe lesson generalizes: **verify what the page renders, not what the pricing page claims.** 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.\n\nThe inverse trap is worse, because it looks like success.\n\nI 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 `rel` empty in DevTools. Dofollow, apparently.\n\nThen I fetched the same URL with a Googlebot UA and found this in the raw HTML:\n\n```\n<a class=\"lhr2 text-white\" ng-href=\"{{:: website.url}}\" target=\"_blank\"\n   ng-repeat=\"website in ctrl.websites | limitTo:(ctrl.limit || 4)\">\n```\n\nAn unrendered AngularJS template. The `href` 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.\n\nThat 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 *Coming Soon* for new accounts. The dofollow section I had been looking at lives on `legacy.` subdomain profiles created years ago.\n\n**If you check `rel` only in the rendered DOM, you will report links that may not exist for a crawler.** Check both. It costs one extra request.\n\nOne directory consumed thirty minutes before I understood it was unwinnable.\n\nEvery 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:\n\n``` js\npage.on('request', r => { if (r.method() === 'POST') console.log(r.url(), r.postData()); });\npage.on('response', async r => {\n  if (r.request().method() === 'POST') console.log(r.status(), (await r.text()).slice(0, 300));\n});\n```\n\nZero POST requests. The click never produced a network call at all, which meant client-side validation was rejecting the form silently.\n\nThe cause turned out to be in the markup:\n\n```\n<input type=\"radio\" name=\"form-name\" value=\"audio_generators\">  <!-- category -->\n<input type=\"radio\" name=\"form-name\" value=\"freemium\">          <!-- pricing -->\n```\n\nTwo logically separate radio groups sharing one `name`. 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 `document.querySelectorAll('input[type=radio]:checked')` flip back and forth.\n\nWorth 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.\n\nTwo sites had genuinely good metrics — decent traffic, reasonable domain age, healthy organic share — and I walked away from both.\n\nOne 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.\n\nAnother 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.\n\nBoth 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.\n\nFive 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.\n\nA few implementation notes that saved time:\n\n**Upload files by targeting the input directly.** Do not click the upload button and handle a native file chooser — if the page navigates mid-dialog you lose every field you filled.\n\n``` js\nconst input = await page.$('input[type=\"file\"]');\nawait input.setInputFiles('/path/to/screenshot.png');\n```\n\nWatch the preview `src`. On WordPress listing themes it flips to something like `/wp-content/uploads/listing-uploads/...` once the async upload registers. If `input.files` has an entry but no preview appears, the front-end state never registered the file and it will not be submitted.\n\n**Rich text editors need their own API.** `fill()` on the underlying textarea does nothing when TinyMCE is mounted on top:\n\n``` js\nawait page.evaluate(html => {\n  window.tinymce.get('job_description').setContent(html);\n}, descriptionHtml);\n```\n\n**Select2 and similar widgets need real mouse events.** Dispatching a synthetic `click()` inside `page.evaluate` will not register the selection. Use Playwright's locator click, which drives the actual input pipeline:\n\n```\nawait searchBox.fill('music');\nawait page.waitForTimeout(2000);           // async option load\nawait page.locator('.select2-results__option')\n  .filter({ hasText: /^Music$/ })\n  .first()\n  .click();                                 // real click, not evaluate\n```\n\n**Watch for re-renders that wipe your work.** 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 *first*, then fill.\n\n**React controlled inputs ignore `.value =`.** Use the native setter so React's onChange fires:\n\n``` js\nconst setter = Object.getOwnPropertyDescriptor(\n  window.HTMLInputElement.prototype, 'value'\n).set;\nsetter.call(el, value);\nel.dispatchEvent(new Event('input', { bubbles: true }));\n```\n\nOne submission required a human to tick a reCAPTCHA v2 checkbox. Clicking `#recaptcha-anchor` inside the anchor iframe left `aria-checked=\"false\"` and produced no token, which is exactly what should happen.\n\nThe 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:\n\n``` js\nconst token = () => page.evaluate(\n  () => document.querySelector('#g-recaptcha-response')?.value?.length || 0\n);\n```\n\nFilling 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.\n\nOne 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.\n\nThe 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.\n\nSo: 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.\n\nIf you want to see the product all this was for, it is [SongStory](https://songstory.ai/) — 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.", "url": "https://wpnews.pro/news/i-ran-22-directory-submissions-with-playwright-in-one-day-here-is-what-actually", "canonical_source": "https://dev.to/norabennett_music/i-ran-22-directory-submissions-with-playwright-in-one-day-here-is-what-actually-blocked-me-1egh", "published_at": "2026-09-08 09:23:05+00:00", "updated_at": "2026-09-08 09:31:41.192686+00:00", "lang": "en", "topics": ["developer-tools", "ai-products"], "entities": ["SongStory", "Playwright", "bai.tools"], "alternates": {"html": "https://wpnews.pro/news/i-ran-22-directory-submissions-with-playwright-in-one-day-here-is-what-actually", "markdown": "https://wpnews.pro/news/i-ran-22-directory-submissions-with-playwright-in-one-day-here-is-what-actually.md", "text": "https://wpnews.pro/news/i-ran-22-directory-submissions-with-playwright-in-one-day-here-is-what-actually.txt", "jsonld": "https://wpnews.pro/news/i-ran-22-directory-submissions-with-playwright-in-one-day-here-is-what-actually.jsonld"}}