diff --git a/.github/workflows/debug.html b/.github/workflows/debug.html
new file mode 100644
index 0000000..407ccac
--- /dev/null
+++ b/.github/workflows/debug.html
@@ -0,0 +1,10 @@
+- name: Upload debug artifacts
+ if: ${{ always() }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: note-debug-${{ github.run_id }}
+ path: |
+ post.log
+ debug.html
+ debug.png
+ trace.zip
diff --git a/.github/workflows/note-perplexity.yaml b/.github/workflows/note-perplexity.yaml
index ebdcaea..2549653 100644
--- a/.github/workflows/note-perplexity.yaml
+++ b/.github/workflows/note-perplexity.yaml
@@ -1,22 +1,10 @@
-name: Note Workflow (Perplexity)
+name: Note Workflow (SEO URL -> note)
on:
workflow_dispatch:
inputs:
- theme:
- description: '記事テーマ'
- required: true
- type: string
- target:
- description: '想定読者(ペルソナ)'
- required: true
- type: string
- message:
- description: '読者に伝えたい核メッセージ'
- required: true
- type: string
- cta:
- description: '読後のアクション(CTA)'
+ article_url:
+ description: '投稿元のSEO記事URL'
required: true
type: string
tags:
@@ -48,20 +36,16 @@ env:
TZ: Asia/Tokyo
jobs:
- research:
- name: Research (Perplexity Search API)
+ fetch:
+ name: Fetch SEO article (Extract title/body)
runs-on: ubuntu-latest
env:
- PERPLEXITY_API_KEY: ${{ secrets.PERPLEXITY_API_KEY }}
- ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
- THEME: ${{ github.event.inputs.theme }}
- TARGET: ${{ github.event.inputs.target }}
+ article_url: ${{ github.event.inputs.article_url }}
+ INPUT_TAGS: ${{ github.event.inputs.tags }}
outputs:
- research_b64: ${{ steps.collect.outputs.research_b64 }}
+ final_b64: ${{ steps.collect.outputs.final_b64 }}
+ title: ${{ steps.collect.outputs.title }}
steps:
- - name: Checkout
- uses: actions/checkout@v4
-
- name: Setup Node.js
uses: actions/setup-node@v4
with:
@@ -70,395 +54,98 @@ jobs:
- name: Install dependencies
run: |
npm init -y
- npm i @perplexity-ai/perplexity_ai ai @ai-sdk/anthropic
+ npm i jsdom @mozilla/readability
- - name: Research with Perplexity API
+ - name: Extract article
run: |
- cat > research.mjs <<'EOF'
- import Perplexity from '@perplexity-ai/perplexity_ai';
- import { generateText } from 'ai';
- import { anthropic } from '@ai-sdk/anthropic';
+ cat > fetch.mjs <<'EOF'
+ import { JSDOM } from 'jsdom';
+ import { Readability } from '@mozilla/readability';
import fs from 'fs';
- const theme = process.env.THEME || '';
- const target = process.env.TARGET || '';
- const today = new Date().toISOString().slice(0,10);
- const artifactsDir = '.note-artifacts';
- fs.mkdirSync(artifactsDir, { recursive: true });
-
- // Perplexity APIクライアント初期化
- const perplexity = new Perplexity({
- apiKey: process.env.PERPLEXITY_API_KEY
- });
-
- async function main() {
- try {
- // 複数の検索クエリを実行
- const queries = [
- `${theme} 最新情報 ${today}`,
- `${theme} トレンド 動向`,
- `${theme} 事例 実践`,
- `${theme} 課題 解決策`,
- `${theme} 将来 展望`
- ];
-
- console.log('Perplexity検索を開始...');
- const searchResults = [];
-
- for (const query of queries) {
- console.log(`検索中: ${query}`);
- const search = await perplexity.search.create({
- query: query,
- maxResults: 5,
- maxTokensPerPage: 2048,
- country: 'JP'
- });
- searchResults.push({ query, results: search.results });
- }
+ function normalizeUrl(raw) {
+ const s = String(raw || '').trim();
+ if (!s) return '';
+ const half = s.replace(/?/g, '?').replace(/#/g, '#').replace(/&/g, '&');
+ const encoded = encodeURI(half);
+ return new URL(encoded).toString();
+ }
- // 検索結果をMarkdown形式でフォーマット
- let researchMd = `# リサーチレポート: ${theme}\n\n`;
- researchMd += `**作成日**: ${today}\n`;
- researchMd += `**対象読者**: ${target}\n\n`;
- researchMd += `---\n\n`;
-
- for (const { query, results } of searchResults) {
- researchMd += `## ${query}\n\n`;
-
- if (Array.isArray(results) && results.length > 0) {
- for (const result of results) {
- researchMd += `### ${result.title}\n\n`;
- researchMd += `**URL**: [${result.url}](${result.url})\n\n`;
- if (result.date) {
- researchMd += `**日付**: ${result.date}\n\n`;
- }
- if (result.snippet) {
- researchMd += `${result.snippet}\n\n`;
- }
- researchMd += `---\n\n`;
- }
- } else {
- researchMd += `検索結果なし\n\n`;
- }
- }
+ const raw = process.env.article_url || '';
+ const url = normalizeUrl(raw);
+ if (!url) {
+ console.error('article_url is empty');
+ process.exit(1);
+ }
- // Claudeで検索結果を要約・構造化
- console.log('Claudeで検索結果を分析・構造化中...');
- const modelName = 'claude-sonnet-4-20250514';
- const systemPrompt = [
- 'あなたは最新情報の分析と構造化に特化した超一流のリサーチャーです。',
- '提供された検索結果を基に、事実ベースで信頼性の高いリサーチレポートを作成してください。',
- '出典は本文内にMarkdownリンクで必ず埋め込むこと。',
- '十分な分量(目安: 2,000語以上)で、各節に適切な見出しと構造を持たせてください。',
- '一次情報(公的機関・規格・論文・公式発表)を優先し、情報源を明記してください。'
- ].join('\n');
-
- const userPrompt = [
- `以下の検索結果を基に、テーマ「${theme}」に関する包括的なリサーチレポートを作成してください。`,
- ``,
- `**対象読者**: ${target}`,
- `**現在日付**: ${today}`,
- ``,
- `## 検索結果`,
- ``,
- researchMd,
- ``,
- `## 要件`,
- ``,
- `1. 検索結果から重要な情報を抽出し、論理的に構造化する`,
- `2. 各主張には必ず出典のMarkdownリンクを埋め込む`,
- `3. 最新のトレンド、具体的な事例、課題と解決策、将来展望を含める`,
- `4. 対象読者(${target})に分かりやすく説明する`,
- `5. 情報の信頼性を評価し、一次情報を優先する`,
- ``,
- `**重要**: 途中経過や確認質問は一切せず、最終レポートのみを返してください。`
- ].join('\n');
-
- const { text } = await generateText({
- model: anthropic(modelName),
- system: systemPrompt,
- prompt: userPrompt,
- temperature: 0.3,
- maxTokens: 30000
- });
+ const res = await fetch(url, {
+ redirect: 'follow',
+ headers: {
+ 'User-Agent':
+ 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
+ 'Accept': 'text/html,application/xhtml+xml'
+ }
+ });
- const finalReport = text || researchMd;
+ const finalUrl = res.url; // ★これが超重要(redirect後のURL)
+ console.log('DEBUG_FETCH_FINAL_URL=', finalUrl)
- // 保存
- fs.writeFileSync(`${artifactsDir}/research.md`, finalReport);
-
- // トレース用に生データも保存
- const traceData = {
- theme,
- target,
- date: today,
- queries,
- searchResults,
- finalReport: finalReport.substring(0, 500) + '...'
- };
- fs.writeFileSync(`${artifactsDir}/research_trace.json`, JSON.stringify(traceData, null, 2));
-
- console.log('リサーチ完了!');
-
- } catch (error) {
- console.error('エラー:', error);
- // エラー時は検索結果のみを保存
- const fallbackReport = `# リサーチレポート: ${theme}\n\n**エラーが発生しました**: ${error.message}\n\n検索は部分的に完了している可能性があります。`;
- fs.writeFileSync(`${artifactsDir}/research.md`, fallbackReport);
- throw error;
- }
+ if (!res.ok) {
+ console.error('fetch failed:', res.status, res.statusText);
+ process.exit(1);
}
- await main();
- EOF
- node research.mjs
+ const html = await res.text();
- - name: Collect research
- id: collect
- run: |
- b64=$(base64 -w 0 .note-artifacts/research.md 2>/dev/null || base64 .note-artifacts/research.md)
- echo "research_b64<> $GITHUB_OUTPUT
- echo "$b64" >> $GITHUB_OUTPUT
- echo "EOF" >> $GITHUB_OUTPUT
-
- - name: Upload research artifacts
- uses: actions/upload-artifact@v4
- with:
- name: research-artifacts
- path: |
- .note-artifacts/research.md
- .note-artifacts/research_trace.json
+ const dom = new JSDOM(html, { url: finalUrl });
+ const reader = new Readability(dom.window.document);
+ const article = reader.parse();
- write:
- name: Write (Claude Sonnet 4.0)
- needs: research
- runs-on: ubuntu-latest
- env:
- ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
- THEME: ${{ github.event.inputs.theme }}
- TARGET: ${{ github.event.inputs.target }}
- MESSAGE: ${{ github.event.inputs.message }}
- CTA: ${{ github.event.inputs.cta }}
- INPUT_TAGS: ${{ github.event.inputs.tags }}
- outputs:
- title: ${{ steps.collect.outputs.title }}
- draft_json_b64: ${{ steps.collect.outputs.draft_json_b64 }}
- steps:
- - name: Checkout
- uses: actions/checkout@v4
+ if (!article || !article.content) {
+ console.error('Readability failed to extract content');
+ process.exit(1);
+ }
- - name: Setup Node.js
- uses: actions/setup-node@v4
- with:
- node-version: '20'
+ const inputTags = (process.env.INPUT_TAGS || '')
+ .split(',')
+ .map(s => s.trim())
+ .filter(Boolean);
- - name: Install AI SDK
- run: |
- npm init -y
- npm i ai @ai-sdk/anthropic
+ const attributionHtml =
+ `出典: ${finalUrl}
`;
- - name: Restore research
- env:
- RESEARCH_B64: ${{ needs.research.outputs.research_b64 }}
- run: |
- mkdir -p .note-artifacts
- echo "$RESEARCH_B64" | base64 -d > .note-artifacts/research.md || echo "$RESEARCH_B64" | base64 --decode > .note-artifacts/research.md
+ const out = {
+ title: (article.title || '').trim() || '(タイトル取得失敗)',
+ body_html: attributionHtml + article.content,
+ tags: inputTags,
+ source_url: url
+ };
- - name: Generate draft (title/body/tags)
- run: |
- cat > write.mjs <<'EOF'
- import { generateText } from 'ai';
- import { anthropic } from '@ai-sdk/anthropic';
- import fs from 'fs';
- const theme=process.env.THEME||''; const target=process.env.TARGET||''; const message=process.env.MESSAGE||''; const cta=process.env.CTA||'';
- const inputTags=(process.env.INPUT_TAGS||'').split(',').map(s=>s.trim()).filter(Boolean);
- const researchReport=fs.readFileSync('.note-artifacts/research.md','utf8');
- const modelName='claude-sonnet-4-20250514';
- function extractJsonFlexible(raw){const t=(raw||'').trim().replace(/\u200B/g,'');try{return JSON.parse(t);}catch{}const m=t.match(/```[a-zA-Z]*\s*([\s\S]*?)\s*```/);if(m&&m[1]){try{return JSON.parse(m[1].trim());}catch{}}const f=t.indexOf('{'),l=t.lastIndexOf('}');if(f!==-1&&l!==-1&&l>f){const c=t.slice(f,l+1);try{return JSON.parse(c);}catch{}}return null;}
- async function repairJson(raw){const sys='入力から {"title":string,"draftBody":string,"tags":string[]} のJSONのみ返答。';const {text}=await generateText({model:anthropic(modelName),system:sys,prompt:String(raw),temperature:0,maxTokens:8000});return extractJsonFlexible(text||'');}
- function sanitizeTitle(t){
- let s=String(t||'').trim();
- // フェンスや見出し、引用符を除去
- s=s.replace(/^```[a-zA-Z0-9_-]*\s*$/,'').replace(/^```$/,'');
- s=s.replace(/^#+\s*/,'');
- s=s.replace(/^"+|"+$/g,'').replace(/^'+|'+$/g,'');
- s=s.replace(/^`+|`+$/g,'');
- s=s.replace(/^json$/i,'').trim();
- if(!s) s='タイトル(自動生成)';
- return s;
- }
- function deriveTitleFromText(text){
- const lines=(text||'').split(/\r?\n/).map(l=>l.trim()).filter(Boolean);
- const firstReal=lines.find(l=>!/^```/.test(l))||lines[0]||'';
- return sanitizeTitle(firstReal);
- }
- const sysWrite='note.com向け長文記事の生成。JSON {title,draftBody,tags[]} で返答。draftBodyは6000〜9000文字を目安に十分な分量で、章ごとに小見出しと箇条書きを適切に含めること。';
- const prompt=[`{テーマ}: ${theme}`,`{ペルソナ}: ${target}`,`{リサーチ内容}: ${researchReport}`,`{伝えたいこと}: ${message}`,`{読後のアクション}: ${cta}`].join('\n');
- const {text}=await generateText({model:anthropic(modelName),system:sysWrite,prompt,temperature:0.7,maxTokens:30000});
- let obj=extractJsonFlexible(text||'')||await repairJson(text||'');
- let title, draftBody, tags; if(obj){title=sanitizeTitle(obj.title); draftBody=String(obj.draftBody||'').trim(); tags=Array.isArray(obj.tags)?obj.tags.map(String):[]}
- if(!title||!draftBody){ title=deriveTitleFromText(text||''); const lines=(text||'').split(/\r?\n/); draftBody=lines.slice(1).join('\n').trim()||(text||''); tags=[]}
- if(inputTags.length){tags=Array.from(new Set([...(tags||[]),...inputTags]));}
- fs.writeFileSync('.note-artifacts/draft.json',JSON.stringify({title,draftBody,tags},null,2));
+ fs.writeFileSync('final.json', JSON.stringify(out, null, 2));
+ console.log('Extracted:', out.title);
EOF
- node write.mjs
+ node fetch.mjs
- - name: Collect draft
- id: collect
+ - name: Verify extracted files
run: |
- title=$(node -e "console.log(JSON.parse(require('fs').readFileSync('.note-artifacts/draft.json','utf8')).title)")
- b64=$(base64 -w 0 .note-artifacts/draft.json 2>/dev/null || base64 .note-artifacts/draft.json)
- echo "title<> $GITHUB_OUTPUT
- echo "$title" >> $GITHUB_OUTPUT
- echo "EOF" >> $GITHUB_OUTPUT
- echo "draft_json_b64<> $GITHUB_OUTPUT
- echo "$b64" >> $GITHUB_OUTPUT
- echo "EOF" >> $GITHUB_OUTPUT
+ ls -la
+ test -f final.json && echo "final.json exists" || (echo "final.json missing" && exit 1)
- - name: Upload draft artifact
+ - name: Upload extracted artifact
uses: actions/upload-artifact@v4
with:
- name: draft-artifact
- path: .note-artifacts/draft.json
-
- factcheck:
- name: Fact-check (Tavily)
- needs: write
- runs-on: ubuntu-latest
- env:
- ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
- TAVILY_API_KEY: ${{ secrets.TAVILY_API_KEY }}
- TITLE: ${{ needs.write.outputs.title }}
- outputs:
- title: ${{ steps.collect.outputs.title }}
- final_b64: ${{ steps.collect.outputs.final_b64 }}
- steps:
- - name: Checkout
- uses: actions/checkout@v4
-
- - name: Setup Node.js
- uses: actions/setup-node@v4
- with:
- node-version: '20'
-
- - name: Install AI SDK
- run: |
- npm init -y
- npm i ai @ai-sdk/anthropic
-
- - name: Restore draft json
- env:
- DRAFT_JSON_B64: ${{ needs.write.outputs.draft_json_b64 }}
- run: |
- mkdir -p .note-artifacts
- echo "$DRAFT_JSON_B64" | base64 -d > .note-artifacts/draft.json || echo "$DRAFT_JSON_B64" | base64 --decode > .note-artifacts/draft.json
-
- - name: Fact-check with Tavily
- run: |
- cat > factcheck.mjs <<'EOF'
- import { generateText } from 'ai';
- import { anthropic } from '@ai-sdk/anthropic';
- import fs from 'fs';
- const draft=JSON.parse(fs.readFileSync('.note-artifacts/draft.json','utf8'));
- const modelName='claude-sonnet-4-20250514';
- const TAVILY_API_KEY=process.env.TAVILY_API_KEY||'';
- if(!TAVILY_API_KEY){ console.error('TAVILY_API_KEY is not set'); process.exit(1); }
-
- function extractJsonFlexible(raw){
- const t=(raw||'').trim().replace(/\u200B/g,'');
- // try object
- try{ const o=JSON.parse(t); return o; }catch{}
- const fence=t.match(/```[a-zA-Z]*\s*([\s\S]*?)\s*```/); if(fence&&fence[1]){ try{ return JSON.parse(fence[1].trim()); }catch{} }
- // try object slice
- let f=t.indexOf('{'), l=t.lastIndexOf('}'); if(f!==-1&&l!==-1&&l>f){ const cand=t.slice(f,l+1); try{ return JSON.parse(cand); }catch{} }
- // try array slice
- f=t.indexOf('['); l=t.lastIndexOf(']'); if(f!==-1&&l!==-1&&l>f){ const cand=t.slice(f,l+1); try{ return JSON.parse(cand); }catch{} }
- return null;
- }
- function stripCodeFence(s){
- const t=String(s||'').trim();
- const m=t.match(/^```[a-zA-Z0-9_-]*\s*([\s\S]*?)\s*```\s*$/); if(m&&m[1]) return m[1].trim();
- return t;
- }
-
- async function proposeQueries(body){
- const sys='あなたは事実検証の専門家です。入力本文から検証が必要な固有名詞・数値・主張を抽出し、Tavily検索用に日本語の検索クエリを最大10件の配列で返してください。出力はJSON配列のみ。';
- const { text } = await generateText({ model: anthropic(modelName), system: sys, prompt: String(body), temperature: 0, maxTokens: 2000 });
- const arr = extractJsonFlexible(text||'');
- return Array.isArray(arr) ? arr.map(String).filter(Boolean).slice(0,10) : [];
- }
-
- async function tavilySearch(q){
- const res = await fetch('https://api.tavily.com/search', {
- method: 'POST', headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ api_key: TAVILY_API_KEY, query: q, search_depth: 'advanced', max_results: 5, include_answer: true })
- });
- if(!res.ok){ return { query:q, results:[], answer:null }; }
- const json = await res.json().catch(()=>({}));
- return { query:q, results: Array.isArray(json.results)? json.results: [], answer: json.answer || null };
- }
-
- function formatEvidence(items){
- const lines = [];
- for(const it of items){
- lines.push(`### 検索: ${it.query}`);
- if(it.answer){ lines.push(`要約: ${it.answer}`); }
- for(const r of it.results||[]){
- const t = (r.title||'').toString();
- const u = (r.url||'').toString();
- const c = (r.content||'').toString().slice(0,500);
- lines.push(`- [${t}](${u})\n ${c}`);
- }
- lines.push('');
- }
- return lines.join('\n');
- }
-
- async function main(){
- const queries = await proposeQueries(draft.draftBody||'');
- const results = [];
- for(const q of queries){ results.push(await tavilySearch(q)); }
- const evidence = formatEvidence(results);
- const sys=[
- 'あなたは事実検証の専門家です。以下の原稿(note記事の下書き)に対し、提供されたエビデンス(Tavily検索結果)に基づき、',
- '誤情報の修正・低信頼出典の置換・信頼できる一次情報の本文内Markdownリンク埋め込みを行って、修正後の本文のみ返してください。',
- '文体・構成は原稿を尊重し、必要に応じて本文末尾に参考文献セクションを追加してください。',
- ].join('\n');
- const prompt = [
- '## 原稿', String(draft.draftBody||''), '', '## エビデンス(Tavily検索結果)', evidence
- ].join('\n\n');
- const { text } = await generateText({ model: anthropic(modelName), system: sys, prompt, temperature: 0.3, maxTokens: 30000 });
- let body = stripCodeFence(text||'');
- let title = process.env.TITLE || draft.title || '';
- let tags = Array.isArray(draft.tags)? draft.tags: [];
- const obj = extractJsonFlexible(body);
- if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
- if (obj.title) title = String(obj.title);
- const candidates = [obj.body, obj.draftBody, obj.content, obj.text];
- const chosen = candidates.find(v=>typeof v==='string' && v.trim());
- if (chosen) body = String(chosen);
- if (Array.isArray(obj.tags)) tags = obj.tags.map(String);
- }
- body = stripCodeFence(body);
- const out = { title, body, tags };
- fs.writeFileSync('.note-artifacts/final.json', JSON.stringify(out,null,2));
- }
+ name: extracted-article
+ if-no-files-found: error
+ path: |
+ final.json
- await main();
- EOF
- node factcheck.mjs
- - name: Upload fact-check artifact
- uses: actions/upload-artifact@v4
- with:
- name: final-artifact
- path: .note-artifacts/final.json
- name: Collect final
id: collect
run: |
- title=$(node -e "console.log(JSON.parse(require('fs').readFileSync('.note-artifacts/final.json','utf8')).title)")
- b64=$(base64 -w 0 .note-artifacts/final.json 2>/dev/null || base64 .note-artifacts/final.json)
+ title=$(node -e "console.log(JSON.parse(require('fs').readFileSync('final.json','utf8')).title)")
+ b64=$(base64 -w 0 final.json 2>/dev/null || base64 final.json)
echo "title<> $GITHUB_OUTPUT
echo "$title" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
@@ -468,12 +155,12 @@ jobs:
post:
name: Post to note.com (Playwright)
- needs: factcheck
+ needs: fetch
if: ${{ github.event.inputs.dry_run != 'true' }}
runs-on: ubuntu-latest
env:
IS_PUBLIC: ${{ github.event.inputs.is_public }}
- STATE_JSON: ${{ secrets.NOTE_STORAGE_STATE_JSON }}
+ STATE_B64: ${{ secrets.NOTE_STORAGE_STATE_B64 }}
START_URL: https://editor.note.com/new
outputs:
final_url: ${{ steps.publish.outputs.published_url || steps.publish.outputs.draft_url }}
@@ -489,15 +176,16 @@ jobs:
- name: Install Playwright
run: |
npm init -y
- npm i playwright marked
+ npm i playwright
npx playwright install --with-deps chromium | cat
- name: Prepare storageState
id: state
run: |
- test -n "$STATE_JSON" || (echo "ERROR: NOTE_STORAGE_STATE_JSON secret is not set" && exit 1)
+ test -n "$STATE_B64" || (echo "ERROR: NOTE_STORAGE_STATE_B64 secret is not set" && exit 1)
mkdir -p "$RUNNER_TEMP"
- echo "$STATE_JSON" > "$RUNNER_TEMP/note-state.json"
+ echo "$STATE_B64" | base64 -d > "$RUNNER_TEMP/note-state.json"
+ node -e "JSON.parse(require('fs').readFileSync('$RUNNER_TEMP/note-state.json','utf8')); console.log('storageState JSON OK')"
echo "STATE_PATH=$RUNNER_TEMP/note-state.json" >> $GITHUB_OUTPUT
- name: Ensure jq (post)
@@ -510,7 +198,7 @@ jobs:
- name: Restore final
id: draft
env:
- FINAL_B64: ${{ needs.factcheck.outputs.final_b64 }}
+ FINAL_B64: ${{ needs.fetch.outputs.final_b64 }}
run: |
test -n "$FINAL_B64" || { echo "final_b64 output is empty"; exit 1; }
echo "$FINAL_B64" | base64 -d > final.json || echo "$FINAL_B64" | base64 --decode > final.json
@@ -523,272 +211,374 @@ jobs:
TITLE: ${{ steps.draft.outputs.TITLE }}
TAGS: ${{ steps.draft.outputs.TAGS }}
STATE_PATH: ${{ steps.state.outputs.STATE_PATH }}
+
run: |
- # 本文は後続スクリプト内でMarkdownリンク→素URL化などの前処理を行う
+ : > post.log
cat > post.mjs <<'EOF'
import { chromium } from 'playwright';
- import { marked } from 'marked';
import fs from 'fs';
import os from 'os';
import path from 'path';
-
- function nowStr(){ const d=new Date(); const z=n=>String(n).padStart(2,'0'); return `${d.getFullYear()}-${z(d.getMonth()+1)}-${z(d.getDate())}_${z(d.getHours())}-${z(d.getMinutes())}-${z(d.getSeconds())}`; }
-
- const STATE_PATH=process.env.STATE_PATH;
- const START_URL=process.env.START_URL||'https://editor.note.com/new';
- const rawTitle=process.env.TITLE||'';
- const rawFinal=JSON.parse(fs.readFileSync('final.json','utf8'));
- const rawBody=String(rawFinal.body||'');
- const TAGS=process.env.TAGS||'';
- const IS_PUBLIC=String(process.env.IS_PUBLIC||'false')==='true';
-
- if(!fs.existsSync(STATE_PATH)){ console.error('storageState not found:', STATE_PATH); process.exit(1); }
-
- const ssDir=path.join(os.tmpdir(),'note-screenshots'); fs.mkdirSync(ssDir,{recursive:true}); const SS_PATH=path.join(ssDir,`note-post-${nowStr()}.png`);
-
- function sanitizeTitle(t){
- let s=String(t||'').trim();
- s=s.replace(/^```[a-zA-Z0-9_-]*\s*$/,'').replace(/^```$/,'');
- s=s.replace(/^#+\s*/,'');
- s=s.replace(/^"+|"+$/g,'').replace(/^'+|'+$/g,'');
- s=s.replace(/^`+|`+$/g,'');
- s=s.replace(/^json$/i,'').trim();
- // タイトルが波括弧や記号のみの時は無効として扱う
- if (/^[\{\}\[\]\(\)\s]*$/.test(s)) s='';
- if(!s) s='タイトル(自動生成)';
- return s;
- }
- function deriveTitleFromMarkdown(md){
- const lines=String(md||'').split(/\r?\n/);
- for (const line of lines){
- const l=line.trim();
- if(!l) continue;
- const m=l.match(/^#{1,3}\s+(.+)/); if(m) return sanitizeTitle(m[1]);
- if(!/^```|^>|^\* |^- |^\d+\. /.test(l)) return sanitizeTitle(l);
- }
- return '';
- }
- function normalizeBullets(md){
- // 先頭の中黒・ビュレットを箇条書きに正規化
- return String(md||'')
- .replace(/^\s*[•・]\s?/gm,'- ')
- .replace(/^\s*◦\s?/gm,' - ');
+
+ function nowStr() {
+ const d = new Date();
+ const z = n => String(n).padStart(2, '0');
+ return `${d.getFullYear()}-${z(d.getMonth() + 1)}-${z(d.getDate())}_${z(d.getHours())}-${z(d.getMinutes())}-${z(d.getSeconds())}`;
}
- function unwrapParagraphs(md){
- // 段落中の不必要な改行をスペースへ(見出し/リスト/引用/コードは除外)
- const lines=String(md||'').split(/\r?\n/);
- const out=[]; let buf=''; let inFence=false;
- for(const raw of lines){
- const line=raw.replace(/\u200B/g,'');
- if(/^```/.test(line)){ inFence=!inFence; buf+=line+'\n'; continue; }
- if(inFence){ buf+=line+'\n'; continue; }
- if(/^\s*$/.test(line)){ if(buf) out.push(buf.trim()); out.push(''); buf=''; continue; }
- // 箇条書きや番号付きの字下げ改行を一行に連結
- if(/^(#{1,6}\s|[-*+]\s|\d+\.\s|>\s)/.test(line)){
- if(buf){ out.push(buf.trim()); buf=''; }
- // 次の数行が連続して単語単位の改行の場合は連結
- out.push(line.replace(/\s+$/,''));
- continue;
- }
- // 行頭が1文字や数文字で改行されているケース(縦伸び)を連結
- if(buf){ buf += (/[。.!?)]$/.test(buf) ? '\n' : ' ') + line.trim(); }
- else { buf = line.trim(); }
- }
- if(buf) out.push(buf.trim());
- return out.join('\n');
+
+ function sanitizeTitle(t) {
+ let s = String(t || '').trim();
+ s = s.replace(/^#+\s*/, '');
+ s = s.replace(/^"+|"+$/g, '').replace(/^'+|'+$/g, '');
+ if (!s) s = 'タイトル(自動生成)';
+ return s;
}
- function preferBareUrls(md){
- const embedDomains=['openai.com','youtube.com','youtu.be','x.com','twitter.com','speakerdeck.com','slideshare.net','google.com','maps.app.goo.gl','gist.github.com'];
- return String(md||'').replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,(m,text,url)=>{
- try{
- const u=new URL(url); const host=u.hostname.replace(/^www\./,'');
- const isEmbed = embedDomains.some(d=>host.endsWith(d) || (url.includes('google.com/maps') && d.includes('google.com')));
- return isEmbed ? `${text}\n${url}\n` : `${text} (${url})`;
- }catch{return `${text} ${url}`;}
+
+ // HTMLを「本文として安全に入るテキスト」に変換(最小限)
+ function htmlToText(html) {
+ let s = String(html || '');
+ // br / p / li / hr を改行に寄せる
+ s = s.replace(/<\s*br\s*\/?\s*>/gi, '\n');
+ s = s.replace(/<\s*\/p\s*>/gi, '\n\n');
+ s = s.replace(/<\s*p(\s+[^>]*)?>/gi, '');
+ s = s.replace(/<\s*\/li\s*>/gi, '\n');
+ s = s.replace(/<\s*li(\s+[^>]*)?>/gi, '・');
+ s = s.replace(/<\s*hr\s*\/?\s*>/gi, '\n\n---\n\n');
+
+ // aタグは「テキスト (URL)」にする(軽い)
+ s = s.replace(/]*href="([^"]+)"[^>]*>(.*?)<\/a>/gi, (_, href, text) => {
+ const t = String(text || '').replace(/<[^>]+>/g, '').trim();
+ const u = String(href || '').trim();
+ if (!t) return u;
+ return `${t} (${u})`;
});
+
+ // それ以外のタグを落とす
+ s = s.replace(/<[^>]+>/g, '');
+
+ // HTMLエンティティ軽く戻す
+ s = s
+ .replace(/ /g, ' ')
+ .replace(/&/g, '&')
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, "'");
+
+ // 連続空白/改行を整形
+ s = s.replace(/\r/g, '');
+ s = s.replace(/[ \t]+\n/g, '\n');
+ s = s.replace(/\n{3,}/g, '\n\n');
+ return s.trim() + '\n';
}
- function isGarbageLine(line){
- return /^[\s\{\}\[\]\(\)`]+$/.test(line || '');
- }
- function normalizeListItemSoftBreaks(md){
- const lines=String(md||'').split(/\r?\n/);
- const out=[]; let inItem=false;
- const listStartRe=/^(\s*)(?:[-*+]\s|\d+\.\s)/;
- for (let i=0;i{ const t=b.trim(); return t.length>0 && !isGarbageLine(t); });
- }
- function mdToHtml(block){
- // JSONが紛れ込んでしまった場合は本文候補のみ抽出
- try{
- const maybe = JSON.parse(block);
- if (maybe && typeof maybe==='object' && !Array.isArray(maybe)){
- const candidates=[maybe.body, maybe.draftBody, maybe.content, maybe.text];
- const chosen=candidates.find(v=>typeof v==='string' && v.trim());
- if (chosen) block = String(chosen);
+
+ const rawFinal = JSON.parse(fs.readFileSync('final.json', 'utf8'));
+ const rawBodyHtml = String(rawFinal.body_html || '');
+ if (!rawBodyHtml.trim()) {
+ console.error('body_html is empty in final.json');
+ process.exit(1);
+ }
+
+ const TITLE = sanitizeTitle(rawTitle);
+ const BODY_TEXT = htmlToText(rawBodyHtml);
+ console.log('DEBUG_BODY_TEXT_LEN=', BODY_TEXT.length);
+ console.log('DEBUG_BODY_TEXT_HEAD=', BODY_TEXT.slice(0, 200));
+
+
+ const ssDir = path.join(os.tmpdir(), 'note-screenshots');
+ fs.mkdirSync(ssDir, { recursive: true });
+ const SS_PATH = path.join(ssDir, `note-post-${nowStr()}.png`);
+
+ let browser, context, page;
+
+ try {
+ browser = await chromium.launch({
+ headless: true,
+ args: [
+ '--lang=ja-JP',
+ '--disable-blink-features=AutomationControlled',
+ '--no-sandbox',
+ ],
+ });
+
+ context = await browser.newContext({
+ storageState: STATE_PATH,
+ locale: 'ja-JP',
+ viewport: { width: 1365, height: 900 },
+ userAgent:
+ 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
+ });
+
+ await context.addInitScript(() => {
+ Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
+ });
+
+ await context.tracing.start({ screenshots: true, snapshots: true, sources: true });
+
+ page = await context.newPage();
+ page.setDefaultTimeout(180000);
+
+ page.on('console', msg => console.log('BROWSER_CONSOLE:', msg.type(), msg.text()));
+ page.on('pageerror', err => console.log('BROWSER_PAGEERROR:', err.message));
+ page.on('requestfailed', req => console.log('REQ_FAILED:', req.url(), req.failure()?.errorText));
+
+ const rawState = fs.readFileSync(STATE_PATH, 'utf8');
+ console.log('DEBUG_STATE_BYTES=', rawState.length);
+
+ const cookies0 = await context.cookies();
+ const noteCookies0 = cookies0.filter(c => (c.domain || '').includes('note.com'));
+ console.log('DEBUG_NOTE_COOKIE_COUNT(before goto)=', noteCookies0.length);
+ console.log('DEBUG_NOTE_COOKIE_NAMES(before goto)=', noteCookies0.map(c => c.name).slice(0, 30).join(','));
+
+ // 認証確認(200が返るならログインは通ってる)
+ const resp = await context.request.get('https://note.com/api/v2/current_user');
+ console.log('DEBUG_current_user_status=', resp.status());
+ const txt = await resp.text();
+ console.log('DEBUG_current_user_body_head=', txt.slice(0, 200));
+
+ await page.goto(START_URL, { waitUntil: 'domcontentloaded' });
+ await page.waitForTimeout(3000);
+
+ console.log('DEBUG_URL_AFTER_GOTO=' + page.url());
+
+ // /new から /notes/.../edit へ遷移するのを待つ
+ const urlNow = page.url();
+ const isEditUrl = /\/notes\/[^/]+\/edit\/?/i.test(urlNow);
+ if (!isEditUrl) {
+ await page.waitForURL(/\/notes\/[^/]+\/edit\/?/i, { timeout: 60000 }).catch(() => {});
+ }
+ console.log('DEBUG_URL_AFTER_WAIT_EDIT=' + page.url());
+
+ await page.screenshot({ path: SS_PATH, fullPage: true });
+ console.log('SCREENSHOT=' + SS_PATH);
+
+ // ===== 本文エディタ(ProseMirror)を特定 =====
+ const bodyCandidates = [
+ // data-testid 系(noteが持ってることが多い)
+ '[data-testid*="editor" i] .ProseMirror[contenteditable="true"]',
+ '[data-testid*="body" i] .ProseMirror[contenteditable="true"]',
+
+ // よくある構造
+ 'main .ProseMirror[contenteditable="true"]',
+ 'article .ProseMirror[contenteditable="true"]',
+
+ // 最終手段:ProseMirror複数なら「2つ目以降」を本文とみなす
+ '.ProseMirror[contenteditable="true"]',
+ ];
+
+ let bodyBox = null;
+
+ // 1) セレクタ候補を上から試す
+ for (const sel of bodyCandidates) {
+ const loc = page.locator(sel);
+ const n = await loc.count().catch(() => 0);
+ if (n === 0) continue;
+
+ // selが広すぎる場合を考慮して「画面内で大きい要素」を本文とみなす
+ let best = null;
+ let bestArea = 0;
+ for (let i = 0; i < n; i++) {
+ const el = loc.nth(i);
+ if (!(await el.isVisible().catch(() => false))) continue;
+ const box = await el.boundingBox().catch(() => null);
+ if (!box) continue;
+ const area = box.width * box.height;
+ if (area > bestArea) {
+ bestArea = area;
+ best = el;
+ }
+ }
+ if (best) { bodyBox = best; break; }
+ }
+
+ if (!bodyBox) {
+ fs.writeFileSync('debug-body.html', await page.content());
+ await page.screenshot({ path: 'debug-body.png', fullPage: true });
+ throw new Error('Body editor not found');
+ }
+
+ await bodyBox.waitFor({ state: 'visible', timeout: 60000 });
+ console.log('DEBUG_BODY_BOX_FOUND=', await bodyBox.evaluate(el => el.className));
+
+
+ // ===== タイトルを特定(あなたのスクショでは本文とは別要素)=====
+ const titleCandidates = [
+ // input/textarea 系
+ 'input[placeholder*="タイトル"]',
+ 'textarea[placeholder*="タイトル"]',
+ 'input[name*="title" i]',
+ 'textarea[name*="title" i]',
+
+ // aria
+ 'input[aria-label*="タイトル"]',
+ 'textarea[aria-label*="タイトル"]',
+
+ // data-testid 系(noteが変えても拾いやすい)
+ '[data-testid*="title" i] input',
+ '[data-testid*="title" i] textarea',
+ '[data-testid*="title" i]',
+
+ // 最終手段:画面上部の見出しっぽい入力
+ 'input[type="text"]',
+ ];
+
+ let titleEl = null;
+ for (const sel of titleCandidates) {
+ const loc = page.locator(sel).first();
+ if (await loc.isVisible().catch(() => false)) { titleEl = loc; break; }
+ }
+
+ if (!titleEl) {
+ fs.writeFileSync('debug-title.html', await page.content());
+ await page.screenshot({ path: 'debug-title.png', fullPage: true });
+ throw new Error('Title element not found (candidates exhausted)');
+ }
+
+ const titleTag = await titleEl.evaluate(el => el.tagName.toLowerCase());
+ if (titleTag === 'input' || titleTag === 'textarea') {
+ await titleEl.fill(TITLE);
+ } else {
+ await titleEl.click({ force: true });
+ await page.keyboard.press('Control+A');
+ await page.keyboard.type(TITLE, { delay: 5 });
+ }
+
+ // ===== 本文入力(HTMLではなくテキストで入れる:最重要)=====
+ await bodyBox.click({ force: true });
+ await page.keyboard.press('Control+A');
+ await page.keyboard.press('Backspace');
+
+ // ProseMirrorは insertHTML が不安定なので insertText で流し込む
+ // ===== 本文入力 =====
+ await bodyBox.click({ force: true });
+
+ // 全消し
+ await page.keyboard.press('Control+A');
+ await page.keyboard.press('Backspace');
+
+ // execCommand(insertText)で分割投入(ProseMirrorが拾いやすい)
+ async function insertByExecCommand(text) {
+ const chunk = 800;
+ for (let i = 0; i < text.length; i += chunk) {
+ const part = text.slice(i, i + chunk);
+
+ await page.evaluate((t) => {
+ // activeElement に対して insertText
+ document.execCommand('insertText', false, t);
+ }, part);
+
+ if (i % (chunk * 20) === 0) await page.waitForTimeout(50);
+ }
+ }
+
+ // フォーカスを強制(念のため)
+ await bodyBox.evaluate(el => el.focus());
+
+ await insertByExecCommand(BODY_TEXT);
+
+ await page.waitForTimeout(1500);
+
+ if (!IS_PUBLIC) {
+ // 下書き保存(ボタンが見えない場合があるので複数候補)
+ const saveBtn = page.locator('button:has-text("下書き保存"), button:has-text("下書きを保存"), [aria-label*="下書き保存"]').first();
+ if (await saveBtn.count()) {
+ await saveBtn.click({ force: true }).catch(() => {});
+ }
+ await page.waitForTimeout(2000);
+
+ await page.screenshot({ path: SS_PATH, fullPage: true });
+ console.log('DRAFT_URL=' + page.url());
+ console.log('SCREENSHOT=' + SS_PATH);
+ return; // main() 内なので合法
}
- }catch{}
- const isList = /^\s*(?:[-*+]\s|\d+\.\s)/.test(block);
- return String(marked.parse(block, { gfm:true, breaks: !isList, mangle:false, headerIds:false }) || '');
- }
- function htmlFromMarkdown(md){
- // 全文を一括でHTML化(段落ベース)。リスト中の意図しない
を避けるため breaks=false
- return String(marked.parse(md, { gfm:true, breaks:false, mangle:false, headerIds:false }) || '');
- }
- async function insertHTML(page, locator, html){
- await locator.click();
- await locator.evaluate((el, html) => {
- el.focus();
- const sel = window.getSelection();
- const range = document.createRange();
- range.selectNodeContents(el);
- range.collapse(false);
- sel.removeAllRanges();
- sel.addRange(range);
- document.execCommand('insertHTML', false, html);
- }, html);
- }
- let TITLE=sanitizeTitle(rawTitle);
- let preBody = preferBareUrls(rawBody);
- preBody = normalizeBullets(preBody);
- preBody = normalizeListItemSoftBreaks(preBody);
- preBody = unwrapParagraphs(preBody);
- if(!TITLE || TITLE==='タイトル(自動生成)'){
- const d=deriveTitleFromMarkdown(preBody);
- if(d) TITLE=d;
- }
- const blocks = splitMarkdownBlocks(preBody);
-
- let browser, context, page;
- try{
- browser = await chromium.launch({ headless: true, args: ['--lang=ja-JP'] });
- context = await browser.newContext({ storageState: STATE_PATH, locale: 'ja-JP' });
- page = await context.newPage();
- page.setDefaultTimeout(180000);
-
- await page.goto(START_URL, { waitUntil: 'domcontentloaded' });
- await page.waitForSelector('textarea[placeholder*="タイトル"]');
- await page.fill('textarea[placeholder*="タイトル"]', TITLE);
-
- const bodyBox = page.locator('div[contenteditable="true"][role="textbox"]').first();
- await bodyBox.waitFor({ state: 'visible' });
- const htmlAll = htmlFromMarkdown(preBody);
- let pasted = false;
- try {
- const origin = new URL(START_URL).origin;
- await context.grantPermissions(['clipboard-read','clipboard-write'], { origin });
- await page.evaluate(async (html, plain) => {
- const item = new ClipboardItem({
- 'text/html': new Blob([html], { type: 'text/html' }),
- 'text/plain': new Blob([plain], { type: 'text/plain' }),
- });
- await navigator.clipboard.write([item]);
- }, htmlAll, preBody);
- await bodyBox.click();
- await page.keyboard.press('Control+V');
- await page.waitForTimeout(200);
- pasted = true;
- } catch (e) {
- // クリップボード権限が無い場合のフォールバック
- }
- if (!pasted) {
- // 一括HTML挿入フォールバック
- await insertHTML(page, bodyBox, htmlAll);
- await page.waitForTimeout(100);
- }
+ const bodyTextNow = await bodyBox.evaluate(el => el.innerText || '');
+ console.log('DEBUG_BODY_AFTER_LEN=', bodyTextNow.trim().length);
+ if (bodyTextNow.trim().length < 20) {
+ await page.screenshot({ path: 'debug-body-after.png', fullPage: true });
+ throw new Error('Body seems not inserted (too short). Check selectors.');
+ }
- if(!IS_PUBLIC){
- const saveBtn = page.locator('button:has-text("下書き保存"), [aria-label*="下書き保存"]').first();
- await saveBtn.waitFor({ state: 'visible' });
- if(await saveBtn.isEnabled()) { await saveBtn.click(); await page.locator('text=保存しました').waitFor({ timeout: 4000 }).catch(()=>{}); }
+
+ // ===== 公開フロー(必要なら後で調整)=====
+ const proceed = page.locator('button:has-text("公開に進む")').first();
+ await proceed.waitFor({ state: 'visible' });
+ await proceed.click({ force: true });
+
+ await Promise.race([
+ page.waitForURL(/\/publish/i).catch(() => {}),
+ page.locator('button:has-text("投稿する")').first().waitFor({ state: 'visible' }).catch(() => {})
+ ]);
+
+ const tags = (TAGS || '').split(/[\n,]/).map(s => s.trim()).filter(Boolean);
+ if (tags.length) {
+ let tagInput = page.locator('input[placeholder*="ハッシュタグ"]');
+ if (!(await tagInput.count())) tagInput = page.locator('input[role="combobox"]').first();
+ await tagInput.waitFor({ state: 'visible' });
+ for (const t of tags) {
+ await tagInput.click();
+ await tagInput.fill(t);
+ await page.keyboard.press('Enter');
+ await page.waitForTimeout(120);
+ }
+ }
+
+ const publishBtn = page.locator('button:has-text("投稿する")').first();
+ await publishBtn.waitFor({ state: 'visible' });
+ await publishBtn.click({ force: true });
+
+ await page.waitForTimeout(5000);
await page.screenshot({ path: SS_PATH, fullPage: true });
- console.log('DRAFT_URL=' + page.url());
+
+ console.log('PUBLISHED_URL=' + page.url());
console.log('SCREENSHOT=' + SS_PATH);
- process.exit(0);
- }
-
- const proceed = page.locator('button:has-text("公開に進む")').first();
- await proceed.waitFor({ state: 'visible' });
- for (let i=0;i<20;i++){ if (await proceed.isEnabled()) break; await page.waitForTimeout(100); }
- await proceed.click({ force: true });
-
- await Promise.race([
- page.waitForURL(/\/publish/i).catch(() => {}),
- page.locator('button:has-text("投稿する")').first().waitFor({ state: 'visible' }).catch(() => {}),
- ]);
-
- const tags=(TAGS||'').split(/[\n,]/).map(s=>s.trim()).filter(Boolean);
- if(tags.length){
- let tagInput=page.locator('input[placeholder*="ハッシュタグ"]');
- if(!(await tagInput.count())) tagInput=page.locator('input[role="combobox"]').first();
- await tagInput.waitFor({ state: 'visible' });
- for(const t of tags){ await tagInput.click(); await tagInput.fill(t); await page.keyboard.press('Enter'); await page.waitForTimeout(120); }
+ } finally {
+ try { await context?.tracing?.stop({ path: 'trace.zip' }); } catch {}
+ try { await page?.close(); } catch {}
+ try { await context?.close(); } catch {}
+ try { await browser?.close(); } catch {}
}
-
- const publishBtn = page.locator('button:has-text("投稿する")').first();
- await publishBtn.waitFor({ state: 'visible' });
- for (let i=0;i<20;i++){ if (await publishBtn.isEnabled()) break; await page.waitForTimeout(100); }
- await publishBtn.click({ force: true });
-
- await Promise.race([
- page.waitForURL(u => !/\/publish/i.test(typeof u === 'string' ? u : u.toString()), { timeout: 20000 }).catch(() => {}),
- page.locator('text=投稿しました').first().waitFor({ timeout: 8000 }).catch(() => {}),
- page.waitForTimeout(5000),
- ]);
-
- await page.screenshot({ path: SS_PATH, fullPage: true });
- const finalUrl=page.url();
- console.log('PUBLISHED_URL=' + finalUrl);
- console.log('SCREENSHOT=' + SS_PATH);
- } finally {
- try{ await page?.close(); }catch{}
- try{ await context?.close(); }catch{}
- try{ await browser?.close(); }catch{}
}
+
+ // ここで実行(returnもOK)
+ main().catch(e => {
+ console.error(e);
+ process.exit(1);
+ });
EOF
- node post.mjs | tee post.log
+
+ node post.mjs 2>&1 | tee -a post.log
+
+ mkdir -p note-screenshots || true
+ cp -r /tmp/note-screenshots/* note-screenshots/ 2>/dev/null || true
+
url=$(grep '^PUBLISHED_URL=' post.log | tail -n1 | cut -d'=' -f2-)
draft=$(grep '^DRAFT_URL=' post.log | tail -n1 | cut -d'=' -f2-)
shot=$(grep '^SCREENSHOT=' post.log | tail -n1 | cut -d'=' -f2-)
+
if [ -n "$url" ]; then echo "published_url=$url" >> $GITHUB_OUTPUT; fi
if [ -n "$draft" ]; then echo "draft_url=$draft" >> $GITHUB_OUTPUT; fi
if [ -n "$shot" ]; then echo "screenshot=$shot" >> $GITHUB_OUTPUT; fi
-
- - name: Upload screenshot (if any)
- if: ${{ steps.publish.outputs.screenshot != '' }}
- uses: actions/upload-artifact@v4
- with:
- name: note-screenshot
- path: ${{ steps.publish.outputs.screenshot }}