{"id":50348087,"url":"https://github.com/redredchen01/backlink-publisher","last_synced_at":"2026-05-29T20:01:33.841Z","repository":{"id":358819060,"uuid":"1236474748","full_name":"redredchen01/backlink-publisher","owner":"redredchen01","description":"Local-first backlink publishing pipeline for Blogger and Medium","archived":false,"fork":false,"pushed_at":"2026-05-26T09:15:29.000Z","size":3956,"stargazers_count":0,"open_issues_count":2,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-26T09:16:35.076Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"Python","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/redredchen01.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":"AGENTS.md","dco":null,"cla":null}},"created_at":"2026-05-12T09:26:41.000Z","updated_at":"2026-05-26T09:16:11.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/redredchen01/backlink-publisher","commit_stats":null,"previous_names":["redredchen01/backlink-publisher"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/redredchen01/backlink-publisher","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/redredchen01%2Fbacklink-publisher","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/redredchen01%2Fbacklink-publisher/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/redredchen01%2Fbacklink-publisher/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/redredchen01%2Fbacklink-publisher/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/redredchen01","download_url":"https://codeload.github.com/redredchen01/backlink-publisher/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/redredchen01%2Fbacklink-publisher/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33668186,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-05-29T02:00:06.066Z","response_time":107,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-05-29T20:01:32.980Z","updated_at":"2026-05-29T20:01:33.829Z","avatar_url":"https://github.com/redredchen01.png","language":"Python","funding_links":[],"categories":[],"sub_categories":[],"readme":"# backlink-publisher\n\nA local-first, terminal-native backlink publishing pipeline for Blogger and Medium.  \nGenerates, validates, and publishes short backlink articles — fully pipe-friendly, cron-safe, and non-interactive.\n\n## Workspace Layout\n\nThis is the canonical project repository. It lives under a parent workspace directory that is **not** itself a git repo. Sibling directories named `bp-\u003ctopic\u003e` (e.g. `bp-events-u4`, `bp-ko-html`) are temporary `git worktree` checkouts of this same repository on parallel feature branches — they share `.git/` with this main checkout. The convention is one `bp-\u003ctopic\u003e` worktree per active feature branch; remove the worktree (`git worktree remove ../bp-\u003ctopic\u003e`) when the branch lands. See `AGENTS.md` for the contributor workflow.\n\n## Quick Start\n\n```bash\n# Install\npip install -e .\n\n# Run the full pipeline (dry-run)\ncat seeds.jsonl \\\n  | plan-backlinks \\\n  | validate-backlinks \\\n  | publish-backlinks --platform medium --mode draft --dry-run\n```\n\n## Prerequisites\n\n| Requirement | Details |\n|---|---|\n| **Python** | \u003e= 3.11 |\n| **Chromium** | Only for Medium browser fallback: `playwright install chromium` |\n\n\u003e **No Node.js required.** Publishing uses the Blogger API v3 and Medium API directly. Chrome/Playwright is only needed as a fallback when no Medium Integration Token is configured.\n\n## First-Run Setup\n\n```bash\n# 1. Install the package and dependencies\npip install -e .\n\n# 2. Copy and edit the config file\ncp config.example.toml ~/.config/backlink-publisher/config.toml\n# Edit: set Blogger blog_id map, OAuth credentials, optional Medium token\n\n# 3. (Optional) Install Chromium for Medium browser fallback\nplaywright install chromium\n#    Then log in to Medium once in the Playwright-managed profile:\n#    open ~/.config/backlink-publisher/chrome-profile-default/\n```\n\n## Pipeline Commands\n\n### 1. plan-backlinks\n\nReads seed JSONL from stdin or `--input`, generates one article payload per row.\n\n```bash\ncat seeds.jsonl | plan-backlinks\ncat seeds.jsonl | plan-backlinks -i /dev/stdin\n```\n\n**Input schema (seed):**\n\n```json\n{\n  \"target_url\": \"https://example.com/article\",\n  \"main_domain\": \"https://example.com\",\n  \"language\": \"en\",\n  \"platform\": \"medium\",\n  \"url_mode\": \"A\",\n  \"publish_mode\": \"draft\",\n  \"topic\": \"optional string\",\n  \"seed_keywords\": [\"optional\", \"strings\"]\n}\n```\n\n| Field | Required | Values |\n|---|---|---|\n| `target_url` | yes | Valid HTTPS URL |\n| `main_domain` | yes | Valid HTTPS URL |\n| `language` | yes | `en`, `zh-CN`, `ru` |\n| `platform` | yes | `medium`, `blogger` |\n| `url_mode` | yes | `A` (main only), `B` (main+category), `C` (main+detail) |\n| `publish_mode` | yes | `draft`, `publish` |\n| `topic` | no | String |\n| `seed_keywords` | no | String array |\n\n**Output schema:**\n\n```json\n{\n  \"id\": \"sha256-truncated-16hex\",\n  \"platform\": \"medium\",\n  \"language\": \"en\",\n  \"publish_mode\": \"draft\",\n  \"target_url\": \"https://example.com/article\",\n  \"main_domain\": \"https://example.com\",\n  \"url_mode\": \"A\",\n  \"title\": \"Exploring example.com: A Comprehensive Guide\",\n  \"slug\": \"exploring-example-com-a-comprehensive-guide\",\n  \"excerpt\": \"...\",\n  \"tags\": [\"backlink\", \"reference\", ...],\n  \"content_markdown\": \"# Title\\n\\n...\",\n  \"links\": [\n    { \"url\": \"...\", \"anchor\": \"...\", \"kind\": \"main_domain\", \"required\": true }\n  ],\n  \"seo\": {\n    \"title\": \"...\",\n    \"description\": \"...\",\n    \"canonical_url\": \"...\"\n  }\n}\n```\n\n- Articles are 100–200 words.\n- 6–8 links per article (main_domain + target + mode-specific + supporting).\n- `main_domain` appears naturally in the body, not at the start or end.\n- Supports Simplified Chinese, English, and Russian.\n\n### 2. validate-backlinks\n\nReads planned JSONL, validates schema + URLs, enriches with a `validation` block.\n\n```bash\ncat planned.jsonl | validate-backlinks\ncat planned.jsonl | validate-backlinks --no-check-urls   # skip HTTP checks\n```\n\n**Validations performed:**\n\n- All required output fields present with correct types\n- 6–8 links per payload\n- `target_url` and all link URLs reachable (HTTP 200/301/302)\n- `main_domain` appears in `content_markdown`\n- Title is non-empty\n- SEO block complete\n- Language roughly matches content (heuristic)\n- `platform=linkedin` rejected (exit code 2)\n\n**Output schema:** adds `validation` block:\n\n```json\n{\n  \"...all input fields...\": {},\n  \"validation\": {\n    \"status\": \"passed\",\n    \"checked_at\": \"2026-05-11T12:00:00+00:00\",\n    \"warnings\": []\n  }\n}\n```\n\n### 3. publish-backlinks\n\nReads validated JSONL and publishes via API-first adapters with browser fallback.\n\n```bash\n# Medium (dry-run)\ncat validated.jsonl | publish-backlinks --platform medium --mode draft --dry-run\n\n# Medium (publish for real — uses API token or browser fallback)\ncat validated.jsonl | publish-backlinks --platform medium --mode publish\n\n# Blogger (draft — uses Blogger API v3)\ncat validated.jsonl | publish-backlinks --platform blogger --mode draft\n\n# Per-row platform (omit --platform)\ncat validated.jsonl | publish-backlinks --mode draft\n```\n\n| Flag | Default | Description |\n|---|---|---|\n| `--platform` | per-row | Override platform for all rows |\n| `--mode` | `draft` | `draft` or `publish` |\n| `--dry-run` | off | Print command plan, don't execute |\n| `--input`, `-i` | stdin | Input file path |\n\n**Medium throttle:** Between consecutive Medium posts, the pipeline sleeps a random\n60–300 seconds to avoid rate-limiting. Override with env vars:\n\n```bash\nMEDIUM_THROTTLE_MIN=30 MEDIUM_THROTTLE_MAX=90 publish-backlinks ...\n```\n\n## SEO Anchor Keywords\n\nGenerated articles place two backlinks pointing at each target site's\n`main_domain`. Without configuration, both anchor texts default to the bare\ndomain (e.g. `your-site.com`) — a near-zero SEO signal. To improve keyword\nrelevance, configure a per-target keyword pool in `~/.config/backlink-publisher/config.toml`:\n\n```toml\n[targets.\"https://your-site.com\"]\nanchor_keywords = [\n  \"your-site\",                  # branded\n  \"comprehensive content hub\",  # head term\n  \"in-depth resource guide\",    # long-tail\n  \"curated reference library\",\n  \"expert tutorials\",\n]\n```\n\n**Selection strategy.** For each article, two distinct keywords are picked\ndeterministically using `keywords[(position + offset) % len(keywords)]`, where\n`offset` is `0/1/2` for `url_mode` `A/B/C`. Same article configuration always\nyields the same anchor distribution; varying `url_mode` across articles rotates\nwhich keyword anchors which slot, producing natural distribution. Recommended\npool size: **5–10 keywords**, mixing branded terms, head terms, and long-tail\nphrases.\n\n**All anchored references** in the article (excerpt, body paragraphs, density\nfallback paragraph, references section) use the configured keywords.\n\n**Fallback.** If `anchor_keywords` is missing or an empty list, the renderer\nfalls back to the bare domain label and emits a single `WARN` per article so\nthe operator notices the missed SEO opportunity. Articles still publish\nnormally.\n\n**New-tab behaviour.** All `\u003ca\u003e` tags in the rendered HTML include\n`target=\"_blank\" rel=\"noopener\"` so backlinks open in a new tab without\nexposing the opener window. (Note: Medium's renderer may strip these\nattributes — behaviour on Medium is best-effort.)\n\n`save_config` rewrites `[targets.\"\u003cdomain\u003e\"]` blocks from the resolved\n`Config` (the `target_anchor_keywords` and `target_three_url` kwargs follow\nthe three-state `None` / `{}` / non-empty contract). Operator-added\n`[targets.X]` subsections — where `X` is not a managed domain — are\npreserved verbatim on save.\n\n## Work-Themed Backlinks (Three-URL Form)\n\nRecommended for new projects (Plan 2026-05-13-004). Each generated article\ncarries **three** backlinks pointing at the same target site:\n\n1. **`main_url`** — the brand-weight anchor (drawn from `branded_pool`).\n2. **`list_url`** — the discovery surface (anchor drawn 70% from `partial_pool`,\n   30% from `exact_pool`).\n3. **`work_url`** — one URL per article, anchor synthesised from the scraped\n   `\u003ctitle\u003e` via the `work_anchor_templates` (default templates: `{title}`,\n   `{title} 详情`, `{title} 推荐`, `{title} 介绍`).\n\nAnchor positions across the three paragraphs are permuted by a per-article\nseed (six possible orderings) so the link layout doesn't form a stable\n\"main first / work last\" fingerprint. All anchors render as\n`\u003ca target=\"_blank\" rel=\"noopener\"\u003e` — **no `nofollow`** so dofollow weight\ntransfers in full. The post-publish verifier (`link_attr_verifier`) flags\nany platform-injected `rel=\"nofollow\"` so silent demotion (Medium and\nsimilar) surfaces in the publish report.\n\n### Configuring via WebUI\n\nOpen `/sites` in the WebUI to fill the three-URL form. The form uses CSRF\ntokens and the page is bound to `127.0.0.1` by default — set\n`BACKLINK_PUBLISHER_ALLOW_NETWORK=1` to bind to a non-loopback address\n(only do this on a trusted network). The save button persists the\nconfiguration via the same `save_config` that `[blogger.oauth]` uses, so\nexisting credentials, the legacy `[sites.*]` block, and any operator-added\ndepth-2 subsections under managed roots (e.g. `[medium.oauth]`,\n`[medium.browser]`, `[targets.X]`) are preserved verbatim.\n\n### Configuring via `config.toml`\n\nEquivalent TOML form:\n\n```toml\n[targets.\"https://your-site.com\"]\nmain_url = \"https://your-site.com/\"\nlist_url = \"https://your-site.com/list\"\nwork_urls = [\"https://your-site.com/work/1\", \"https://your-site.com/work/2\"]\nbranded_pool = [\"Your Site\", \"Your Site Hub\"]\npartial_pool = [\"site hub partial keyword\"]\nexact_pool = [\"site keyword\"]\n# work_anchor_templates = [\"{title}\", \"{title} 详情\", \"{title} 推荐\", \"{title} 介绍\"]\n# list_path_blocklist = [\"/tag/\", \"/category/\", \"/page/\"]\n# insecure_tls = false\n```\n\nWhen `work_urls` is empty, the planner discovers candidates by fetching\n`/sitemap.xml` (recursing one level into `\u003csitemapindex\u003e`), falling back\nto scraping `\u003ca href\u003e` elements off `list_url` with a default nav-path\nblocklist (`/tag/`, `/category/`, `/page/`, `/author/`, `/about`,\n`/contact`, `/search`, `/feed`).\n\n### Dual-path coexistence (no migration pressure)\n\nSites that already have a `[sites.\"\u003cdomain\u003e\"]` block continue to use the\nzh-CN short-form scheduler (next section). Adding a `[targets.\"\u003cdomain\u003e\"]`\nthree-URL block for the same domain just routes that domain through the\nwork-themed planner instead — both paths are kept alive. A single INFO\nlog notes the coexistence so you can decide when (or whether) to migrate.\n\n## zh-CN Short-Form Anchor Profile Scheduler\n\nThe default path above (`[targets.\"\u003cdomain\u003e\"].anchor_keywords`) drives en/ru\narticles and any zh-CN target that hasn't opted into the scheduler. zh-CN\ntargets can opt into a richer **anchor profile scheduler** that:\n\n- generates 150–200-character short articles (1 main link to home + 1–2\n  secondary links to non-home pages) instead of the long-form 6–8-link layout\n- enforces a sliding-window distribution against a Safe SEO target\n  (Branded 55% / Partial 25% / Exact 10% / LSI 10%) across the four anchor\n  type buckets\n- picks each secondary link's URL category from `{hot, animate, category,\n  topic}` so a single article never repeats the same target page twice\n\n### Default mode: LLM-free runtime\n\nThe 51acgs.com block in `config.example.toml` is pre-sized for **no LLM at\nruntime**: 126 hand-picked candidates across 20 `(url_category, anchor_type)`\ncells, the heavy-use `home/branded` cell padded to 15 entries to comfortably\noutlast the 20-entry text-dedup window. A 500-article × 3-seed simulation\nproduces zero LLM fallback calls and lands within 1 pp of every target\nproportion.\n\nTo enable, uncomment the `[sites.\"https://51acgs.com\".url_categories]` and\n`[sites.\"https://51acgs.com\".anchor_pools.*]` blocks in `config.example.toml`\n(or copy them to your `~/.config/backlink-publisher/config.toml`). The\nscheduler engages automatically for any zh-CN seed row whose `main_domain`\nhas these blocks configured; rows without v2 config fall through to the\nlegacy long-form path with zero behavior change.\n\nTo extend the scheduler to another site, mirror the 51acgs.com structure:\n\n1. List the site's `url_categories` (must include `home` plus at least one\n   non-home category).\n2. Fill `[sites.\"\u003csite\u003e\".anchor_pools.\u003ccategory\u003e.\u003ctype\u003e]` for every\n   `(url_category, anchor_type)` cell you want covered — minimum 3\n   candidates per cell, **≥12 in `home/branded`** to keep the scheduler\n   out of the degrade path.\n3. Run `pytest tests/test_config_example_pool.py` after editing — the\n   regression tests run a 500-article simulation against your pool and\n   fail if any cell would trigger a degrade.\n\n### Optional mode: hybrid with LLM fallback\n\nIf you want to thin some cells and let an LLM generate candidates on\ndemand, uncomment the `[llm.anchor_provider]` block:\n\n```toml\n[llm.anchor_provider]\nbase_url = \"https://api.openai.com/v1\"\nmodel = \"gpt-4o-mini\"\ntimeout_s = 30\n```\n\nProvide the API key via the `BACKLINK_LLM_API_KEY` env var (preferred) or\n`api_key = \"sk-...\"` in the same block (which requires `chmod 600\nconfig.toml` — a warning is emitted otherwise). `base_url` must be\n`https://`.\n\nBefore promoting hybrid config to production, validate the provider's\nrejection rate against your content shape:\n\n```bash\npython scripts/llm_rejection_spike.py\n# exit 0 = rejection rate \u003c 20%, exit 1 = above threshold\n```\n\nAdult-content sites should expect significant rejection rates from\nmainstream providers — `scripts/llm_rejection_spike.py` makes that\nmeasurement reproducible so the trade-off is explicit rather than\ndiscovered during a production batch.\n\n### Observability\n\nAfter at least 50 articles, inspect the per-site anchor profile:\n\n```bash\nreport-anchors --from-profile https://51acgs.com\n```\n\nThe report shows the rolling type distribution vs. target, a\n`url_category × anchor_type` cross-tab, and degradation rate flagged\nwith ⚠️ when above 10%. JSON output is available via `--json` for\nscripting.\n\n`[anchor.proportions]`, `[anchor_alarm]`, and `[llm.anchor_provider]` are\noperator-edit-only and preserved verbatim by `save_config` (unmanaged\nroots). See the **Managing SEO keywords** section above for the full\n`save_config` taxonomy and credential-lifecycle notes.\n\n### Anchor Distribution Visibility\n\n`report-anchors --from-profile` also surfaces three per-target-URL\ndistribution metrics over rolling 30d and 90d windows:\n\n- **Shannon entropy** of normalized anchor-text distribution\n- **Exact-match ratio** — fraction with `anchor_type == \"exact\"`\n- **Top-3 concentration** over non-branded anchors only\n\nWhen the 90d window for any target crosses a configured threshold,\n`report-anchors` emits a structured `alarm` block in JSON output, a\n`WARN [anchor_alarm]` line per breaching target on stderr, and exits\nwith code **6** so cron wrappers can alert without ambiguity.\n\n```bash\nreport-anchors --from-profile https://example.com\n# Exit 0  → no breach\n# Exit 6  → at least one target breached in the 90d window\n```\n\nThis is **detection, not prevention**. The publish path does not consult\nthe alarm — the operator is the deciding agent. When a target breaches:\npause publishing to that URL, rotate its anchor strategy, and re-run\nafter another batch of articles. The thresholds are a conservative\nlower-bound approximation of Google's SpamBrain signal — false-positives\ntrigger a 5-minute review; false-negatives risk a Penguin penalty, so\ndefaults favor an earlier warning (sample-size floor 20 per target vs.\nthe 50-entry floor used for domain-level metrics).\n\nOverride thresholds via `[anchor_alarm]` in `config.toml`. See\n`config.example.toml` for the full schema including per-domain and\nper-URL overrides.\n\nNote: the operator-facing aliases `report-anchors` (without\n`--from-profile`) and `cat payloads.jsonl | report-anchors` continue to\nreport type distribution from the JSONL stream but **do not** compute\nthe distribution alarm — that path lacks the `anchor_type` field needed\nfor exact-ratio. A stderr hint surfaces this on every invocation so the\nzero exit code is not mistaken for \"no breach detected\".\n\n## Publisher Adapters\n\nPublishing is API-first with a browser fallback for Medium.\n\n| Platform | Primary | Fallback | Auth |\n|---|---|---|---|\n| **Blogger** | Blogger API v3 (OAuth2) | — | OAuth2 token |\n| **Medium** | Medium API v1 (Integration Token) | Playwright browser automation | Integration token / browser |\n| **Velog** | Internal GraphQL `writePost` | — | Cookie jar (30-day window) |\n\n### Blogger Setup\n\n1. Create a Google Cloud project and enable the **Blogger API v3**.\n2. Create OAuth2 credentials (Desktop app) and download client JSON.\n3. Add credentials to `~/.config/backlink-publisher/config.toml`:\n\n```toml\n[blogger.oauth]\nclient_id     = \"...\"\nclient_secret = \"...\"\n\n[blogger]\n\"https://your-site.com\" = \"your-blog-id\"\n```\n\n4. Run any Blogger publish — a browser window opens once for OAuth authorization.\n   The token is saved automatically for future runs.\n\n### Velog Setup\n\nvelog.io has no official API; we publish via its internal `v2.velog.io/graphql`\nGraphQL endpoint using a cookie jar from social login.\n\n1. Install Playwright (required once):\n\n```bash\npip install playwright \u0026\u0026 playwright install chromium\n```\n\n2. Run the login command (opens a headed Chromium window):\n\n```bash\nvelog-login\n```\n\n3. Complete social login (Google / GitHub / Facebook) in the browser.\n   Credentials are saved to `~/.config/backlink-publisher/velog-cookies.json` (0600).\n\n4. Publish:\n\n```bash\ncat seeds.jsonl | plan-backlinks | validate-backlinks \\\n  | publish-backlinks --platform velog --mode publish\n```\n\n**Notes:**\n- Cookie TTL: access_token 24 h (auto-refreshed); refresh_token **30 days**.\n  Re-run `velog-login` once per 30 days.\n- Phase 1 cap: **5 posts/day** until 2026-06-02, then 30/day.\n- Cross-machine: daily cap is per-machine. Coordinate manually if using multiple machines.\n- See `docs/operations/velog-login.md` for full operator guide.\n\n### Medium Setup\n\n**Option A — Integration Token (preferred):**\n\n1. Generate a token at `medium.com/me/settings/security → Integration tokens`.\n2. Add to config:\n\n```toml\n[medium]\nintegration_token = \"your-token\"\n```\n\n**Option B — Browser fallback (no token needed):**\n\n```bash\nplaywright install chromium\n# Launch the managed profile once and log in to Medium:\n# The profile is at ~/.config/backlink-publisher/chrome-profile-default/\n```\n\nThe pipeline automatically uses the browser if no token is configured.\n\n## Exit Codes\n\n| Code | Meaning |\n|---|---|\n| `0` | Success |\n| `1` | Usage error (bad CLI flags) |\n| `2` | Input validation error (schema, link count, bad URLs) |\n| `3` | Dependency error (missing config, OAuth not set up, Playwright not installed) |\n| `4` | External service error (API error, login expired, CAPTCHA) |\n| `5` | Unexpected internal error |\n| `6` | Anchor distribution alarm — `report-anchors --from-profile` detected at least one target's 90d window exceeds the configured threshold. Output is otherwise valid; treat as a warning that requires operator action. |\n\n## Output Contract\n\n- **stdout** — structured JSONL only (on success)\n- **stderr** — diagnostic messages only (on failure)\n- **exit code** — 0 on success, non-zero on failure\n- No human-readable \"Done\" or \"Success\" messages in any mode\n\n## Example Pipeline\n\n```bash\n# Step 1: generate seeds\ncat \u003e seeds.jsonl \u003c\u003c'EOF'\n{\"target_url\":\"https://example.com/article\",\"main_domain\":\"https://example.com\",\"language\":\"en\",\"platform\":\"medium\",\"url_mode\":\"A\",\"publish_mode\":\"draft\",\"topic\":\"Web Development\"}\n{\"target_url\":\"https://blog.example.org/posts/guide\",\"main_domain\":\"https://blog.example.org\",\"language\":\"zh-CN\",\"platform\":\"blogger\",\"url_mode\":\"C\",\"publish_mode\":\"publish\",\"topic\":\"Python最佳实践\"}\nEOF\n\n# Step 2: full pipeline (dry-run)\ncat seeds.jsonl | plan-backlinks | validate-backlinks | publish-backlinks --mode draft --dry-run\n\n# Step 3: full pipeline (Blogger, publish)\ncat seeds.jsonl | plan-backlinks | validate-backlinks | publish-backlinks --platform blogger --mode publish\n```\n\n## Troubleshooting\n\nIf a publish fails with `channel 'X' credentials expired`, open Settings (`/settings`) and click **重新绑定** on the affected channel card. A headed browser opens for you to log in; the badge transitions `绑定中…` → `已绑定 ✓` when the storage_state file is written and `mark_bound` records the bind. CLI alternative: `bind-channel --channel \u003cvelog|medium|blogger\u003e`. See `AGENTS.md → Binding a channel` for the full lifecycle.\n\n| Problem | Solution |\n|---|---|\n| `Blogger OAuth not configured` | Add `[blogger.oauth]` to `~/.config/backlink-publisher/config.toml` |\n| `No Blogger blog_id configured for domain` | Add the domain mapping under `[blogger]` in config.toml |\n| `channel 'blogger' credentials expired` (exit 3) | Open `/settings` → Blogger → 重新绑定, **or** run `bind-channel --channel blogger` |\n| `channel 'medium' credentials expired` (exit 3) | Open `/settings` → Medium → 重新绑定, **or** run `bind-channel --channel medium` |\n| `medium integration token not configured` | Add `[medium] integration_token = \"...\"` to config, or install Playwright as fallback |\n| `Medium login expired` | Log in to Medium in the managed Chrome profile |\n| `Medium CAPTCHA detected` | Solve CAPTCHA manually at medium.com, then retry |\n| `Playwright is not installed` | Run `playwright install chromium` |\n| `Medium selector changed` | Update selectors in `src/backlink_publisher/adapters/_medium_selectors.py` |\n| Failed publish saved screenshot | Check `~/.cache/backlink-publisher/screenshots/` for error screenshots |\n\n## Project Structure\n\n```\nbacklink-publisher/\n├── config.example.toml\n├── pyproject.toml\n├── README.md\n├── src/backlink_publisher/\n│   ├── __init__.py\n│   ├── adapters/\n│   │   ├── __init__.py          # dispatcher (publish, verify_adapter_setup)\n│   │   ├── base.py              # AdapterResult dataclass\n│   │   ├── blogger_api.py       # Blogger API v3 adapter\n│   │   ├── medium_api.py        # Medium API v1 adapter\n│   │   ├── medium_browser.py    # Playwright browser fallback\n│   │   └── _medium_selectors.py # CSS selector constants\n│   ├── cli/\n│   │   ├── plan_backlinks.py\n│   │   ├── validate_backlinks.py\n│   │   └── publish_backlinks.py\n│   ├── config.py\n│   ├── schema.py\n│   ├── errors.py\n│   ├── jsonl.py\n│   ├── linkcheck.py\n│   ├── language_check.py\n│   └── markdown_utils.py\n├── tests/\n│   ├── test_adapter_base.py\n│   ├── test_adapter_blogger_api.py\n│   ├── test_adapter_medium_api.py\n│   ├── test_adapter_medium_browser.py\n│   ├── test_adapter_dispatcher.py\n│   ├── test_config.py\n│   ├── test_markdown_render.py\n│   ├── test_throttle.py\n│   ├── test_plan_backlinks.py\n│   ├── test_validate_backlinks.py\n│   ├── test_publish_backlinks.py\n│   └── test_edge_cases.py\n└── fixtures/\n    └── seed.jsonl\n```\n\n## For contributors\n\nAdding a new publishing platform (WordPress, Substack, Telegraph, …) is one `register(\"x\", XAdapter)` call away from being reachable through the CLI and schema layers. See [AGENTS.md → Adding a new publisher adapter](AGENTS.md#adding-a-new-publisher-adapter) for the five-step recipe (subclass / implement / register / config / deps / test) that cites `BloggerAPIAdapter` at every step.\n\nFor broader project conventions (`docs/solutions/` lesson curation, monolith SLOC budget, worktree auto-cleanup), the rest of [AGENTS.md](AGENTS.md) is the source of truth.\n\n## Developer Tooling (Experimental)\n\n\u003e **Not part of the publishing pipeline.** These tools use [Webwright](https://github.com/microsoft/Webwright) (LLM-driven Playwright) to accelerate local development tasks only.\n\n**Prerequisites:** `pip install -e \".[dev-webwright]\"` and an `ANTHROPIC_API_KEY` (or `OPENAI_API_KEY`) in your environment.\n\n**Scaffold a new platform adapter** — explore a platform's login + post flow and get a Playwright script draft:\n\n```bash\nmake scaffold PLATFORM=devto LOGIN_URL=https://dev.to/enter\n# Output: docs/spikes/scaffold-devto-\u003cdate\u003e/  (gitignored)\n# Review the draft, then promote to src/.../adapters/ following AGENTS.md § adapter recipe\n```\n\n**Diagnose a bind-channel failure** — reproduce and document a login flow failure with screenshots:\n\n```bash\nmake diagnose CHANNEL=velog\n# Output: docs/diagnostics/velog-\u003cdate\u003e/  (gitignored)\n# Read summary.txt and screenshots to identify the root cause\n```\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fredredchen01%2Fbacklink-publisher","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fredredchen01%2Fbacklink-publisher","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fredredchen01%2Fbacklink-publisher/lists"}