DEV Community

Odilon HUGONNOT
Odilon HUGONNOT

Posted on • Originally published at web-developpeur.com

Automating blog publishing to dev.to and LinkedIn

Writing an article is already work. Republishing it manually on dev.to — copy-pasting the markdown, reformatting code blocks, fixing relative links — then crafting the LinkedIn post with the image and hook text... that's exactly the kind of friction that ends up meaning you post once every two months. The goal was simple: node scripts/publish-article.js my-slug and done. Here's what that looks like in practice.

The 3-script architecture

Three Node.js scripts, each responsible for one platform or step:

  • devto-draft-all.js + devto-publish-next.js — dev.to pipeline with cadence (4 days between articles)
  • linkedin-publish.js — LinkedIn publishing with large image
  • publish-article.js — unified script that orchestrates everything

Each pipeline has its own JSON schedule file, following the same pattern as posts.json: a list of objects with slug, scheduled publication date, and status (draft, published). Simple, versionable, readable.

Dev.to: the easy API

Dev.to exposes a clean REST API. An API key in the header, a POST to /api/articles, that's it. Articles are written in HTML (the .en.php files) — Turndown handles the HTML → Markdown conversion. The canonical URL points to the original blog: essential so Google doesn't treat dev.to as the primary source.

const payload = {
    article: {
        title: meta.title,
        published: false,          // draft first, publish separately
        body_markdown: markdown,
        tags: ['golang', 'api'],
        canonical_url: `https://www.web-developpeur.com/en/blog/${slug}`,
        description: meta.excerpt,
    },
};

const res = await fetch('https://dev.to/api/articles', {
    method: 'POST',
    headers: { 'api-key': DEVTO_API_KEY, 'Content-Type': 'application/json' },
    body: JSON.stringify(payload),
});
Enter fullscreen mode Exit fullscreen mode

Two separate steps: first create the draft, then publish via a second call (PUT /api/articles/:id with published: true). The 4-day cadence between publications avoids spamming followers — the devto-publish-next.js script checks the last published article's date before acting.

HTML → Markdown conversion with Turndown

Articles are written in HTML inside the .en.php files. Turndown converts that to clean Markdown, but two points need custom rules: code blocks (Prism uses class="language-go" which needs to be translated to triple backticks), and relative links that break on dev.to if not made absolute.

td.addRule('fenced-code-blocks', {
    filter: node => node.nodeName === 'CODE' && node.parentNode.nodeName === 'PRE',
    replacement: (content, node) => {
        const lang = (node.className || '').replace('language-', '').trim();
        return `\n\`\`\`${lang}\n${node.textContent}\n\`\`\`\n`;
    },
});

td.addRule('absolute-links', {
    filter: 'a',
    replacement: (content, node) => {
        let href = node.getAttribute('href') || '';
        if (href.startsWith('/')) href = 'https://www.web-developpeur.com' + href;
        return `[${content}](${href})`;
    },
});
Enter fullscreen mode Exit fullscreen mode

Without the absolute-links rule, all internal blog links (/blog/goroutine-leaks-golang) point to dev.to/blog/... on the reader's side. Classic.

LinkedIn: the painful API

This is where it gets interesting. No simple API key — LinkedIn requires OAuth 2.0. The full procedure to get a usable token:

  1. Create an app on the LinkedIn developer portal
  2. Enable the "Share on LinkedIn" (w_member_social) and "Sign In with LinkedIn using OpenID Connect" (openid, profile) products
  3. Add http://localhost:8989/callback as an authorized redirect URL
  4. Run a local server that receives the callback and exchanges the code for a token (valid for 60 days)
// Local OAuth server
const server = createServer(async (req, res) => {
    const url = new URL(req.url, 'http://localhost:8989');
    if (url.pathname !== '/callback') return;

    const code = url.searchParams.get('code');

    const tokenRes = await fetch('https://www.linkedin.com/oauth/v2/accessToken', {
        method: 'POST',
        headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
        body: new URLSearchParams({
            grant_type: 'authorization_code',
            code,
            redirect_uri: 'http://localhost:8989/callback',
            client_id: CLIENT_ID,
            client_secret: CLIENT_SECRET,
        }),
    });

    const token = await tokenRes.json();
    // save token.access_token to .linkedin-env
    server.close();
});

server.listen(8989);
Enter fullscreen mode Exit fullscreen mode

LinkedIn gotchas

Two undocumented pitfalls that cost time:

The OAuth URL from WSL. Opening the authorization URL from cmd.exe on WSL truncates the URL at the first & — the browser receives a truncated URL, the authorization fails with no useful error message. Fix: display the full URL in the terminal and copy-paste it manually into the browser.

The unauthorized_scope_error. It doesn't mean the scopes are misconfigured in the code — it means the products aren't activated in the developer portal. The "Share on LinkedIn" activation can take a few minutes and sometimes requires a page reload in the portal.

To retrieve the person ID (required in all API calls), use the OpenID Connect endpoint: GET /v2/userinfo, field sub.

Image upload and LinkedIn post creation

The large image (vs the small link preview generated automatically) requires 3 API calls in order. If you just paste the URL into the text without uploading an image, LinkedIn generates an automatic card but small. For the large image: upload the JPEG manually and don't include a URL in the text.

// 1. Register the upload
const registerRes = await fetch('https://api.linkedin.com/v2/assets?action=registerUpload', {
    method: 'POST',
    headers,
    body: JSON.stringify({
        registerUploadRequest: {
            recipes: ['urn:li:digitalmediaRecipe:feedshare-image'],
            owner: `urn:li:person:${PERSON_ID}`,
            serviceRelationships: [{
                relationshipType: 'OWNER',
                identifier: 'urn:li:userGeneratedContent'
            }],
        },
    }),
});
const { uploadUrl, asset } = (await registerRes.json()).value;

// 2. Upload the JPEG
await fetch(uploadUrl, {
    method: 'PUT',
    headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'image/jpeg' },
    body: readFileSync(`assets/images/og/${slug}.jpg`),
});

// 3. Create the post with the asset
await fetch('https://api.linkedin.com/v2/ugcPosts', {
    method: 'POST',
    headers,
    body: JSON.stringify({
        author: `urn:li:person:${PERSON_ID}`,
        lifecycleState: 'PUBLISHED',
        specificContent: {
            'com.linkedin.ugc.ShareContent': {
                shareCommentary: { text: postText },
                shareMediaCategory: 'IMAGE',
                media: [{ status: 'READY', media: asset, title: { text: title } }],
            },
        },
        visibility: { 'com.linkedin.ugc.MemberNetworkVisibility': 'PUBLIC' },
    }),
});
Enter fullscreen mode Exit fullscreen mode

The unified script

publish-article.js orchestrates everything in order:

node scripts/publish-article.js my-slug
Enter fullscreen mode Exit fullscreen mode

What it does:

  1. Checks that both FR and EN files exist
  2. Checks the entry in posts.json
  3. Generates the OG image (1200×628) via Puppeteer
  4. Creates the draft on dev.to (EN version, with canonical URL)
  5. Publishes on LinkedIn (FR version, with large image)
  6. Deploys the site to OVH via FTP

The draft/publish split on dev.to is intentional: the draft is created immediately, actual publication is handled by the cron according to the configured cadence.

The dev.to cron

Dev.to has a publication cadence managed by a cron on WSL. The script checks the last published article's date before acting — if the 4-day cadence isn't met, it does nothing. Use --force to bypass.

# crontab -e
17 3,15 * * * /home/user/work/cv/scripts/devto-cron.sh
Enter fullscreen mode Exit fullscreen mode

The shell script activates nvm, loads environment variables, runs devto-publish-next.js and logs the result to a rotating log file. Two runs per day (3:17am and 3:17pm) to avoid missing the window if the PC is off in the morning.

Conclusion

Dev.to is trivial: one API key, one POST, done. LinkedIn is two orders of magnitude more complex for a functionally identical result. The local OAuth server on localhost:8989 is the simplest solution without deploying a dedicated callback server. The token lasts 60 days — remember to renew it (a linkedin-refresh.js script handles that).

The real gain isn't in raw time saved — creating the post manually took 10 minutes. It's in removing mental friction. When posting is a command, you post more often. Consistency is the actual goal.

Top comments (0)