---
name: Backlink
slug: backlink
category: SEO
description: Backlink discovers backlink and external-link opportunities, checks link quality, and verifies evidence from search tools and logged-in dashboards. Use it for competitor link research, directory submissions, outreach lists, and scraping no-API report tables.
github: "https://github.com/yan-labs/yan-skills/tree/main/backlink"
language: JavaScript
stars: 177
forks: 78
install: "npx degit https://github.com/yan-labs/yan-skills/tree/main/backlink ~/.claude/skills/backlink"
installs_to: ~/.claude/skills/backlink
source_path: backlink/SKILL.md
collection_size: 9
category_size: 197
collection_url: "https://dirskills.com/collections/yan-labs/yan-skills"
added: 2026-09-07T05:20:53.420Z
last_synced: 2026-09-07T05:20:53.420Z
canonical_url: "https://dirskills.com/skills/backlink"
---

# Backlink

Backlink discovers backlink and external-link opportunities, checks link quality, and verifies evidence from search tools and logged-in dashboards. Use it for competitor link research, directory submissions, outreach lists, and scraping no-API report tables.

**Install:**

```bash
npx degit https://github.com/yan-labs/yan-skills/tree/main/backlink ~/.claude/skills/backlink
```

## README

<skill name="backlink" version="3.3" body-format="xml">

<why-xml>
The frontmatter above stays YAML because the Skill loader reads it for
discovery. Everything below is XML because this Skill is mostly laws and
routing, and a law that is easy to skim past is a law that gets broken. Tagged
blocks make "which rule did I just violate" answerable by name.
</why-xml>

<mission>
One business Skill for the complete backlink lifecycle. Do not split it back
apart, and do not create another browser-extension Skill — OpenCLI and its
Chrome extension are the connector underneath this Skill, never a separate
business workflow.

Two former Skills were merged in on 2026-08-16 and deleted: `backlink-analyzer`
(analysis templates, toxicity rubric, outreach — now in three references under
its original Apache-2.0 licence) and `browser-harvest` (pulling tables out of
logged-in dashboards — now <ref file="references/harvest.md"/>). The harvest
knowledge is general-purpose: ad platforms, e-commerce backends, any no-API
SaaS report. When a harvesting task has nothing to do with links, load this
Skill anyway and read that one reference.
</mission>

<map>
<summary>
Two things live here and they answer different questions. **The data files are
the asset; the references are how to use them and how not to fool yourself.**
</summary>
<tree><![CDATA[
backlink/
├── SKILL.md              ← you are here: laws + routing + workflow entry points
├── CONTRIBUTING.md       ← how to submit a PR, the data model, the evidence rule
│
├── data/                 ← THE DATABASE. Machine-readable, PR-able, CI-checked.
│   ├── free-channels.json       places that publish a link at no cost
│   ├── submission-targets.json  routes that ACCEPT a submission — first-pass library
│   ├── paid-platforms.json      platforms observed carrying purchased placements
│   ├── network-fingerprints.json known automation/PBN families; negative evidence, never placements
│   ├── index-submission.json    engines that take a URL and publish NO link
│   └── schema/                  JSON Schema for the files above
│
├── scripts/              ← run these; do not re-derive their knowledge by hand
│   ├── validate-data.mjs           PR gate. CI runs exactly this. Must exit 0.
│   ├── validate-skill-xml.mjs      the OTHER gate: SKILL.md body well-formed + every
│   │                               <ref>/<law-ref> resolves. A bare <tag> in prose
│   │                               silently unbalances the doc from that line on.
│   ├── self-test.mjs               end-to-end smoke over the core scripts
│   ├── health.mjs                  run before ANY browser task
│   ├── opencli-core.mjs            ★ defaultSession(), batchBrowser(), openAndEval(), run(), closeSession()
│   ├── lib-tools-share.mjs         ★ the ONE panel launcher
│   ├── tools-share-open.mjs        launch a tool by name; --goto for a deep link
│   ├── tools-share-node.mjs        `list` a tool's nodes (read-only) or `probe` them one by
│   │                               one — each node is a DIFFERENT shared account, so a node
│   │                               capped on its daily report quota is fixed by switching node,
│   │                               not by retrying
│   ├── similarweb-query.mjs        performance | channels | similar-sites | audience-geo | site-keywords
│   ├── similarweb-keywords.mjs     seed keyword → thousands of related keywords.
│   │                               The keyword-research entry point the pipeline was missing.
│   │                               Column-major DOM table; parsing lives in lib-similarweb.mjs
│   ├── similarweb-batch.mjs        bulk traffic screen — one login, N domains, resumable;
│   │                               emits evidence rows (value+stopReason+screenshot), no verdicts
│   ├── semrush-batch.mjs           same, on the other card's quota (organic traffic)
│   ├── lib-batch-evidence.mjs      the batch scripts' shared evidence contract — row shape,
│   │                               completeness (resume) semantics, evidence-dir paths
│   ├── semrush-overview.mjs        AS / organic traffic / ref-domains / keywords
│   ├── semrush-keyword.mjs         global keyword detail plus one-session multi-country bulk plans
│   ├── semrush-report.mjs          the OTHER five no-export reports (incl. referring-domains,
│   │                               --rollup aggregates the rows THIS run fetched); reuses one
│   │                               session; table reports paginate — pass --all-pages or it warns
│   ├── semrush-traffic.mjs         Traffic & Market (.Trends) TOTAL visits — the only
│   │                               Semrush number comparable with Similarweb. Runs
│   │                               **foreground by default**, alone in this Skill:
│   │                               the summary never hydrates if it *loads* hidden.
│   │                               But "empty" has two unrelated causes with opposite
│   │                               remedies — not hydrated vs never had a table — and
│   │                               only the first is worth re-reading.
│   │                               See <law-ref id="hidden-tabs-do-not-hydrate"/>
│   ├── traffic-crosscheck.mjs      offline: eats one semrush-traffic.mjs JSON and one
│   │                               similarweb-query.mjs JSON and reports the DIFFERENCES
│   │                               between them — never an agree/diverge/conflict verdict,
│   │                               never a non-zero exit for a big diff. How to read a gap:
│   │                               references/traffic-screen.md. Never touches a page itself
│   ├── tools-share-evidence.mjs    rendered, redacted evidence bundle for one report.
│   │                               For a NEW capture prefer scripts/ground-truth.mjs (the
│   │                               two-witness collector); come here for the REPORTS route
│   │                               registry (which URL is which report) and for the wider
│   │                               artifact set (html / ax / network / app-json)
│   ├── page-read.mjs               render a public page → text, prices, paywall signal
│   │                               spans (matched text + context, never verdict booleans)
│   ├── apply-traffic-screen.mjs    write measured numbers + evidence paths back (never verdicts)
│   ├── inspect-page.mjs            full form census (every form, every field, semantics +
│   │                               markers) + scene evidence; fillable/blocker are marked
│   │                               `suggested` — the AI judges from the census + screenshot
│   ├── safe-fill.mjs               fill a reviewed payload, never submit; refusal exits
│   │                               leave a captureScene pair first
│   ├── lib-evidence-scene.mjs     ★ captureScene(): the ONE failure-scene contract —
│   │                               piercing census + screenshot, paired, redacted, never
│   │                               throws. Every browser script's failure branch calls it
│   │                               BEFORE any close/exit (先取证后死、先取证后关).
│   │                               Reuses ground-truth.mjs's CENSUS_EXPR verbatim.
│   ├── lib-deep-dom.mjs           ★ the ONE shadow-DOM-piercing traversal. EVERY counting
│   │                               probe goes through it. Measured 2026-08-29 on one page,
│   │                               one instant: body.innerText 59 chars / deep text
│   │                               1,605,054 / 44 shadow roots. innerText and
│   │                               querySelectorAll both stop at the shadow boundary, so
│   │                               every table / cell / text count taken before this file
│   │                               existed measured a sliver of the page. Emits the LIGHT
│   │                               reading beside the deep one - the gap is the diagnostic.
│   │                               Also holds the segmented-scroll capability (default off)
│   │                               and readChartGeometry() - per-SVG text/mark PIXEL
│   │                               positions, the collection surface chart-only routes need
│   │                               (default OFF: one getBoundingClientRect per node forces
│   │                               layout; ground-truth opens it once AFTER chart readiness).
│   │                               See <law-ref id="readiness-must-bind-to-this-query"/>
│   ├── lib-chart-read.mjs         ★ the chart-only READER. Extracts axis ticks, axis range,
│   │                               x labels, series names from census.deepText, and per-point
│   │                               values from census.chartGeometry when present. It CONVERTS
│   │                               AND EXTRACTS, it does not conclude. Anything it cannot read
│   │                               is `value: null` + an `uncertain` reason code - never a
│   │                               plausible-looking guess, so "unreadable" and "the value is
│   │                               0" stay distinguishable. NOTE: `census.deep.svgText` is a
│   │                               COUNT, not text; without chartGeometry the reader tops out
│   │                               at `capability: 'axis-only'`.
│   ├── lib-report-readiness.mjs    ★ the report-route criteria, and the HARD GATE that runs
│   │                               BEFORE any classification: landed path == requested
│   │                               route, header domain == requested target, content region
│   │                               non-empty. Any one failing ⇒ `inconclusive`, never
│   │                               `no-table` and never `empty`.
│   ├── lib-submit-outcome.mjs      ★ the ONE "did this submission get accepted" criterion.
│   │                               Paired on purpose: acceptance evidence must sit OUTSIDE
│   │                               every form, and no rejection marker may be present — a
│   │                               form that silently redraws itself with our own URL echoed
│   │                               back into its input satisfies "our URL is on the page"
│   │                               while nothing was accepted.
│   │                               See <law-ref id="readiness-must-bind-to-this-query"/>
│   ├── release-submit-guard.mjs    only after explicit per-submission approval
│   ├── submit-directory.mjs        the single-target driver; one session per staged site
│   ├── adapter-phpld.mjs           ★ reference implementation of one-session-per-site
│   ├── adapter-phpld-submit.mjs    Lane A submit for that family. SEPARATE ON PURPOSE —
│   │                               staging is safe family-wide, pressing submit is not,
│   │                               and the two must never share a flag. Re-checks for a
│   │                               challenge that appeared since staging, and refuses.
│   ├── ledger.mjs                  candidate → … → indexed → rel_verified; stats +
│   │                               remaining + domains (submitted/public/… → a
│   │                               plain domain list, for targets-select --ledger
│   │                               and anyone else who just needs the exclusion set)
│   ├── discovery-queue.mjs         recursive competitor/commenter expansion
│   ├── harvest-commenters.mjs      pull commenter domains off an article
│   ├── third-party-list-ingest.mjs someone else's list → screened leads + diff
│   ├── fingerprint-forms.mjs       ★ cluster targets by FORM SHAPE, not by site. Field
│   │                               names are stable across every install of a family,
│   │                               so one adapter covers twenty sites. This is what makes
│   │                               batch cheaper than walking 150 forms by hand.
│   ├── probe-submission-targets.mjs leads → reachability, route, gate, price; dumps the
│   │                               raw HTML per domain into `<out>.evidence/` — the
│   │                               classification is a suggestion, the HTML is the record
│   ├── lib-probe-classifier.mjs   ★ the probe's classification layer (decide/classifyKind/
│   │                               gatesFrom), separately unit-tested; every output is
│   │                               `suggested: true` and the AI may overrule it against
│   │                               the dumped raw HTML
│   ├── merge-submission-targets.mjs fold a probe run into the two data files. Drops nothing
│   │                               silently: every dead/unverified row is printed in full
│   │                               (and written by --dropped-out) with its reason and
│   │                               evidence, and the usable→gated downgrade is listed as
│   │                               derived-from-the-gate-set, not applied in silence
│   ├── lib-cohort.mjs              ★ the shared cohort/gate vocabulary — targets-select,
│   │                               validate-data, probe and merge all read it. Change a
│   │                               cohort name here, not in four places.
│   ├── targets-select.mjs          pick ONE batch: --cohort open | captcha | … ;
│   │                               reads the project ledger by default (submitted-
│   │                               or-later AND rejected excluded, no flag needed;
│   │                               --include-rejected to reopen a dead one on purpose)
│   ├── paid-platform-registry.mjs  merge a harvest into the paid registry
│   ├── harvest-*.{sh,mjs}          bulk table extraction from logged-in dashboards
│   └── harvest.browser.js          generic virtual-scroll table extractor: rebuilds rows
│                                   by Y-coordinate clustering, adapts to column drift.
│                                   NOTE the dot — the harvest-* glob above does NOT match it.
│                                   NOT the first choice any more: scripts/ground-truth.mjs
│                                   pierces shadow DOM, finds the inner scroll container,
│                                   and pairs every read with a screenshot. Come here only
│                                   when you need a whole table exported as a file.
│
└── references/           ← method, traps, and why the rules are the rules
    ├── browser-runtime.md     ★★ READ FIRST for any browser work. The laws + measurements.
    ├── traffic-screen.md      ★ the qualifying gate, and why it runs before the form
    ├── submission-lanes.md    ★ lanes, cohorts, the three guards, staged queues
    ├── instant-publish.md     ★ free channels: how each class behaves, what kills them
    ├── paid-platforms.md      ★ paid: tiers, why a burst is not a purchase
    ├── batch-campaign.md      ★ 100+ rows: queue, idempotency, resume, reporting
    ├── directory-run-playbook.md ★ what a real run hits: hidden free tiers, already-listed sites, stale ledger rows
    ├── index-submission.md      index-only channels; why `indexed` must name an engine
    ├── authorized-data-sources.md  the panel, the cards, quota, expiry, the traps
    ├── field-notes.md           what actually blocks submissions in practice
    ├── harvest.md               scraping failures that look like success
    ├── pagination-harvest.md    tables with hundreds of pages: which paging mechanism,
    │                            what a full crawl really costs, how to sample without bias,
    │                            and how to notice rows silently going missing
    ├── safety-policy.md         read before any fill / submit / logged-in action
    ├── acquisition-doctrine.md  the standing ruling on what is worth pursuing
    ├── discovery-loop.md · link-quality-rubric.md · analysis-templates.md
    ├── outreach-templates.md · backlinkdirs.md · prompts.md · credits.md
    └── LICENSE-analysis-templates-Apache-2.0
]]></tree>
<path-rule>Resolve every path in this file relative to this SKILL.md.</path-rule>
</map>

<routing>
<summary>Match the ask to a starting point. When two rows fit, take the lower one — it is more specific.</summary>

<plain-language-index>
<why>
The `route` rows below are written for someone who already knows what this
Skill contains. **Real asks do not arrive in that shape.** They arrive as one
vague Chinese sentence, from a user who has never seen this file and does not
know that 64 platform pages or 40-odd scripts exist. This table is the
intent→capability index for that sentence: left column is what the user
actually says, right column is the ONE place to open first. Read the whole
table before deciding "this Skill can't do that" — the answer is usually a
file the user could not have named.
</why>
<how-to-read>
One row = one starting point, not a recipe. Open it, then follow its own
pointers. Rows are grouped; within a group the later row is the more specific.
</how-to-read>

<group name="发外链：去哪发、能不能发"><![CDATA[
| 用户大概会这么说 | 从这里开始 |
|---|---|
| 「帮我发点外链」「给我的站搞点外链」（最模糊的那句） | `node scripts/targets-select.mjs --stats` 看现有入口库有什么，再 references/submission-lanes.md 选一个 cohort。**不要**先去搜索引擎找新站 |
| 「有没有不用注册就能发的」「免费的、立刻能发的」 | `data/free-channels.json` 过 `account:"none"` + `status:"live"`，机制看 references/instant-publish.md。目录提交不满足这句话 |
| 「能花钱买吗」「竞品这些链是买的吧」 | references/paid-platforms.md → `data/paid-platforms.json`（按被多少独立站点观察到排） |
| 「把这个站提交到目录站」「提交外链目录」 | references/submission-lanes.md → `scripts/submit-directory.mjs`；真实一轮会遇到什么见 references/directory-run-playbook.md |
| 「去博客评论区留链接」「评论外链」 | `scripts/harvest-commenters.mjs` 先拿到真在评论的域名，再走 screen → submit |
| 「我有 300 个站要批量提」「跑一轮不能中断」 | references/batch-campaign.md。单站循环跑 300 遍是错的（幂等、断点、报表都缺） |
| 「别人给了我一份『500 个免费外链网站』」 | `node scripts/third-party-list-ingest.mjs --blocklist data/network-fingerprints.json`，再读 references/instant-publish.md 的「Reading a third-party list」 |
| 「让 Google / Brave 收录我的新页面」 | references/index-submission.md。它不产生外链，永远不进 placement ledger |
| 「帮我写封外链合作邮件」 | references/outreach-templates.md |
]]></group>

<group name="判断值不值得发：质量、毒性、流量"><![CDATA[
| 用户大概会这么说 | 从这里开始 |
|---|---|
| 「这些外链质量怎么样」「我的外链档案健不健康」 | references/link-quality-rubric.md，模板在 references/analysis-templates.md |
| 「有没有垃圾链要拒绝」「要不要 disavow」「毒性」 | references/link-quality-rubric.md 的毒性部分 + `data/network-fingerprints.json`（网络家族指纹是负面证据，不是投放位） |
| 「这个站看着不行，别发了吧」 | 先读 references/acquisition-doctrine.md **再**否掉。凭 DR 低 / nofollow / 不同题材单方面否掉是本 Skill 明令禁止的 |
| 「这个站有没有人访问」「有没有流量，值不值得提交」 | references/traffic-screen.md → `scripts/similarweb-batch.mjs` 或 `scripts/semrush-batch.mjs`（一次登录、N 个域名、可续跑；只出证据行，不出判决） |
| 「两个工具给的流量对不上」 | `node scripts/traffic-crosscheck.mjs` 只报差异，怎么读这个差异见 references/traffic-screen.md。它不会给「一致/冲突」结论 |
| 「先看看这个页面上写了什么」「这个站收不收费」 | <workflow-ref id="explore"/>；公开页用 `node scripts/page-read.mjs`（只读，出文本片段+截图，不出布尔判决） |
| 「这 150 个站的表单长什么样」「能不能自动填」 | `scripts/probe-submission-targets.mjs` 探路 → `scripts/fingerprint-forms.mjs` 按**表单形状**聚类（一个 adapter 覆盖二十个站）→ `scripts/inspect-page.mjs` 做单站表单普查 |
]]></group>

<group name="看竞品：他的外链和流量是哪来的"><![CDATA[
| 用户大概会这么说 | 从这里开始 |
|---|---|
| 「竞品的外链是从哪来的」 | <ref file="../platforms/semrush/backlink-analytics/OVERVIEW.md"/>（backlinks / refdomains / anchors / backlink-gap 各页能给什么、坑在哪），再决定跑哪个采集 |
| 「谁在给他导流量」「他的推荐流量来源」 | <ref file="../platforms/similarweb/referrals/OVERVIEW.md"/>（incoming / outgoing） |
| 「帮我找一批新机会」「顺着竞品往下挖」 | references/discovery-loop.md + `scripts/discovery-queue.mjs`（递归展开竞品与评论者），挖到的必须并回登记库 |
| 「他和我的受众重合吗」 | <ref file="../platforms/similarweb/audience/OVERVIEW.md"/>（三域名韦恩图，一条深链就是一次三方对比） |
]]></group>

<group name="面板取数：我不知道这些平台有什么功能"><![CDATA[
| 用户大概会这么说 | 从这里开始 |
|---|---|
| **「Semrush 能查什么」「这工具有什么功能」「我不知道该看哪个报表」** | **<ref file="../platforms/semrush/OVERVIEW.md"/>** — 平台总览：套餐边界、配额纪律、跨页通用坑、六个板块索引。**任何 Semrush 相关的模糊问题都从这一页开始**，不要凭记忆回答「它有没有这个功能」 |
| **「Similarweb 能查什么」** | **<ref file="../platforms/similarweb/OVERVIEW.md"/>** — 同上，五个板块索引 |
| 「这两个工具我们到底买到了哪些功能 / 哪些还没探过」 | references/semrush-feature-map.md · references/similarwe
