backlinkflow 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -121,6 +121,9 @@ No hardcoded sites. Examples in `examples/`.
121
121
  | `backlinkflow status` | Tracker summary (submitted/pending/failed) |
122
122
  | `backlinkflow report` | Regenerate proof-of-submission report |
123
123
  | `backlinkflow stats` | Database stats (counts by category, alive/dead) |
124
+ | `backlinkflow indexnow <url>` | Ping IndexNow (Bing/Yandex/Naver/Seznam) + Google for instant indexing |
125
+ | `backlinkflow awesome <repo> --dry-run` | Generate awesome-list PR snippet (never opens PRs) |
126
+ | `backlinkflow measure <url>` | Measure backlinks via free Common Crawl index |
124
127
  | `backlinkflow db:review` | Flag DB quality issues (dead links, homepage-as-submit) |
125
128
  | `backlinkflow db:regenerate` | Rebuild DB from source lists |
126
129
  | `backlinkflow init` | Write config template |
@@ -303,8 +306,8 @@ examples/ # 2 example site configs
303
306
 
304
307
  - [x] **v0.1** — directory DB, AI payloads, tracker, reports
305
308
  - [x] **v0.2** — Playwright automation engine (`--go`), site adapters, pacing, proof screenshots
306
- - [ ] **v0.3** — GitHub awesome-list PR generator, IndexNow pings
307
- - [ ] **v0.4** — seoflow integration (measure backlinks before/after submission)
309
+ - [x] **v0.3** — IndexNow pinger, awesome-list PR generator
310
+ - [x] **v0.4** — Common Crawl backlink measurement
308
311
  - [ ] **v0.5** — Badge-handling component (the Submitator moat we haven't replicated)
309
312
 
310
313
  ---
@@ -0,0 +1,80 @@
1
+ function suggestSection(cfg) {
2
+ const hay = [
3
+ cfg.siteName,
4
+ cfg.siteDescription || "",
5
+ (cfg.tags || []).join(" "),
6
+ cfg.contentDomain || ""
7
+ ].join(" ").toLowerCase();
8
+ const find = (keys) => keys.some((k) => hay.includes(k));
9
+ if (find(["ai", "llm", "machine learning", "gpt", "generative"])) return "AI";
10
+ if (find(["saas", "product", "startup", "tool", "app"])) return "Products & Tools";
11
+ if (find(["developer", "dev", "api", "cli", "open source", "self-host"])) return "Developer Tools";
12
+ if (find(["travel", "blog", "content", "seo"])) return "Content & SEO";
13
+ return "Tools";
14
+ }
15
+ function buildOneLiner(cfg) {
16
+ const fallback = cfg.contentDomain || cfg.siteName || "a product";
17
+ const raw = cfg.siteDescription || `A free tool for ${fallback}.`;
18
+ let line = raw.trim().replace(/\s+/g, " ").replace(/\.$/, "");
19
+ if (line.length > 80) line = `${line.slice(0, 77)}\u2026`;
20
+ return line;
21
+ }
22
+ function buildSnippet(cfg) {
23
+ const name = cfg.siteName || "Your Product";
24
+ const url = cfg.siteUrl || "https://example.com";
25
+ const desc = buildOneLiner(cfg);
26
+ return {
27
+ markdown: `- [${name}](${url}) \u2014 ${desc}`,
28
+ suggestedSection: suggestSection(cfg)
29
+ };
30
+ }
31
+ function buildPrBody(cfg, repo) {
32
+ const { markdown, suggestedSection } = buildSnippet(cfg);
33
+ return [
34
+ `## Add ${cfg.siteName || "Product"} to ${repo}`,
35
+ "",
36
+ `### Checklist`,
37
+ "",
38
+ "- [x] The snippet follows awesome-list formatting (`- [Name](url) \u2014 one-liner`)",
39
+ "- [x] Description is \u226480 chars, no trailing period",
40
+ "- [x] I have read the repository's contribution guidelines",
41
+ "",
42
+ `### Snippet`,
43
+ "",
44
+ "```markdown",
45
+ markdown,
46
+ "```",
47
+ "",
48
+ `### Details`,
49
+ "",
50
+ `- **Product:** ${cfg.siteName || "(unset)"}`,
51
+ `- **URL:** ${cfg.siteUrl || "(unset)"}`,
52
+ `- **Suggested section:** ${suggestedSection}`,
53
+ `- **Category/tags:** ${(cfg.tags || []).join(", ") || "(none)"}`,
54
+ "",
55
+ "---",
56
+ "",
57
+ `_Generated by [BacklinkFlow](https://github.com/imsankz/backlinkflow) \`awesome\` (dry-run). Manual PR only \u2014 the tool never pushes._`
58
+ ].join("\n");
59
+ }
60
+ function buildInstructions(repo, snippet) {
61
+ return [
62
+ `1. Fork https://github.com/${repo}`,
63
+ `2. Clone, then edit the README.md of the awesome list`,
64
+ `3. Add this line to the ${snippet.suggestedSection} section (find the closest match):`,
65
+ "",
66
+ ` ${snippet.markdown}`,
67
+ "",
68
+ "4. Commit (conventional: `docs: add <product>`) and push to your fork",
69
+ "5. Open a PR against the upstream repo \u2014 paste the PR body below",
70
+ "6. Respect each list's contribution guide; some want alphabetical order"
71
+ ].join("\n");
72
+ }
73
+ export {
74
+ buildInstructions,
75
+ buildOneLiner,
76
+ buildPrBody,
77
+ buildSnippet,
78
+ suggestSection
79
+ };
80
+ //# sourceMappingURL=awesome.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/awesome.ts"],
4
+ "sourcesContent": ["/**\n * BacklinkFlow v0.3 \u2014 GitHub awesome-list contribution generator.\n *\n * DRY-RUN ONLY. Generates the markdown snippet (product name, one-line\n * description, link) formatted for an awesome-list README contribution,\n * plus the PR body and step-by-step instructions. Never opens a PR and\n * never pushes to another repo.\n *\n * Awesome-list convention (awesome-lint friendly):\n * `- [Product Name](https://site.com) \u2014 One-line description, \u226480 chars, no period.`\n */\nimport { loadConfig } from './config.js';\n\nexport interface AwesomeSnippet {\n /** The markdown list item, e.g. `- [Name](url) \u2014 description` */\n markdown: string;\n /** Which awesome-list section it fits best (heuristic). */\n suggestedSection: string;\n}\n\n/** Pick the most likely awesome-list section for the config content. */\nexport function suggestSection(cfg: ReturnType<typeof loadConfig>): string {\n const hay = [\n cfg.siteName,\n cfg.siteDescription || '',\n (cfg.tags || []).join(' '),\n cfg.contentDomain || '',\n ].join(' ').toLowerCase();\n const find = (keys: string[]): boolean => keys.some((k) => hay.includes(k));\n if (find(['ai', 'llm', 'machine learning', 'gpt', 'generative'])) return 'AI';\n if (find(['saas', 'product', 'startup', 'tool', 'app'])) return 'Products & Tools';\n if (find(['developer', 'dev', 'api', 'cli', 'open source', 'self-host'])) return 'Developer Tools';\n if (find(['travel', 'blog', 'content', 'seo'])) return 'Content & SEO';\n return 'Tools';\n}\n\n/** Build the one-line description from config. */\nexport function buildOneLiner(cfg: ReturnType<typeof loadConfig>): string {\n const fallback = cfg.contentDomain || cfg.siteName || 'a product';\n const raw = cfg.siteDescription || `A free tool for ${fallback}.`;\n let line = raw.trim().replace(/\\s+/g, ' ').replace(/\\.$/, '');\n // awesome-lint caps list descriptions at 80 chars, no trailing period.\n if (line.length > 80) line = `${line.slice(0, 77)}\u2026`;\n return line;\n}\n\n/** Build the markdown list item. */\nexport function buildSnippet(cfg: ReturnType<typeof loadConfig>): AwesomeSnippet {\n const name = cfg.siteName || 'Your Product';\n const url = cfg.siteUrl || 'https://example.com';\n const desc = buildOneLiner(cfg);\n return {\n markdown: `- [${name}](${url}) \u2014 ${desc}`,\n suggestedSection: suggestSection(cfg),\n };\n}\n\n/** Full PR body for the awesome-list contribution (dry-run output). */\nexport function buildPrBody(cfg: ReturnType<typeof loadConfig>, repo: string): string {\n const { markdown, suggestedSection } = buildSnippet(cfg);\n return [\n `## Add ${cfg.siteName || 'Product'} to ${repo}`,\n '',\n `### Checklist`,\n '',\n '- [x] The snippet follows awesome-list formatting (\\`- [Name](url) \u2014 one-liner\\`)',\n '- [x] Description is \u226480 chars, no trailing period',\n \"- [x] I have read the repository's contribution guidelines\",\n '',\n `### Snippet`,\n '',\n '```markdown',\n markdown,\n '```',\n '',\n `### Details`,\n '',\n `- **Product:** ${cfg.siteName || '(unset)'}`,\n `- **URL:** ${cfg.siteUrl || '(unset)'}`,\n `- **Suggested section:** ${suggestedSection}`,\n `- **Category/tags:** ${(cfg.tags || []).join(', ') || '(none)'}`,\n '',\n '---',\n '',\n `_Generated by [BacklinkFlow](https://github.com/imsankz/backlinkflow) \\`awesome\\` (dry-run). Manual PR only \u2014 the tool never pushes._`,\n ].join('\\n');\n}\n\n/** Human instructions to turn the snippet into a real contribution. */\nexport function buildInstructions(repo: string, snippet: AwesomeSnippet): string {\n return [\n `1. Fork https://github.com/${repo}`,\n `2. Clone, then edit the README.md of the awesome list`,\n `3. Add this line to the ${snippet.suggestedSection} section (find the closest match):`,\n '',\n ` ${snippet.markdown}`,\n '',\n '4. Commit (conventional: `docs: add <product>`) and push to your fork',\n '5. Open a PR against the upstream repo \u2014 paste the PR body below',\n '6. Respect each list\\'s contribution guide; some want alphabetical order',\n ].join('\\n');\n}\n"],
5
+ "mappings": "AAqBO,SAAS,eAAe,KAA4C;AACzE,QAAM,MAAM;AAAA,IACV,IAAI;AAAA,IACJ,IAAI,mBAAmB;AAAA,KACtB,IAAI,QAAQ,CAAC,GAAG,KAAK,GAAG;AAAA,IACzB,IAAI,iBAAiB;AAAA,EACvB,EAAE,KAAK,GAAG,EAAE,YAAY;AACxB,QAAM,OAAO,CAAC,SAA4B,KAAK,KAAK,CAAC,MAAM,IAAI,SAAS,CAAC,CAAC;AAC1E,MAAI,KAAK,CAAC,MAAM,OAAO,oBAAoB,OAAO,YAAY,CAAC,EAAG,QAAO;AACzE,MAAI,KAAK,CAAC,QAAQ,WAAW,WAAW,QAAQ,KAAK,CAAC,EAAG,QAAO;AAChE,MAAI,KAAK,CAAC,aAAa,OAAO,OAAO,OAAO,eAAe,WAAW,CAAC,EAAG,QAAO;AACjF,MAAI,KAAK,CAAC,UAAU,QAAQ,WAAW,KAAK,CAAC,EAAG,QAAO;AACvD,SAAO;AACT;AAGO,SAAS,cAAc,KAA4C;AACxE,QAAM,WAAW,IAAI,iBAAiB,IAAI,YAAY;AACtD,QAAM,MAAM,IAAI,mBAAmB,mBAAmB,QAAQ;AAC9D,MAAI,OAAO,IAAI,KAAK,EAAE,QAAQ,QAAQ,GAAG,EAAE,QAAQ,OAAO,EAAE;AAE5D,MAAI,KAAK,SAAS,GAAI,QAAO,GAAG,KAAK,MAAM,GAAG,EAAE,CAAC;AACjD,SAAO;AACT;AAGO,SAAS,aAAa,KAAoD;AAC/E,QAAM,OAAO,IAAI,YAAY;AAC7B,QAAM,MAAM,IAAI,WAAW;AAC3B,QAAM,OAAO,cAAc,GAAG;AAC9B,SAAO;AAAA,IACL,UAAU,MAAM,IAAI,KAAK,GAAG,YAAO,IAAI;AAAA,IACvC,kBAAkB,eAAe,GAAG;AAAA,EACtC;AACF;AAGO,SAAS,YAAY,KAAoC,MAAsB;AACpF,QAAM,EAAE,UAAU,iBAAiB,IAAI,aAAa,GAAG;AACvD,SAAO;AAAA,IACL,UAAU,IAAI,YAAY,SAAS,OAAO,IAAI;AAAA,IAC9C;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA,kBAAkB,IAAI,YAAY,SAAS;AAAA,IAC3C,cAAc,IAAI,WAAW,SAAS;AAAA,IACtC,4BAA4B,gBAAgB;AAAA,IAC5C,yBAAyB,IAAI,QAAQ,CAAC,GAAG,KAAK,IAAI,KAAK,QAAQ;AAAA,IAC/D;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF,EAAE,KAAK,IAAI;AACb;AAGO,SAAS,kBAAkB,MAAc,SAAiC;AAC/E,SAAO;AAAA,IACL,8BAA8B,IAAI;AAAA,IAClC;AAAA,IACA,2BAA2B,QAAQ,gBAAgB;AAAA,IACnD;AAAA,IACA,MAAM,QAAQ,QAAQ;AAAA,IACtB;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF,EAAE,KAAK,IAAI;AACb;",
6
+ "names": []
7
+ }
@@ -12,27 +12,49 @@ const genericAdapter = {
12
12
  async submit({ page, payload, siteUrl, log }) {
13
13
  await page.waitForLoadState("domcontentloaded");
14
14
  await page.waitForTimeout(1500);
15
- const name = page.locator('input[name*="name" i], input[placeholder*="name" i], input[placeholder*="title" i]').first();
16
- const url = page.locator('input[name*="url" i], input[type="url"], input[placeholder*="url" i], input[placeholder*="website" i]').first();
17
- const email = page.locator('input[type="email"], input[name*="email" i]').first();
18
- const desc = page.locator("textarea").first();
15
+ const { detectFields, fillVisible } = await import("./fields.js");
16
+ const fields = await detectFields(page);
19
17
  let filled = 0;
20
- if (await name.isVisible().catch(() => false)) {
21
- await name.fill(payload.name);
18
+ if (await fields.name?.isVisible().catch(() => false)) {
19
+ await fields.name.fill(payload.name);
22
20
  filled++;
23
21
  }
24
- if (await url.isVisible().catch(() => false)) {
25
- await url.fill(siteUrl);
22
+ if (await fields.url?.isVisible().catch(() => false)) {
23
+ await fields.url.fill(siteUrl);
26
24
  filled++;
27
25
  }
28
- if (await email.isVisible().catch(() => false) && payload.fields?.email) {
29
- await email.fill(payload.fields.email);
26
+ if (await fields.email?.isVisible().catch(() => false) && payload.fields?.email) {
27
+ await fields.email.fill(payload.fields.email);
30
28
  filled++;
31
29
  }
32
- if (await desc.isVisible().catch(() => false)) {
33
- await desc.fill(payload.description);
30
+ if (await fields.description?.isVisible().catch(() => false)) {
31
+ await fields.description.fill(payload.description);
34
32
  filled++;
35
33
  }
34
+ if (payload.category && fields.selects) {
35
+ for (const s of fields.selects) {
36
+ if (!await s.locator.isVisible().catch(() => false)) continue;
37
+ const opts = await s.locator.locator("option").allTextContents().catch(() => []);
38
+ const want = payload.category.toLowerCase();
39
+ const exact = opts.find((o) => o.toLowerCase().trim() === want);
40
+ const fuzzy = opts.find((o) => o.toLowerCase().includes(want.slice(0, 5)) || want.includes(o.toLowerCase().slice(0, 5)));
41
+ const pick = exact || fuzzy || s.value;
42
+ if (pick) {
43
+ await s.locator.selectOption({ label: pick });
44
+ filled++;
45
+ }
46
+ break;
47
+ }
48
+ }
49
+ if (payload.fields?.pricing && fields.radios) {
50
+ for (const r of fields.radios) {
51
+ if (!await r.locator.isVisible().catch(() => false)) continue;
52
+ await r.locator.check({ force: true }).catch(() => r.locator.click({ force: true }).catch(() => {
53
+ }));
54
+ filled++;
55
+ break;
56
+ }
57
+ }
36
58
  if (filled === 0) {
37
59
  return { ok: false, note: "no fillable form fields detected" };
38
60
  }
@@ -40,12 +62,13 @@ const genericAdapter = {
40
62
  await page.waitForTimeout(800);
41
63
  const ok = await clickSubmit(page);
42
64
  await page.waitForTimeout(2500);
65
+ const { verdictAfterSubmit } = await import("./submit.js");
43
66
  const body = await page.textContent("body").catch(() => "");
44
- const success = /(thank you|submitted|success|received|we'll review|got it|in review|pending|added)/i.test(body);
45
- const stillOnForm = await name.isVisible().catch(() => false);
67
+ const stillOnForm = await fields.name?.isVisible().catch(() => false) ?? true;
68
+ const v = verdictAfterSubmit(body, page.url(), !stillOnForm, filled);
46
69
  return {
47
- ok: ok && (success || !stillOnForm),
48
- note: success ? "success message detected" : stillOnForm ? "may still be on form" : "form submitted, awaiting response"
70
+ ok: ok && v.ok,
71
+ note: v.ok ? v.note : ok ? `${v.note} (submitted but unverified)` : "submit button click failed"
49
72
  };
50
73
  }
51
74
  };
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/engine/adapters.ts"],
4
- "sourcesContent": ["/**\n * LinkFlow v0.2 \u2014 site adapter registry.\n * Each adapter knows how to submit to one directory. The generic adapter\n * auto-detects form fields; site-specific adapters handle login/paywall/captcha.\n */\nimport type { Page } from 'playwright';\nimport type { Payload } from '../types.js';\n\nexport interface SubmitContext {\n page: Page;\n payload: Payload;\n siteUrl: string;\n email?: string;\n /** credentials for login-required sites (from config) */\n credentials?: Record<string, { email?: string; password?: string }>;\n /** log a step */\n log: (msg: string) => void;\n}\n\nexport interface Adapter {\n /** Directory name(s) this adapter handles (matched against dir.name). */\n matches: string[];\n /** Requires login credentials? */\n needsCredentials?: boolean;\n submit(ctx: SubmitContext): Promise<{ ok: boolean; proof?: string; note?: string }>;\n}\n\n/** Try to click a submit button, fallback to Enter. */\nasync function clickSubmit(page: Page): Promise<boolean> {\n const btn = page.locator('button[type=\"submit\"], input[type=\"submit\"], button:has-text(\"Submit\"), button:has-text(\"Add\"), button:has-text(\"Launch\"), button:has-text(\"Save\")').first();\n if (await btn.isVisible().catch(() => false)) {\n await btn.click();\n return true;\n }\n // fallback: press Enter in a focused field\n await page.keyboard.press('Enter');\n return true;\n}\n\n/** Generic adapter \u2014 smart auto-detection for any form-based directory. */\nexport const genericAdapter: Adapter = {\n matches: [],\n async submit({ page, payload, siteUrl, log }) {\n // Wait for form to settle\n await page.waitForLoadState('domcontentloaded');\n await page.waitForTimeout(1500);\n\n // Detect and fill fields\n const name = page.locator('input[name*=\"name\" i], input[placeholder*=\"name\" i], input[placeholder*=\"title\" i]').first();\n const url = page.locator('input[name*=\"url\" i], input[type=\"url\"], input[placeholder*=\"url\" i], input[placeholder*=\"website\" i]').first();\n const email = page.locator('input[type=\"email\"], input[name*=\"email\" i]').first();\n const desc = page.locator('textarea').first();\n\n let filled = 0;\n if (await name.isVisible().catch(() => false)) { await name.fill(payload.name); filled++; }\n if (await url.isVisible().catch(() => false)) { await url.fill(siteUrl); filled++; }\n if (await email.isVisible().catch(() => false) && payload.fields?.email) { await email.fill(payload.fields.email); filled++; }\n if (await desc.isVisible().catch(() => false)) { await desc.fill(payload.description); filled++; }\n\n if (filled === 0) {\n return { ok: false, note: 'no fillable form fields detected' };\n }\n\n log(` filled ${filled} fields`);\n await page.waitForTimeout(800);\n\n // Submit\n const ok = await clickSubmit(page);\n await page.waitForTimeout(2500);\n\n // Verify: did we leave the form / get a success message?\n const body = await page.textContent('body').catch(() => '');\n const success = /(thank you|submitted|success|received|we'll review|got it|in review|pending|added)/i.test(body);\n const stillOnForm = await name.isVisible().catch(() => false);\n\n return {\n ok: ok && (success || !stillOnForm),\n note: success ? 'success message detected' : stillOnForm ? 'may still be on form' : 'form submitted, awaiting response',\n };\n },\n};\n\n/** SaaSHub \u2014 requires login. */\nexport const saashubAdapter: Adapter = {\n matches: ['SaaSHub', 'saashub.com'],\n needsCredentials: true,\n async submit({ page, payload, siteUrl, credentials, log }) {\n const creds = credentials?.saashub;\n if (!creds?.email || !creds?.password) {\n return { ok: false, note: 'SaaSHub needs credentials.saashub in config' };\n }\n // Login\n await page.goto('https://www.saashub.com/login');\n await page.waitForTimeout(1200);\n await page.fill('input[name=\"email\"], input[type=\"email\"]', creds.email);\n await page.fill('input[name=\"password\"], input[type=\"password\"]', creds.password);\n await page.click('button[type=\"submit\"], input[type=\"submit\"]');\n await page.waitForTimeout(2500);\n\n // Submit page\n await page.goto('https://www.saashub.com/new');\n await page.waitForTimeout(1500);\n await genericAdapter.submit({ page, payload, siteUrl, log });\n return { ok: true, note: 'submitted via saashub adapter' };\n },\n};\n\n/** Product Hunt \u2014 anti-bot, manual only. Mark as manual. */\nexport const productHuntAdapter: Adapter = {\n matches: ['Product Hunt', 'producthunt.com'],\n async submit() {\n return { ok: false, note: 'Product Hunt is manual-only (anti-bot). Use linkflow payload to prep copy.' };\n },\n};\n\n/** Registry: all adapters. */\nexport const ADAPTERS: Adapter[] = [genericAdapter, saashubAdapter, productHuntAdapter];\n\n/** Find an adapter for a directory name. */\nexport function findAdapter(dirName: string): Adapter | null {\n const n = dirName.toLowerCase();\n return ADAPTERS.find((a) => a.matches.some((m) => n.includes(m.toLowerCase()))) || null;\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["/**\n * LinkFlow v0.2 \u2014 site adapter registry.\n * Each adapter knows how to submit to one directory. The generic adapter\n * auto-detects form fields; site-specific adapters handle login/paywall/captcha.\n */\nimport type { Page } from 'playwright';\nimport type { Payload } from '../types.js';\n\nexport interface SubmitContext {\n page: Page;\n payload: Payload;\n siteUrl: string;\n email?: string;\n /** credentials for login-required sites (from config) */\n credentials?: Record<string, { email?: string; password?: string }>;\n /** log a step */\n log: (msg: string) => void;\n}\n\nexport interface Adapter {\n /** Directory name(s) this adapter handles (matched against dir.name). */\n matches: string[];\n /** Requires login credentials? */\n needsCredentials?: boolean;\n submit(ctx: SubmitContext): Promise<{ ok: boolean; proof?: string; note?: string }>;\n}\n\n/** Try to click a submit button, fallback to Enter. */\nasync function clickSubmit(page: Page): Promise<boolean> {\n const btn = page.locator('button[type=\"submit\"], input[type=\"submit\"], button:has-text(\"Submit\"), button:has-text(\"Add\"), button:has-text(\"Launch\"), button:has-text(\"Save\")').first();\n if (await btn.isVisible().catch(() => false)) {\n await btn.click();\n return true;\n }\n // fallback: press Enter in a focused field\n await page.keyboard.press('Enter');\n return true;\n}\n\n/** Generic adapter \u2014 smart auto-detection for any form-based directory. */\nexport const genericAdapter: Adapter = {\n matches: [],\n async submit({ page, payload, siteUrl, log }) {\n // Wait for form to settle\n await page.waitForLoadState('domcontentloaded');\n await page.waitForTimeout(1500);\n\n // Detect and fill fields (v0.3: also select dropdowns + radio pricing)\n const { detectFields, fillVisible } = await import('./fields.js');\n const fields = await detectFields(page);\n\n let filled = 0;\n if (await fields.name?.isVisible().catch(() => false)) { await fields.name.fill(payload.name); filled++; }\n if (await fields.url?.isVisible().catch(() => false)) { await fields.url.fill(siteUrl); filled++; }\n if (await fields.email?.isVisible().catch(() => false) && payload.fields?.email) { await fields.email.fill(payload.fields.email); filled++; }\n if (await fields.description?.isVisible().catch(() => false)) { await fields.description.fill(payload.description); filled++; }\n // select dropdowns (category) + radios (pricing)\n if (payload.category && fields.selects) {\n for (const s of fields.selects) {\n if (!(await s.locator.isVisible().catch(() => false))) continue;\n const opts = await s.locator.locator('option').allTextContents().catch(() => [] as string[]);\n const want = payload.category.toLowerCase();\n const exact = opts.find((o) => o.toLowerCase().trim() === want);\n const fuzzy = opts.find((o) => o.toLowerCase().includes(want.slice(0, 5)) || want.includes(o.toLowerCase().slice(0, 5)));\n const pick = exact || fuzzy || s.value;\n if (pick) { await s.locator.selectOption({ label: pick }); filled++; }\n break;\n }\n }\n if (payload.fields?.pricing && fields.radios) {\n for (const r of fields.radios) {\n if (!(await r.locator.isVisible().catch(() => false))) continue;\n await r.locator.check({ force: true }).catch(() => r.locator.click({ force: true }).catch(() => {}));\n filled++;\n break;\n }\n }\n\n if (filled === 0) {\n return { ok: false, note: 'no fillable form fields detected' };\n }\n\n log(` filled ${filled} fields`);\n await page.waitForTimeout(800);\n\n // Submit\n const ok = await clickSubmit(page);\n await page.waitForTimeout(2500);\n\n // Verify via shared pure logic: success message, redirect off form, soft error\n const { verdictAfterSubmit } = await import('./submit.js');\n const body = await page.textContent('body').catch(() => '');\n const stillOnForm = await fields.name?.isVisible().catch(() => false) ?? true;\n const v = verdictAfterSubmit(body, page.url(), !stillOnForm, filled);\n\n return {\n ok: ok && v.ok,\n note: v.ok ? v.note : ok ? `${v.note} (submitted but unverified)` : 'submit button click failed',\n };\n },\n};\n\n/** SaaSHub \u2014 requires login. */\nexport const saashubAdapter: Adapter = {\n matches: ['SaaSHub', 'saashub.com'],\n needsCredentials: true,\n async submit({ page, payload, siteUrl, credentials, log }) {\n const creds = credentials?.saashub;\n if (!creds?.email || !creds?.password) {\n return { ok: false, note: 'SaaSHub needs credentials.saashub in config' };\n }\n // Login\n await page.goto('https://www.saashub.com/login');\n await page.waitForTimeout(1200);\n await page.fill('input[name=\"email\"], input[type=\"email\"]', creds.email);\n await page.fill('input[name=\"password\"], input[type=\"password\"]', creds.password);\n await page.click('button[type=\"submit\"], input[type=\"submit\"]');\n await page.waitForTimeout(2500);\n\n // Submit page\n await page.goto('https://www.saashub.com/new');\n await page.waitForTimeout(1500);\n await genericAdapter.submit({ page, payload, siteUrl, log });\n return { ok: true, note: 'submitted via saashub adapter' };\n },\n};\n\n/** Product Hunt \u2014 anti-bot, manual only. Mark as manual. */\nexport const productHuntAdapter: Adapter = {\n matches: ['Product Hunt', 'producthunt.com'],\n async submit() {\n return { ok: false, note: 'Product Hunt is manual-only (anti-bot). Use linkflow payload to prep copy.' };\n },\n};\n\n/** Registry: all adapters. */\nexport const ADAPTERS: Adapter[] = [genericAdapter, saashubAdapter, productHuntAdapter];\n\n/** Find an adapter for a directory name. */\nexport function findAdapter(dirName: string): Adapter | null {\n const n = dirName.toLowerCase();\n return ADAPTERS.find((a) => a.matches.some((m) => n.includes(m.toLowerCase()))) || null;\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -2,24 +2,120 @@ const NAME_RE = /(name|title|product|app.?name|tool.?name|startup)/i;
2
2
  const URL_RE = /(url|website|link|homepage|site|domain)/i;
3
3
  const EMAIL_RE = /(email|e-?mail)/i;
4
4
  const DESC_RE = /(desc|description|about|summary|detail|intro|what)/i;
5
- const SUBMIT_RE = /(submit|send|add|post|create|list|suggest|save|launch)/i;
6
- function byText(page, re) {
7
- return [
8
- page.locator('input[type="text"], input:not([type]), textarea, input[type="url"], input[type="email"]').filter({ hasText: re }),
9
- page.locator('input[type="text"], input:not([type]), textarea, input[type="url"], input[type="email"]').filter({ has: page.locator("..") }),
10
- page.locator(`input[placeholder*="${re.source}" i]`),
11
- page.locator(`input[name*="${re.source}" i]`),
12
- page.locator(`textarea[placeholder*="${re.source}" i]`),
13
- page.locator(`textarea[name*="${re.source}" i]`)
14
- ].flat();
5
+ const CATEGORY_RE = /(category|categor[ie]s|type|section|list|topic)/i;
6
+ const PRICING_RE = /(pricing|price|tier|plan|free|freemium)/i;
7
+ const EXCLUDE_RE = /(password|passwd|pwd|csrf|token|captcha|honeypot|_?wpnonce|_token|remember)/i;
8
+ function normalizePlaceholder(p) {
9
+ if (!p) return "";
10
+ return p.replace(/^(select|choose|pick|enter|your|a|an|the)\s+/gi, "").replace(/…|\.\.\.|\.$/g, "").trim();
11
+ }
12
+ const KIND_SELECTORS = [
13
+ 'input[type="text"]',
14
+ "input:not([type])",
15
+ 'input[type="url"]',
16
+ 'input[type="email"]',
17
+ "textarea",
18
+ "select",
19
+ 'input[type="radio"]'
20
+ ];
21
+ async function collectCandidates(page) {
22
+ const raw = await page.evaluate((selList) => {
23
+ const out2 = [];
24
+ for (const sel of selList) {
25
+ document.querySelectorAll(sel).forEach((el, idx) => {
26
+ const e = el;
27
+ const tag = e.tagName.toLowerCase();
28
+ const type = e.type || (tag === "textarea" ? "textarea" : tag === "select" ? "select" : "text");
29
+ out2.push({
30
+ tag,
31
+ type,
32
+ kindSelector: sel,
33
+ index: idx,
34
+ name: e.getAttribute("name") || "",
35
+ placeholder: e.getAttribute("placeholder") || "",
36
+ id: e.id || "",
37
+ label: (() => {
38
+ if (e.id) {
39
+ const l = document.querySelector(`label[for="${CSS.escape(e.id)}"]`);
40
+ if (l) return (l.textContent || "").trim();
41
+ }
42
+ const parent = e.closest("label, .field, .form-group, .form-field, .control, .form-control");
43
+ if (parent) return (parent.textContent || "").replace(/\s+/g, " ").trim().slice(0, 120);
44
+ return "";
45
+ })(),
46
+ value: e.getAttribute("value") || "",
47
+ options: tag === "select" ? Array.from(e.options).map((o) => o.text.trim()) : []
48
+ });
49
+ });
50
+ }
51
+ return out2;
52
+ }, [...KIND_SELECTORS]);
53
+ const out = [];
54
+ const seen = /* @__PURE__ */ new Set();
55
+ for (const el of raw) {
56
+ const hintRaw = `${el.label} ${el.placeholder} ${el.name}`;
57
+ if (EXCLUDE_RE.test(hintRaw)) continue;
58
+ const hint = hintRaw.toLowerCase();
59
+ const key = `${el.kindSelector}:${el.index}`;
60
+ if (seen.has(key)) continue;
61
+ seen.add(key);
62
+ if (el.tag === "input" && ["hidden", "checkbox", "submit", "button", "file", "password", "radio"].includes(el.type)) {
63
+ if (el.type !== "radio") continue;
64
+ const normPh2 = normalizePlaceholder(el.placeholder || el.label);
65
+ const tierMatch = /(free|basic|hobby|starter|standard|pro|premium|business|enterprise)/i.exec(`${normPh2} ${hint} ${el.value}`);
66
+ if (!PRICING_RE.test(hint) && !PRICING_RE.test(normPh2) && !tierMatch) continue;
67
+ const tier = tierMatch?.[1] || "free";
68
+ out.push({ kindSelector: el.kindSelector, index: el.index, kind: "radio", hint: normPh2 || hint, pickValue: tier.toLowerCase() });
69
+ continue;
70
+ }
71
+ if (el.tag === "select") {
72
+ if (!CATEGORY_RE.test(hint) && !CATEGORY_RE.test(normalizePlaceholder(el.placeholder))) continue;
73
+ const opts = el.options || [];
74
+ const good = opts.find((o) => /(software|saas|web|app|tool|product|ai|startup|business|other|service)/i.test(o) && !/^(select|choose|pick|--|$)/i.test(o));
75
+ const first = opts.find((o) => !/^(select|choose|pick|--|$)/i.test(o.trim()));
76
+ out.push({ kindSelector: el.kindSelector, index: el.index, kind: "select", hint, pickValue: good || first || "" });
77
+ continue;
78
+ }
79
+ const normPh = normalizePlaceholder(el.placeholder);
80
+ const text = `${el.label} ${normPh} ${el.name}`;
81
+ const kind = el.tag === "textarea" ? "textarea" : "input";
82
+ if (kind === "textarea") {
83
+ out.push({ kindSelector: el.kindSelector, index: el.index, kind, hint: text, pickValue: "" });
84
+ } else if (URL_RE.test(text) || el.type === "url") {
85
+ out.push({ kindSelector: el.kindSelector, index: el.index, kind, hint: text, pickValue: "" });
86
+ } else if (EMAIL_RE.test(text) || el.type === "email") {
87
+ out.push({ kindSelector: el.kindSelector, index: el.index, kind, hint: text, pickValue: "" });
88
+ } else if (NAME_RE.test(text)) {
89
+ out.push({ kindSelector: el.kindSelector, index: el.index, kind, hint: text, pickValue: "" });
90
+ } else if (DESC_RE.test(text)) {
91
+ out.push({ kindSelector: el.kindSelector, index: el.index, kind, hint: text, pickValue: "" });
92
+ } else if (kind === "input") {
93
+ out.push({ kindSelector: el.kindSelector, index: el.index, kind, hint: text, pickValue: "" });
94
+ }
95
+ }
96
+ return out;
97
+ }
98
+ function locatorFor(page, c) {
99
+ return page.locator(c.kindSelector).nth(c.index);
15
100
  }
16
101
  async function detectFields(page) {
17
102
  const out = {};
18
- out.name = page.locator('input[name*="name" i], input[placeholder*="name" i], input[placeholder*="title" i], input[name*="title" i]').first().or(page.locator('input[type="text"]').first());
19
- out.url = page.locator('input[name*="url" i], input[type="url"], input[placeholder*="url" i], input[placeholder*="website" i]').first();
20
- out.email = page.locator('input[type="email"], input[name*="email" i], input[placeholder*="email" i]').first();
21
- out.description = page.locator("textarea").first();
22
- out.submit = page.locator('button[type="submit"], input[type="submit"], button:has-text("Submit"), button:has-text("Add"), button:has-text("Launch"), button:has-text("Save")').first();
103
+ const cands = await collectCandidates(page);
104
+ const take = (kind, re) => cands.find((c) => c.kind === kind && (!re || re.test(c.hint)));
105
+ const urlC = take("input", URL_RE);
106
+ const nameC = take("input", NAME_RE) || cands.find((c) => c.kind === "input" && !URL_RE.test(c.hint) && !EMAIL_RE.test(c.hint) && !DESC_RE.test(c.hint));
107
+ const emailC = take("input", EMAIL_RE);
108
+ const descC = take("textarea") || take("input", DESC_RE);
109
+ const submit = page.locator('button[type="submit"], input[type="submit"], button:has-text("Submit"), button:has-text("Add"), button:has-text("Launch"), button:has-text("Save"), button:has-text("Create"), button:has-text("Submit Tool"), button:has-text("List it")').first();
110
+ if (urlC) out.url = locatorFor(page, urlC);
111
+ if (nameC) out.name = locatorFor(page, nameC);
112
+ if (emailC) out.email = locatorFor(page, emailC);
113
+ if (descC) out.description = locatorFor(page, descC);
114
+ out.submit = submit;
115
+ const selectC = cands.filter((c) => c.kind === "select");
116
+ if (selectC.length) out.selects = selectC.map((c) => ({ locator: locatorFor(page, c), value: c.pickValue }));
117
+ const radioC = cands.filter((c) => c.kind === "radio");
118
+ if (radioC.length) out.radios = radioC.map((c) => ({ locator: locatorFor(page, c), value: c.pickValue }));
23
119
  return out;
24
120
  }
25
121
  async function isUsable(loc) {
@@ -48,11 +144,60 @@ async function fillVisible(page, fields, data) {
48
144
  await fields.description.fill(data.description);
49
145
  filled.push("description");
50
146
  }
147
+ if (data.category && fields.selects) {
148
+ for (const s of fields.selects) {
149
+ if (!await isUsable(s.locator)) continue;
150
+ const want = data.category.toLowerCase();
151
+ const opts = await s.locator.locator("option").allTextContents().catch(() => []);
152
+ const exact = opts.find((o) => o.toLowerCase().trim() === want);
153
+ const fuzzy = opts.find((o) => o.toLowerCase().includes(want.slice(0, 5)) || want.includes(o.toLowerCase().slice(0, 5)));
154
+ const pick = exact || fuzzy || s.value;
155
+ if (pick) {
156
+ await s.locator.selectOption({ label: pick });
157
+ filled.push("select");
158
+ }
159
+ break;
160
+ }
161
+ }
162
+ if (data.pricing && fields.radios) {
163
+ for (const r of fields.radios) {
164
+ if (!await isUsable(r.locator)) continue;
165
+ const want = data.pricing.toLowerCase();
166
+ const labelText = await page.evaluate(
167
+ (sel) => {
168
+ const el = document.querySelector(sel);
169
+ if (!el) return "";
170
+ const label = el.closest("label");
171
+ if (label) return label.textContent || "";
172
+ const parent = el.parentElement;
173
+ if (parent) return parent.textContent || "";
174
+ return el.getAttribute("value") || "";
175
+ },
176
+ // reconstruct a css selector from the candidate's locator string
177
+ radioSelectorFromLocator(r.locator)
178
+ );
179
+ const score = (t) => t.toLowerCase().includes(want) ? 2 : /(free|basic|hobby|starter|standard)/i.test(t) ? 1 : 0;
180
+ if (score(labelText) >= 1) {
181
+ await r.locator.check({ force: true }).catch(() => r.locator.click({ force: true }).catch(() => {
182
+ }));
183
+ filled.push("radio");
184
+ break;
185
+ }
186
+ }
187
+ }
51
188
  return filled;
52
189
  }
190
+ function radioSelectorFromLocator(loc) {
191
+ const s = String(loc);
192
+ const m = s.match(/locator\((.+)\)/);
193
+ return m ? m[1] : "";
194
+ }
53
195
  export {
196
+ collectCandidates,
54
197
  detectFields,
55
198
  fillVisible,
56
- isUsable
199
+ isUsable,
200
+ locatorFor,
201
+ normalizePlaceholder
57
202
  };
58
203
  //# sourceMappingURL=fields.js.map
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/engine/fields.ts"],
4
- "sourcesContent": ["/**\n * LinkFlow v0.2 \u2014 smart form field detection.\n * Finds name/url/email/description/submit elements by label, placeholder, and name attrs.\n */\nimport type { Page, Locator } from 'playwright';\n\nexport interface DetectedFields {\n name?: Locator;\n url?: Locator;\n email?: Locator;\n description?: Locator;\n submit?: Locator;\n}\n\nconst NAME_RE = /(name|title|product|app.?name|tool.?name|startup)/i;\nconst URL_RE = /(url|website|link|homepage|site|domain)/i;\nconst EMAIL_RE = /(email|e-?mail)/i;\nconst DESC_RE = /(desc|description|about|summary|detail|intro|what)/i;\nconst SUBMIT_RE = /(submit|send|add|post|create|list|suggest|save|launch)/i;\n\n/** Build a locator list from label+placeholder+name attrs. */\nfunction byText(page: Page, re: RegExp): Locator[] {\n return [\n page.locator('input[type=\"text\"], input:not([type]), textarea, input[type=\"url\"], input[type=\"email\"]').filter({ hasText: re }),\n page.locator('input[type=\"text\"], input:not([type]), textarea, input[type=\"url\"], input[type=\"email\"]').filter({ has: page.locator('..') }),\n page.locator(`input[placeholder*=\"${re.source}\" i]`),\n page.locator(`input[name*=\"${re.source}\" i]`),\n page.locator(`textarea[placeholder*=\"${re.source}\" i]`),\n page.locator(`textarea[name*=\"${re.source}\" i]`),\n ].flat();\n}\n\n/** Detect the form fields on the current page. */\nexport async function detectFields(page: Page): Promise<DetectedFields> {\n const out: DetectedFields = {};\n\n // Name\n out.name = page.locator('input[name*=\"name\" i], input[placeholder*=\"name\" i], input[placeholder*=\"title\" i], input[name*=\"title\" i]').first().or(page.locator('input[type=\"text\"]').first());\n // URL\n out.url = page.locator('input[name*=\"url\" i], input[type=\"url\"], input[placeholder*=\"url\" i], input[placeholder*=\"website\" i]').first();\n // Email\n out.email = page.locator('input[type=\"email\"], input[name*=\"email\" i], input[placeholder*=\"email\" i]').first();\n // Description\n out.description = page.locator('textarea').first();\n // Submit\n out.submit = page.locator('button[type=\"submit\"], input[type=\"submit\"], button:has-text(\"Submit\"), button:has-text(\"Add\"), button:has-text(\"Launch\"), button:has-text(\"Save\")').first();\n\n return out;\n}\n\n/** Verify a locator is visible before filling. */\nexport async function isUsable(loc: Locator | undefined): Promise<boolean> {\n if (!loc) return false;\n try {\n return await loc.isVisible();\n } catch {\n return false;\n }\n}\n\n/** Fill only visible fields, return which were filled. */\nexport async function fillVisible(page: Page, fields: DetectedFields, data: {\n name?: string; url?: string; email?: string; description?: string;\n}): Promise<string[]> {\n const filled: string[] = [];\n if (data.name && await isUsable(fields.name)) { await fields.name!.fill(data.name); filled.push('name'); }\n if (data.url && await isUsable(fields.url)) { await fields.url!.fill(data.url); filled.push('url'); }\n if (data.email && await isUsable(fields.email)) { await fields.email!.fill(data.email); filled.push('email'); }\n if (data.description && await isUsable(fields.description)) { await fields.description!.fill(data.description); filled.push('description'); }\n return filled;\n}\n"],
5
- "mappings": "AAcA,MAAM,UAAU;AAChB,MAAM,SAAS;AACf,MAAM,WAAW;AACjB,MAAM,UAAU;AAChB,MAAM,YAAY;AAGlB,SAAS,OAAO,MAAY,IAAuB;AACjD,SAAO;AAAA,IACL,KAAK,QAAQ,yFAAyF,EAAE,OAAO,EAAE,SAAS,GAAG,CAAC;AAAA,IAC9H,KAAK,QAAQ,yFAAyF,EAAE,OAAO,EAAE,KAAK,KAAK,QAAQ,IAAI,EAAE,CAAC;AAAA,IAC1I,KAAK,QAAQ,uBAAuB,GAAG,MAAM,MAAM;AAAA,IACnD,KAAK,QAAQ,gBAAgB,GAAG,MAAM,MAAM;AAAA,IAC5C,KAAK,QAAQ,0BAA0B,GAAG,MAAM,MAAM;AAAA,IACtD,KAAK,QAAQ,mBAAmB,GAAG,MAAM,MAAM;AAAA,EACjD,EAAE,KAAK;AACT;AAGA,eAAsB,aAAa,MAAqC;AACtE,QAAM,MAAsB,CAAC;AAG7B,MAAI,OAAO,KAAK,QAAQ,4GAA4G,EAAE,MAAM,EAAE,GAAG,KAAK,QAAQ,oBAAoB,EAAE,MAAM,CAAC;AAE3L,MAAI,MAAM,KAAK,QAAQ,uGAAuG,EAAE,MAAM;AAEtI,MAAI,QAAQ,KAAK,QAAQ,4EAA4E,EAAE,MAAM;AAE7G,MAAI,cAAc,KAAK,QAAQ,UAAU,EAAE,MAAM;AAEjD,MAAI,SAAS,KAAK,QAAQ,oJAAoJ,EAAE,MAAM;AAEtL,SAAO;AACT;AAGA,eAAsB,SAAS,KAA4C;AACzE,MAAI,CAAC,IAAK,QAAO;AACjB,MAAI;AACF,WAAO,MAAM,IAAI,UAAU;AAAA,EAC7B,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAGA,eAAsB,YAAY,MAAY,QAAwB,MAEhD;AACpB,QAAM,SAAmB,CAAC;AAC1B,MAAI,KAAK,QAAQ,MAAM,SAAS,OAAO,IAAI,GAAG;AAAE,UAAM,OAAO,KAAM,KAAK,KAAK,IAAI;AAAG,WAAO,KAAK,MAAM;AAAA,EAAG;AACzG,MAAI,KAAK,OAAO,MAAM,SAAS,OAAO,GAAG,GAAG;AAAE,UAAM,OAAO,IAAK,KAAK,KAAK,GAAG;AAAG,WAAO,KAAK,KAAK;AAAA,EAAG;AACpG,MAAI,KAAK,SAAS,MAAM,SAAS,OAAO,KAAK,GAAG;AAAE,UAAM,OAAO,MAAO,KAAK,KAAK,KAAK;AAAG,WAAO,KAAK,OAAO;AAAA,EAAG;AAC9G,MAAI,KAAK,eAAe,MAAM,SAAS,OAAO,WAAW,GAAG;AAAE,UAAM,OAAO,YAAa,KAAK,KAAK,WAAW;AAAG,WAAO,KAAK,aAAa;AAAA,EAAG;AAC5I,SAAO;AACT;",
6
- "names": []
4
+ "sourcesContent": ["/**\n * BacklinkFlow v0.2 \u2014 smart form field detection.\n * Finds name/url/email/description/submit elements by label, placeholder, name attrs,\n * and (v0.3) handles select dropdowns (category) and radio buttons (pricing tier).\n */\n\nexport interface FieldCandidate {\n /** CSS kind selector, e.g. 'input[type=\"text\"]' \u2014 used with .nth(index). */\n kindSelector: string;\n index: number;\n kind: 'input' | 'textarea' | 'select' | 'radio';\n hint: string;\n /** For select: the option text to pick. For radio: the tier label matched. */\n pickValue: string;\n}\n\nconst NAME_RE = /(name|title|product|app.?name|tool.?name|startup)/i;\nconst URL_RE = /(url|website|link|homepage|site|domain)/i;\nconst EMAIL_RE = /(email|e-?mail)/i;\nconst DESC_RE = /(desc|description|about|summary|detail|intro|what)/i;\nconst CATEGORY_RE = /(category|categor[ie]s|type|section|list|topic)/i;\nconst PRICING_RE = /(pricing|price|tier|plan|free|freemium)/i;\n\n/** Exclude login/anti-bot fields that look like our targets but aren't. */\nconst EXCLUDE_RE = /(password|passwd|pwd|csrf|token|captcha|honeypot|_?wpnonce|_token|remember)/i;\n\n/** Normalize a placeholder: \"Choose a category\u2026\" \u2192 \"category\". */\nexport function normalizePlaceholder(p: string | null | undefined): string {\n if (!p) return '';\n return p\n .replace(/^(select|choose|pick|enter|your|a|an|the)\\s+/gi, '')\n .replace(/\u2026|\\.\\.\\.|\\.$/g, '')\n .trim();\n}\n\nconst KIND_SELECTORS = [\n 'input[type=\"text\"]',\n 'input:not([type])',\n 'input[type=\"url\"]',\n 'input[type=\"email\"]',\n 'textarea',\n 'select',\n 'input[type=\"radio\"]',\n] as const;\n\n/** Read form fields + their labels/placeholders/options from the page. */\nexport async function collectCandidates(page: any): Promise<FieldCandidate[]> {\n const raw = await page.evaluate((selList) => {\n const out: any[] = [];\n for (const sel of selList) {\n document.querySelectorAll(sel).forEach((el, idx) => {\n const e = el as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;\n const tag = e.tagName.toLowerCase();\n const type = (e as HTMLInputElement).type || (tag === 'textarea' ? 'textarea' : tag === 'select' ? 'select' : 'text');\n out.push({\n tag,\n type,\n kindSelector: sel,\n index: idx,\n name: e.getAttribute('name') || '',\n placeholder: e.getAttribute('placeholder') || '',\n id: e.id || '',\n label: (() => {\n if (e.id) {\n const l = document.querySelector(`label[for=\"${CSS.escape(e.id)}\"]`);\n if (l) return (l.textContent || '').trim();\n }\n const parent = e.closest('label, .field, .form-group, .form-field, .control, .form-control');\n if (parent) return (parent.textContent || '').replace(/\\s+/g, ' ').trim().slice(0, 120);\n return '';\n })(),\n value: e.getAttribute('value') || '',\n options: tag === 'select' ? Array.from((e as HTMLSelectElement).options).map((o) => o.text.trim()) : [],\n });\n });\n }\n return out;\n }, [...KIND_SELECTORS]);\n\n const out: FieldCandidate[] = [];\n const seen = new Set<string>();\n for (const el of raw) {\n const hintRaw = `${el.label} ${el.placeholder} ${el.name}`;\n if (EXCLUDE_RE.test(hintRaw)) continue;\n const hint = hintRaw.toLowerCase();\n const key = `${el.kindSelector}:${el.index}`;\n if (seen.has(key)) continue;\n seen.add(key);\n\n if (el.tag === 'input' && ['hidden', 'checkbox', 'submit', 'button', 'file', 'password', 'radio'].includes(el.type)) {\n if (el.type !== 'radio') continue;\n // radio: keep pricing-tier groups (label, placeholder, name or option text)\n const normPh = normalizePlaceholder(el.placeholder || el.label);\n const tierMatch = /(free|basic|hobby|starter|standard|pro|premium|business|enterprise)/i.exec(`${normPh} ${hint} ${el.value}`);\n if (!PRICING_RE.test(hint) && !PRICING_RE.test(normPh) && !tierMatch) continue;\n const tier = tierMatch?.[1] || 'free';\n out.push({ kindSelector: el.kindSelector, index: el.index, kind: 'radio', hint: normPh || hint, pickValue: tier.toLowerCase() });\n continue;\n }\n\n if (el.tag === 'select') {\n if (!CATEGORY_RE.test(hint) && !CATEGORY_RE.test(normalizePlaceholder(el.placeholder))) continue;\n const opts: string[] = el.options || [];\n const good = opts.find((o) => /(software|saas|web|app|tool|product|ai|startup|business|other|service)/i.test(o) && !/^(select|choose|pick|--|$)/i.test(o));\n const first = opts.find((o) => !/^(select|choose|pick|--|$)/i.test(o.trim()));\n out.push({ kindSelector: el.kindSelector, index: el.index, kind: 'select', hint, pickValue: good || first || '' });\n continue;\n }\n\n const normPh = normalizePlaceholder(el.placeholder);\n const text = `${el.label} ${normPh} ${el.name}`;\n const kind: 'input' | 'textarea' = el.tag === 'textarea' ? 'textarea' : 'input';\n if (kind === 'textarea') {\n out.push({ kindSelector: el.kindSelector, index: el.index, kind, hint: text, pickValue: '' });\n } else if (URL_RE.test(text) || el.type === 'url') {\n out.push({ kindSelector: el.kindSelector, index: el.index, kind, hint: text, pickValue: '' });\n } else if (EMAIL_RE.test(text) || el.type === 'email') {\n out.push({ kindSelector: el.kindSelector, index: el.index, kind, hint: text, pickValue: '' });\n } else if (NAME_RE.test(text)) {\n out.push({ kindSelector: el.kindSelector, index: el.index, kind, hint: text, pickValue: '' });\n } else if (DESC_RE.test(text)) {\n out.push({ kindSelector: el.kindSelector, index: el.index, kind, hint: text, pickValue: '' });\n } else if (kind === 'input') {\n // unlabeled text input \u2014 candidate for name (last resort)\n out.push({ kindSelector: el.kindSelector, index: el.index, kind, hint: text, pickValue: '' });\n }\n }\n return out;\n}\n\n/** Build a Playwright locator for a candidate. */\nexport function locatorFor(page: any, c: FieldCandidate): any {\n return page.locator(c.kindSelector).nth(c.index);\n}\n\nexport interface DetectedFields {\n name?: any;\n url?: any;\n email?: any;\n description?: any;\n submit?: any;\n /** select dropdowns detected as category fields (value = option text to pick) */\n selects?: { locator: any; value: string }[];\n /** radio buttons detected as pricing fields (value = tier label to pick) */\n radios?: { locator: any; value: string }[];\n}\n\n/** Detect the form fields on the current page. */\nexport async function detectFields(page: any): Promise<DetectedFields> {\n const out: DetectedFields = {};\n const cands = await collectCandidates(page);\n\n const take = (kind: FieldCandidate['kind'], re?: RegExp): FieldCandidate | undefined =>\n cands.find((c) => c.kind === kind && (!re || re.test(c.hint)));\n\n const urlC = take('input', URL_RE);\n const nameC = take('input', NAME_RE) || cands.find((c) => c.kind === 'input' && !URL_RE.test(c.hint) && !EMAIL_RE.test(c.hint) && !DESC_RE.test(c.hint));\n const emailC = take('input', EMAIL_RE);\n const descC = take('textarea') || take('input', DESC_RE);\n\n const submit = page.locator('button[type=\"submit\"], input[type=\"submit\"], button:has-text(\"Submit\"), button:has-text(\"Add\"), button:has-text(\"Launch\"), button:has-text(\"Save\"), button:has-text(\"Create\"), button:has-text(\"Submit Tool\"), button:has-text(\"List it\")').first();\n\n if (urlC) out.url = locatorFor(page, urlC);\n if (nameC) out.name = locatorFor(page, nameC);\n if (emailC) out.email = locatorFor(page, emailC);\n if (descC) out.description = locatorFor(page, descC);\n out.submit = submit;\n\n const selectC = cands.filter((c) => c.kind === 'select');\n if (selectC.length) out.selects = selectC.map((c) => ({ locator: locatorFor(page, c), value: c.pickValue }));\n const radioC = cands.filter((c) => c.kind === 'radio');\n if (radioC.length) out.radios = radioC.map((c) => ({ locator: locatorFor(page, c), value: c.pickValue }));\n\n return out;\n}\n\n/** Verify a locator is visible before filling. */\nexport async function isUsable(loc: any | undefined): Promise<boolean> {\n if (!loc) return false;\n try {\n return await loc.isVisible();\n } catch {\n return false;\n }\n}\n\n/** Fill only visible fields, return which were filled. */\nexport async function fillVisible(page: any, fields: DetectedFields, data: {\n name?: string; url?: string; email?: string; description?: string; category?: string; pricing?: string;\n}): Promise<string[]> {\n const filled: string[] = [];\n if (data.name && await isUsable(fields.name)) { await fields.name.fill(data.name); filled.push('name'); }\n if (data.url && await isUsable(fields.url)) { await fields.url.fill(data.url); filled.push('url'); }\n if (data.email && await isUsable(fields.email)) { await fields.email.fill(data.email); filled.push('email'); }\n if (data.description && await isUsable(fields.description)) { await fields.description.fill(data.description); filled.push('description'); }\n\n // Select dropdowns: choose the option matching our category hint.\n if (data.category && fields.selects) {\n for (const s of fields.selects) {\n if (!(await isUsable(s.locator))) continue;\n const want = data.category.toLowerCase();\n const opts = await s.locator.locator('option').allTextContents().catch(() => [] as string[]);\n const exact = opts.find((o) => o.toLowerCase().trim() === want);\n const fuzzy = opts.find((o) => o.toLowerCase().includes(want.slice(0, 5)) || want.includes(o.toLowerCase().slice(0, 5)));\n const pick = exact || fuzzy || s.value;\n if (pick) {\n await s.locator.selectOption({ label: pick });\n filled.push('select');\n }\n break; // one category select per form is the sane assumption\n }\n }\n\n // Radio buttons: click the tier matching our pricing hint.\n if (data.pricing && fields.radios) {\n for (const r of fields.radios) {\n if (!(await isUsable(r.locator))) continue;\n const want = data.pricing.toLowerCase();\n const labelText = await page.evaluate(\n (sel) => {\n const el = document.querySelector(sel);\n if (!el) return '';\n const label = el.closest('label');\n if (label) return label.textContent || '';\n const parent = el.parentElement;\n if (parent) return parent.textContent || '';\n return el.getAttribute('value') || '';\n },\n // reconstruct a css selector from the candidate's locator string\n radioSelectorFromLocator(r.locator)\n );\n const score = (t: string): number => (t.toLowerCase().includes(want) ? 2 : /(free|basic|hobby|starter|standard)/i.test(t) ? 1 : 0);\n if (score(labelText) >= 1) {\n await r.locator.check({ force: true }).catch(() => r.locator.click({ force: true }).catch(() => {}));\n filled.push('radio');\n break;\n }\n }\n }\n return filled;\n}\n\n/** Playwright locator toString gives something like locator(...) \u2014 extract the selector for page.evaluate. */\nfunction radioSelectorFromLocator(loc: any): string {\n const s = String(loc);\n const m = s.match(/locator\\((.+)\\)/);\n return m ? m[1] : '';\n}\n"],
5
+ "mappings": "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",
6
+ "names": ["out", "normPh"]
7
7
  }
@@ -17,6 +17,37 @@ async function preflight(url) {
17
17
  return { ok: false };
18
18
  }
19
19
  }
20
+ const SUCCESS_RE = /(thank you|thanks for|submitted|submission received|we'?ve received|we'll review|we will review|got it|in review|under review|pending review|successfully (added|submitted)|your (site|tool|product|listing) (has been )?(added|submitted)|added successfully)/i;
21
+ const SOFT_ERROR_RE = /(error|failed|invalid|missing|required|try again|something went wrong|recaptcha|captcha)/i;
22
+ function verdictAfterSubmit(bodyText, url, wasOnForm, fieldsFilled) {
23
+ const body = bodyText || "";
24
+ const u = url.toLowerCase();
25
+ if (SUCCESS_RE.test(body)) return { ok: true, note: "success message detected" };
26
+ if (!wasOnForm && fieldsFilled > 0) return { ok: true, note: "redirected off the form (listing/thank-you page)" };
27
+ if (SOFT_ERROR_RE.test(body)) return { ok: false, note: "soft error text on page" };
28
+ return { ok: false, note: "no success signal detected" };
29
+ }
30
+ function backoffSleep(attempt) {
31
+ const ms = Math.min(3e3 * Math.pow(2, attempt), 6e4);
32
+ return new Promise((r) => setTimeout(r, ms));
33
+ }
34
+ async function withRetry(fn, opts) {
35
+ const retries = opts?.retries ?? 2;
36
+ let lastErr;
37
+ for (let attempt = 0; attempt <= retries; attempt++) {
38
+ try {
39
+ return await fn();
40
+ } catch (err) {
41
+ lastErr = err;
42
+ if (attempt < retries && (opts?.shouldRetry ? opts.shouldRetry(err) : true)) {
43
+ await backoffSleep(attempt);
44
+ } else {
45
+ break;
46
+ }
47
+ }
48
+ }
49
+ throw lastErr;
50
+ }
20
51
  async function submitOne(dir, payload, opts) {
21
52
  const cfg = loadConfig();
22
53
  const { siteUrl, dryRun = false, proofDir = ".linkflow/proofs" } = opts;
@@ -26,7 +57,12 @@ async function submitOne(dir, payload, opts) {
26
57
  if (dir.status === "paid") {
27
58
  return { directory: dir.name, status: "skipped", note: "status=paid" };
28
59
  }
29
- const pf = await preflight(dir.submitUrl);
60
+ let pf;
61
+ try {
62
+ pf = await withRetry(() => preflight(dir.submitUrl), { retries: 2, shouldRetry: (e) => !(e instanceof Error && /404/.test(e.message)) });
63
+ } catch {
64
+ pf = { ok: false };
65
+ }
30
66
  if (!pf.ok) {
31
67
  const note = pf.status ? `preflight HTTP ${pf.status}` : "preflight unreachable";
32
68
  recordSubmission({ site: siteUrl, directory: dir.name, status: "failed", submittedAt: (/* @__PURE__ */ new Date()).toISOString(), notes: note });
@@ -35,7 +71,15 @@ async function submitOne(dir, payload, opts) {
35
71
  if (dryRun) {
36
72
  return { directory: dir.name, status: "pending", note: "dry-run (preflight OK)" };
37
73
  }
38
- const { browser, page } = await launchBrowser();
74
+ let session;
75
+ try {
76
+ session = await withRetry(() => launchBrowser(), { retries: 1 });
77
+ } catch (err) {
78
+ const note = `browser launch failed: ${err.message.slice(0, 100)}`;
79
+ recordSubmission({ site: siteUrl, directory: dir.name, status: "failed", submittedAt: (/* @__PURE__ */ new Date()).toISOString(), notes: note });
80
+ return { directory: dir.name, status: "failed", note };
81
+ }
82
+ const { browser, page } = session;
39
83
  try {
40
84
  const adapter = findAdapter(dir.name) || genericAdapter;
41
85
  if (adapter.needsCredentials && !cfg.ai?.apiKey) {
@@ -85,6 +129,9 @@ async function submitOne(dir, payload, opts) {
85
129
  }
86
130
  }
87
131
  export {
88
- submitOne
132
+ backoffSleep,
133
+ submitOne,
134
+ verdictAfterSubmit,
135
+ withRetry
89
136
  };
90
137
  //# sourceMappingURL=submit.js.map