| name | backlink |
| description | OpenCLI-first backlink discovery, profile analysis, opportunity qualification, safe browser-assisted form filling, evidence-based verification, and bulk data harvesting from logged-in dashboards. Use for backlinks, external links, competitor link research, blog-comment opportunities, directory submissions, Similarweb/Semrush/Ahrefs discovery, Search Console verification, anchor analysis, toxic-link review, disavow review, outreach templates, scraping SaaS report tables that have no API, driving the owner's logged-in Chrome from a script, or Chinese requests such as 反链、外链、找外链、发外链、评论外链、外链分析、抓后台数据、导出报表、数据面板、数据勘测. |
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.
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 ). 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.
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.**
/ resolves. A bare 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
│ ├── similarweb-query.mjs performance | channels | similar-sites
│ ├── similarweb-batch.mjs bulk traffic screen — one login, N domains, resumable
│ ├── semrush-batch.mjs same, on the other card's quota (organic traffic)
│ ├── semrush-overview.mjs AS / organic traffic / ref-domains / keywords
│ ├── semrush-keyword.mjs one keyword: volume, KD, CPC, per-country split
│ ├── semrush-report.mjs the OTHER four no-export reports; reuses one session
│ │ table reports paginate — pass --all-pages or it warns
│ ├── page-read.mjs render a public page → text, prices, paywall shape
│ ├── apply-traffic-screen.mjs write verdicts back into submission-targets.json
│ ├── inspect-page.mjs dump one target's form / login / CAPTCHA state
│ ├── safe-fill.mjs fill a reviewed payload, never submit
│ ├── 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
│ ├── 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
│ ├── merge-submission-targets.mjs fold a probe run into the two data files
│ ├── 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 | … ; --ledger excludes submitted
│ ├── 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.
│
└── 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
├── 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
]]>
Resolve every path in this file relative to this SKILL.md.
Match the ask to a starting point. When two rows fit, take the lower one — it is more specific.
`data/free-channels.json` filtered to `account: "none"` and `status: "live"`,
then for that class's mechanics.
Directory submission does NOT satisfy this ask; burning a campaign discovering
that is the common failure.
, then `data/paid-platforms.json`
sorted by how many independent sites were observed using each.
— merge whatever you harvest back
into the registry.
`node scripts/targets-select.mjs --stats`, then one cohort at a time per
.
. The single-target loop is correct
per target and wrong per campaign.
`scripts/third-party-list-ingest.mjs` to normalise and diff it, then the
"Reading a third-party list" section of
.
. It publishes no link, so it never
enters the placement ledger.
BEFORE rejecting anything.
— still OpenCLI, still a script.
Query the data rather than reading JSON by eye.
c.account==="none"&&c.status==="live").map(c=>`${c.id}\t${c.kind}`).join("\n"))'
node scripts/paid-platform-registry.mjs list --min-sites 2
]]>
`$backlink → scripts and policy → OpenCLI → the owner's authorized Chrome → website`
Every script here shells out to the opencli binary, which drives the owner's
own logged-in Chrome through the OpenCLI extension. No Playwright, no headless
instance, no remote runtime. That identity is the entire reason this Skill
exists, and it is why the laws below matter.
Read before any browser work. The
detailed laws, the measurements behind them, the two other drivers and what they
cost, and the ordered checklist for diagnosing "something stole my tab" now live in
the opencli Skill — that file points at the exact reference for each, and keeps the
backlink-specific residue (scripts/opencli-core.mjs, subagent session fan-out).
Load /opencli when you need the detail: npx skills add yan-labs/yan-skills --skill opencli -g -y.
OpenCLI is the default for **everything**, including a quick ad-hoc look at one
page. It reaches the owner's Chrome through an extension plus a local daemon,
and because it is a CLI, any agent runtime that can run a shell command gets the
identical capability — Claude Code, Codex, anything else. Work done through a
runtime-specific tool cannot be replayed from a script or from another agent
later, which defeats the reason this Skill has scripts.
Use an existing OpenCLI adapter first. When no adapter exists, use a named
browser session with DOM/network inspection.
`opencli browser <session>` is a one-page abstraction. **A session name owns
exactly one tab.** Different names never steal from, switch, or pollute each
other. So N pages need N session names.
This inverts the intuition most people arrive with, which is why it is stated
first. Measured 2026-08-21 under three concurrent agents: distinct session names
produced **zero** cross-agent thefts across 4 rounds × 3 pages; three agents
sharing the name `work` produced 3, 12, and 2 thefts, one of them missing on
every check it made. Re-confirmed the same day against this Skill as written:
three agents told only to follow it scored **36/36 clean with zero leaked
tabs**.
Do not use `tab new`, `tab select`, or `open --tab` to hold several pages under
one session. All three fail, and every one fails **silently** — the command
reports success and the next read returns the wrong page.
Measured 2026-08-21 on opencli 1.8.6: a session tracks only its newest tab, so
earlier ids drop out of `tab list`; `tab select` returns success with no effect
on reads; `open --tab <id>` opens a **new** tab and leaves the named one
untouched; and `get` does not accept `--tab` at all, so a run using `get url` to
confirm its position cannot be right about it. One three-agent run took the
owner's Chrome from 11 tabs to 30 orphans.
`--tab` works on `open`, `state`, `extract`, `find`, and `click`. When a read
must name its target, use `state --tab <id>`.
Read this next sentence before you over-correct. Under
a session owns exactly one page, so there is
nothing to disambiguate and plain get url is safe and is the simplest
confirmation read. The objection above is only about sessions holding several
pages. Three testers each flagged this as the passage most likely to be
misread — one of them nearly threaded a --tab id through the whole job to
obey a rule that did not apply.
The canonical check after every navigation, and the one Law 4 exists to make
possible:
Never write a literal session name as a default. In JS use
`defaultSession(base)` from `scripts/opencli-core.mjs`; in shell use
`SESSION="backlink-$$"`.
"Another task stole my tab" is never the CLI round-robining — it is always two
tasks that picked the same name. The commonest source is documentation:
`opencli browser --help` opens with `opencli browser work open https://x.com`,
so every agent copying the example lands on `work`. This Skill caused the same
failure itself when `tools-share-open.mjs` defaulted to `backlink-panel`.
Subagents inherit the parent's environment, so several agents spawned inside one
conversation resolve to the same default. When fanning browser work across
parallel agents, give each an explicit `--session` or a distinct
`OPENCLI_SESSION_SUFFIX`.
Make names **describe the work**: `backlink-probe-<suffix>` beats `bl-1`. The
session name is the primary identifier. With the custom extension build
(PR #2316), the Chrome tab group now shows active session names
(`OpenCLI: session-a, session-b`), making groups distinguishable.
On the stock Web Store extension the group title is still the fixed
`"OpenCLI Browser"`.
A name needs two distinguishing parts, and it is easy to ship only one. The
suffix makes your task unique against other agents. It does nothing to
separate your own pages from each other, and by
a three-page job needs three names. So
backlink-probe-$$ used for all three pages obeys this law's letter and breaks
Law 1. Vary both: backlink-probe-p1-$$, -p2-$$, -p3-$$.
opencli browser --help opens with opencli browser work open https://x.com.
That is the literal collision name this law exists to prevent, printed by the
tool itself, and an agent that consults --help for syntax after reading this
law will see the CLI modelling the anti-pattern. Trust the law. The same help
text also shows a trailing --window background, which does work but is now
redundant — see .
Release the lease with opencli browser <session> close when done. A session
left open leaves a tab that looks exactly like live work somebody else is doing.
Verify the close rather than trusting the message. close prints
"Browser session tab lease released" whether or not the tab went with it, and
the native check costs one command — an empty tab list means the tab is
actually gone:
Browser session tab lease released
opencli browser "$S" tab list # -> [] (anything else means it survived)
]]>
Counting tabs in Chrome from the outside cannot answer this while other tasks
are running, because their tabs are in the same count.
Open every session you need up front and capture every handle, then start the
work loop. Do not interleave creation with use.
Every driver tested shares one race window: the stretch between creating a page
and holding a stable handle to it. Two independent runs lost pages in exactly
that gap, because a bare `open` with no established handle resolves against
whatever "current" happens to mean at that instant.
Background is the default. Do not override it. `--window foreground` is for the
one case where the person has to finish something by hand; `--window isolated`
keeps automation in a window of its own. The flag, when you do pass one, sits
**between** the session name and the subcommand:
`opencli browser <session> --window isolated <command>`.
Requires the OpenCLI extension at 1.0.32 or newer (opencli doctor prints
it). On older builds the default is foreground and every single command needs
--window background spelled out.
Background mode runs the owner's real logged-in Chrome without raising the
window, and opens its tab in the window they are already using. It is not
headless — navigator.webdriver is false, the UA carries no Headless,
plugins.length is 5. So "background will trip the site's bot defences" is not
a real concern, and there is never a reason to reach for foreground to look more
human.
Foreground does steal the person's attention, and an earlier version of this
law said otherwise. That claim rested on one measurement axis — the frontmost
application, which foreground genuinely leaves alone. Re-measured 2026-08-23
on the axis that was missing: under --window foreground the owner's active
tab jumps away mid-task; under background it never moves and the tab count
returns to baseline after close. A law that checks one axis and concludes
"no harm" is worse than no law, because it licenses the harm.
If a person reports the screen "jumping around" while everything ran in
background, the cause is several tasks writing to one shared page — that is
being violated.
Before the session name it fails with unknown command: <yoursession>, which
reads like a broken install rather than a syntax error — check flag position
before reinstalling anything.
After the subcommand it works. Re-measured 2026-08-21: opencli browser s open URL --window background succeeds identically to the between form, and the
CLI's own --help prints that trailing form as its second example. An earlier
version of this law claimed both positions fail; a tester falsified it in one
command. Prefer the between form for consistency with the rest of this Skill,
and do not treat the trailing form as an error when you meet it in someone
else's script.
Request foreground only when the user explicitly wants to watch. If a site
cannot be operated without stealing focus, stop and report that constraint.
It attaches over CDP, and CDP cannot reach the owner's Chrome: Chrome 136+
silently ignores `--remote-debugging-port` on the default user-data-dir
(verified on 151 — the flag is passed, no port is opened), and macOS TCC blocks
copying the profile out. Relaunching Chrome is wasted effort; do not suggest it.
Its `--profile` means a separate directory you log into once, unrelated to the
owner's sessions. Use it only for tasks needing **no** logged-in identity, and
address tabs by `--label`, never by the `t1`/`t2` positional index, which is a
shared namespace across agents.
It reaches the owner's Chrome but has no isolation boundary of any kind: one
flat tab group shared by every concurrent agent, and omitting `tabId` resolves
to "first tab in the shared group". It also has a reproducible bug where closing
one of your own tabs tears down your session's tab-group tracking and orphans
the rest. It is Claude-only, so anything built on it cannot be replayed from
another runtime.
node scripts/health.mjs
Run before browser work. Use `--check-update` only when the user asks about
versions; an available update is informational, and upgrading OpenCLI needs a
separate request.
Read before any fill, submission,
account, or logged-in operation.
Confirm ownership rather than assuming it:
opencli browser "$SESSION" tab list # should show only your own tab
**当用户说「数据面板」「数据勘测」「查一下数据」「用 Similarweb 看看」「Semrush 拉一下」,
指的都是同一件事:走那个共享账号的代理面板,用 Similarweb 或 Semrush 查。**
这两个产品是这里唯一的第三方数据源,没有别的候选,不需要反问用户指的是哪个平台。
分工固定,按问题类型选,一次只开一个:
| 问题 | 用哪个 | 拿得到什么 |
|---|
| 这个站多大、流量从哪来、还有哪些同类站 | Similarweb | 总访问量(含直接/推荐)、渠道构成、相似站、地理分布 |
| 这个词多少量、多难、谁在排、它的外链长什么样 | Semrush | 分国家搜索量与 KD、关键词全库导出、自然排名、主要页面、引荐域名与反链 |
两边的「流量」口径不同,对不上很正常。 Semrush 域名概览给的是自然搜索流量估算,
Similarweb 给的是总访问量。同一个站两边差三倍以上是常态,写结论时必须标明口径,
否则会得出「竞品比想象中弱」这种错误判断。绝不放进同一列。
Both live behind one shared-account panel, and launching through the launcher is
mandatory — a deep link into the tool origin before the launcher runs lands on
about:blank.
node scripts/tools-share-open.mjs --tool semrush
Both entry points are optional overrides, not prerequisites.
lib-tools-share.mjs ships a DEFAULT_DASHBOARD and loads the Skill's
gitignored .env, so the scripts run with neither variable set — an earlier
revision of this file called them required, which sent a tester hunting for
configuration that was already there. Set them only to point at a different
dashboard:
"
export TOOLS_SHARE_APP_ORIGIN="https://"
]]>
The launched application sits on a different host from the dashboard entry
point, so the second cannot be derived from the first.
All of these share one launcher, lib-tools-share.mjs. Do not write a second
one. A previous copy of the launch sequence inside similarweb-query.mjs
omitted three of the four known traps and failed with a generic "unavailable"
whose real cause differed every time.
Check the subscription expiry before planning around it — it is short-dated,
the scripts print it, and they warn inside 7 days.
Budget the whole recon against the quota printed at launch. Reusing a
session skips the launcher, which is the point, and the side effect is that the
quota text never re-renders — so no reused-session call prints a fresh reading.
A rule like "stop at 80%" cannot be enforced mid-run; decide the size of the
run up front.
Raise your shell timeout before a batch, not after it fails. A panel launch
costs 20–40s and each report ~15s, so five domains or a dozen keywords in one
call runs for minutes and a two-minute default kills it mid-flight. The scripts
write incrementally so nothing is lost, but the run still has to be restarted.
Everything else about cards, quota, and the traps is in
.
Ad-hoc looking is still scripted work. Use OpenCLI so the look is replayable.
:
# a unique-but-meaningless name still cannot answer "whose tab is this".
S="explore-pricing-$$"
opencli browser "$S" open "https://example.com/pricing"
opencli browser "$S" get url # confirm you landed
opencli browser "$S" extract
opencli browser "$S" close
opencli browser "$S" tab list # expect []
]]>
For a public page where you want prices and paywall shape parsed out:
node scripts/page-read.mjs --url https://example.com/pricing --out .backlink/pricing.json
`page-read.mjs` reads only; it never fills or submits. `curl | grep` returns an
empty shell on the SPAs these sites are built with.
If you find yourself running the same exploration twice, that is the signal to
write a script for it. That is how every script in `scripts/` started.
Recursive discovery: seed competitors → get their backlink rows from an
authorized Semrush/Ahrefs export or logged-in browser → classify source URLs
(editorial, resource, directory, profile, comment, login wall, paid, CAPTCHA,
rejected) → harvest commenter domains on real article pages → feed those back
into the queue → repeat to a bounded depth. Rank by topical fit, page quality,
moderation, public visibility, and referral potential. Low-quality comment
volume is auxiliary, never the goal.
bulk: feed an authorized referring-domains export straight in.
Edges are typed refdomain — do NOT route these through import-commenters,
which would record a commenter relationship nobody observed.
node scripts/discovery-queue.mjs import-refdomains --file .backlink/discovery.json
--source competitor.com --input .backlink/competitor-refdomains.csv
node scripts/harvest-commenters.mjs --session "discovery-$$" --url https://example.com/article --out .backlink/commenters.json
node scripts/discovery-queue.mjs import-commenters --file .backlink/discovery.json --input .backlink/commenters.json
node scripts/discovery-queue.mjs next --file .backlink/discovery.json --limit 10
]]>
Domain overview is one page out of five that matter; the other four have no
export button and are where competitor recon actually happens. Pass the same
--session across the whole recon — the panel launch costs 20–40s and a
login, the report itself ~15s, and semrush-report.mjs skips the launch when
the session is already parked on the tool origin (sessionReused: true says
which happened).
This is the one place a session legitimately handles several reports — it is
still one page at a time, navigated in sequence, which is what
allows. Holding them open simultaneously
would need N session names.
These metrics help discover and prioritize candidates. They never prove a
backlink is public, indexed, followable, or causally producing traffic. The
parsing traps that make a report silently return zeros are documented in
— read it before writing any
new reader, especially the rule that a readiness predicate must key on a data
row, never on a tab name, column header, or filter chip.
Then check the parser against itself. A ready page and a correct parse are
different claims, and the second one fails silently. One live run under-reported
all five domains it touched — the worst lost 91 rows of 93, and the one that
looked healthiest still lost 49 — with no error anywhere and a wrong written
conclusion on top.
The check is two comparisons, and conflating them produces false alarms:
Count the record-shaped lines in `rawText`, compare with `parsed.rows.length`.
A gap here means **your regex has a blind spot** — the rows arrived and you
dropped them. This is the silent, dangerous one. Fix the parser.
Semrush prints its own total (`自然搜索排名: N`). If that exceeds what `rawText`
even contains, the rows **never reached you**: these tables are virtual-scroll
and only mount a fraction at a time, so a full pull needs the export, which
costs quota. This is a known ceiling, not a bug — say so rather than "fixed
the parser".
A live re-run shows both at once: three domains matched their headline exactly
(14/14, 22/22, 5/5) while one read 91 against a claimed 430. The first three
prove the parser; the fourth is level 2 and needs no fix.
The qualifying test is real traffic (`>= 100` monthly visits), never DR, and it
runs BEFORE the form does.
Measuring a domain costs one query; filling its form costs two orders of
magnitude more. One run filled every form across a 73-domain family and only
then sampled five for traffic — every filled form was discarded.
Inspect every target independently. Never infer a form from a sibling site. A
page is fillable only when there is one unambiguous qualifying form and no
detected CAPTCHA/login wall.
Modes are `comment`, `directory`, or `auto`.
Create a reviewed JSON payload with truthful values. For comment mode,
`description` is the comment body.
It revalidates the URL, form identity, field semantics, login state, and CAPTCHA
state, installs a submit guard, and never submits. The human reviews the
rendered page and performs final submission. Only after the user explicitly
authorizes one exact reviewed submission may the agent run
`release-submit-guard.mjs` — and releasing the guard still does not click
Submit.
Lane B leaves forms on screen for the owner to finish. **One session name per
staged site** — a session owns one tab, so reusing one session overwrites the
previous staged form while the report still says N staged. `adapter-phpld.mjs`
carries the reference implementation.
Analyze referring-domain quality and topical relevance; suspicious networks,
sitewide links, and toxic patterns; anchor and target-page diversity;
follow/nofollow/UGC/sponsored distribution **when observed**; competitor gaps
and prioritized next opportunities.
— scoring, toxicity, disavow.
— report shapes.
— frameworks; sending needs the
user's explicit approval per message.
These templates assume you already have the data. They do not fetch it. **A
report built from templates alone, with no observed rows behind it, is
fabrication.** Do not disavow links, contact site owners, or change production
sites unless the user separately asks. Treat third-party authority and traffic
estimates as directional and time-sensitive.
before writing any scraping loop. It
documents failures that produce **plausible, silently wrong output**: virtual
scroll tables that are not `<table>` and drop rows without erroring, long URLs
that make whole rows vanish, execution-channel timeouts that look like failure
while the page loop is still running, and Chrome's intensive throttling
stretching a four-second loop into twenty-five minutes.
`scripts/harvest.browser.js` is the in-page collector. Its output arrives via a
Blob download rather than a return value, because the execution channel
truncates at roughly 1 KB.
candidate → qualified → drafted → filled → submitted → public → indexed → rel_verified
Per-project progress: what have I submitted vs what's left?
node scripts/ledger.mjs stats --file .backlink/ledger.json
node scripts/ledger.mjs remaining --file .backlink/ledger.json --min-traffic 100
node scripts/ledger.mjs remaining --file .backlink/ledger.json --cohort open --free-only
Select next batch, excluding already-submitted domains
node scripts/targets-select.mjs --cohort open --min-traffic 100 --ledger .backlink/ledger.json
]]>
submitted, public, indexed, and rel_verified each require an evidence
note. Never promote a record from a filled form, a pending notice, or a
historical assumption. indexed must name the engine — indexed@google,
indexed@brave. An unqualified "indexed" is a claim about the whole web built
from one crawler's opinion.
No coordinate-based "human-like" clicking.
No CAPTCHA, Turnstile, login, paywall, quota, or account-scope bypass.
Never treat "not yet measured" as "qualified". The traffic gate only works if
unmeasured rows are excluded from a batch rather than waved through.
`--min-traffic` drops them by design; `--unmeasured` lists them as the next
screening queue, never as a batch.
A gate metric is validated against known-bad domains, never against famous ones.
Any signal a link network can manufacture for itself — DR, popularity rank,
index size — will pass a farm. Tranco's top-1M failed exactly this way: 48 of 73
confirmed farm domains sat inside it, from rank 134k to 998k.
No generic praise, fake identity, invented metrics, or a comment body that
ignores the article it sits under. Never invent a product fact to fill a field —
founder, pricing, address, launch date, user count, ownership, legal, contact.
Leave optional unknowns blank and stop a row whose required field is unknown.
A host site on a different topic is fine. Relevance and DR **rank** candidates,
they never **gate** them, and `nofollow` is an observation to record rather than
a reason to skip. Read before
rejecting any target on quality grounds.
No link farms, spam generators, adult/malware surfaces, hidden reciprocal links,
temporary eligibility pages, or cloaking. Two identical give-aways in one place
— one site script across dozens of domains, and a promotional sentence repeated
word for word — mean one operator. Submitting to N of its domains buys one
link's value while accruing N times the footprint.
Do not record a submission as a backlink. This includes handing a URL to a
search engine: that is an index-submission channel, it publishes no link, and it
belongs in `data/index-submission.json` rather than the placement ledger.
Do not record `follow`, `nofollow`, `ugc`, `sponsored`, or `indexed` without
observing it for the exact URL. A click, a completed registration, a saved
draft, a form that cleared itself, or a generic thank-you URL is **not** evidence
of a submission — those record what you did, and the ledger records what the
site did.
Do not automatically resubmit an unconfirmed target. Never retry an ambiguous
final action — one where the submit happened and the result was not observed.
Check the account backend, then the mailbox, then the public page. That state is
`outcome-unknown`, and it is not a failure.
Anchor text is the brand, the product name, or the naked canonical URL. Never
request dofollow treatment, never repeat a commercial exact-match anchor across
a campaign, and treat a paid or incentivised placement that publishes as a plain
follow link as **noncompliant** rather than as a win.
Records carry aliases and evidence IDs. Passwords, OTPs, recovery codes,
cookies, OAuth parameters, magic links, raw session IDs, raw email addresses,
and phone numbers belong in none of them. Keep raw cookies, tokens,
authorization headers, and credentials out of logs.
A third-party traffic figure without `source · metric · month · geography ·
device · date verified` is not a number. Store all six or store none.
Prefer a documented HTTP endpoint over an MCP server when both serve the same
data from the same quota — the MCP adds a connection and a process without
adding capability, and a failure there is harder to tell apart from the service
being down. Keep MCP where it is the only authorized channel; never retire a
working path before the replacement has run successfully once.
Records carry `lastVerifiedAt` because this genre dies faster than it changes. A
channel that worked three months ago may be gone, gated, or `noindex` today.
Re-verify before a campaign; the validator warns on anything `live` older than
180 days. **Fixing a wrong row is worth more than adding a new channel.**
`free-channels.json` records **a published link on a live page** and requires
`relObserved`/`anchorRendered`. `submission-targets.json` records **a submission
route that exists** and the validator rejects those fields there. A target
graduates from the second into the first the moment an actual anchor is
observed; until then it makes no promise about `rel`, anchor text, or
indexability, and the report must not imply one.
Two volume sources disagreeing by more than ~3× is not evidence that "volume
is unreliable" — it is a resolvable arithmetic question, and you MUST resolve
it before either number enters a decision. Pick a domain ranking #1 for the
disputed keyword, get its real traffic and Organic-Search share from
Similarweb, and get its ranked keywords with volumes from Semrush. Divide
observed organic clicks by the candidate volume total to get an implied CTR:
under 40% is plausible, over 100% falsifies that volume. This validates
**volume only, never intent** — a keyword can clear the CTR check and still be
worthless if the SERP shows the searchers do not want what you sell. A
falsification of one function of a tool (its volume model) says nothing about
another function of the same tool (e.g. SERP-composition reads have no
estimation model and are unaffected). Full worked example:
.
A closed-loop volume check validates **magnitude at one point in time**. It says
nothing about whether that demand persists, and the two questions need separate
evidence. Traffic tools report a trailing window, so a keyword measured during a
viral spike passes the CTR check with real, correctly-computed, and
already-obsolete numbers. Before a keyword is allowed to anchor a product line,
a page build, or a link campaign, pull a multi-year **Google Trends** curve for
it alongside a known-evergreen term in the same category. A term that is flat at
zero until one month, spikes, and decays is a fad — entering it means fighting
for a shrinking pool, and the incumbent's traffic collapse will be invisible in
rank data. The diagnostic that separates the two causes: if the incumbent still
holds #1 while its traffic falls, **demand fell, not rankings** — that is decay,
not a penalty, and no amount of link building recovers it. Real case: a keyword
verified closed-loop at 72k–143k/mo went to 2.2/100 on Trends within three
months while the #1 site kept its position and lost 87% of its traffic.
Read the reference **before** acting, not after the run goes wrong.
references/browser-runtime.md
references/safety-policy.md
references/batch-campaign.md — the single-target loop deduplicates too late, stalls behind the first CAPTCHA, cannot tell an interrupted row from an unstarted one, and produces a number that counts forms instead of links
references/directory-run-playbook.md — a real run's difficulty is before and after the form, not in it: 4 of 5 successful submissions hid their free tier behind a paid upsell, one target was already listed without any submission, and a driver's ledger row went stale the moment someone else finished the job
references/field-notes.md — personal-contact requirements outrank CAPTCHAs, and landing-page CAPTCHA scans give false negatives
the "Reading a third-party list" section of references/instant-publish.md — a Dofollow column is an assertion about a platform, never an observation of a link
references/paid-platforms.md
references/index-submission.md
references/acquisition-doctrine.md
references/backlinkdirs.md
references/prompts.md
references/credits.md
data sources and authorization boundary
candidates by type, and the reason for qualification or rejection
current ledger state, never an inferred later state
evidence links or local evidence files
the next safe action, and whether human review or submission is required
Source: [Skills.sh](https://skills.sh/yan-labs/yan-skills)
For a project-level install omit `-g`; update with `npx skills update backlink -p -y`.
opencli comes first. Every browser action in this Skill runs through it, and
it carries the rules this Skill only summarises.
It also requires the OpenCLI binary and browser extension from
yan-labs/OpenCLI releases —
not the Chrome Web Store build. The store build defaults to foreground: it raises
a window and steals the tab the person is reading. That failure is silent — commands
still succeed, only the behaviour is wrong — so opencli doctor flags an extension
older than 1.0.32 explicitly. When it does, act on it rather than working around it.