docs: wordpress import pipeline architecture with dot diagrams

Design doc for ingesting WordPress sites into MPS shops. Covers two
input modes (REST API + WXR XML), 4-phase HTML conversion pipeline,
content/media/comment mapping, CLI interface, competitive analysis,
and future enhancements. Includes rendered dot diagrams for the
architecture overview and HTML conversion detail flow.
This commit is contained in:
russell@unturf.com 2026-03-12 11:32:47 -04:00
parent 8649e6aaae
commit 01bd9086fe
5 changed files with 1274 additions and 0 deletions

View file

@ -0,0 +1,81 @@
// WordPress HTML Conversion Pipeline — Detail
// Render: dot -Tsvg docs/wordpress-import-html.dot -o docs/wordpress-import-html.dot.svg
digraph html_conversion {
rankdir=LR;
node [shape=box, style="rounded,filled", fontname="monospace", fontsize=9];
edge [fontname="monospace", fontsize=8];
raw_html [label="Raw WordPress HTML\n\n<!-- wp:paragraph -->\n<p class=\"has-large-font-size\">\n[gallery ids=\"1,2,3\"]\n<img srcset=\"...\" />", fillcolor="#fce4ec", shape=note];
subgraph cluster_phase1 {
label="Phase 1: Pre-process (BeautifulSoup)";
style=dashed;
color="#5871ad";
strip_gutenberg [label="Strip Gutenberg\ncomments\n<!-- /?wp:\\S+.*?-->", fillcolor="#e8eaf6"];
strip_shortcodes [label="Strip shortcodes\n[vc_*] [et_pb_*]\n[fusion_*]\nkeep inner content", fillcolor="#e8eaf6"];
convert_embeds [label="Convert embeds\n[embed]URL[/embed]\n→ bare URL", fillcolor="#e8eaf6"];
convert_captions [label="Convert captions\n[caption] → <figure>", fillcolor="#e8eaf6"];
strip_srcset [label="Strip srcset/sizes\nStrip WP classes\nUnwrap empty divs", fillcolor="#e8eaf6"];
}
subgraph cluster_phase2 {
label="Phase 2: Image Migration";
style=dashed;
color="#58ad71";
find_imgs [label="Find all <img> src\nand background-image\nURLs", fillcolor="#e8f5e9"];
strip_suffix [label="Strip WP size suffix\n-300x200 → original\nphoto-1024x768.jpg\n→ photo.jpg", fillcolor="#e8f5e9"];
download [label="Download original\nfrom WP server\nrequests.get()\ntimeout=30s", fillcolor="#e8f5e9"];
upload_s3 [label="Upload to S3\n{shop_id}/{product_id}/\ninline/{filename}\nACL=public-read", fillcolor="#e8f5e9"];
rewrite [label="Rewrite src URLs\nold WP URL\n→ CDN URL", fillcolor="#e8f5e9"];
}
subgraph cluster_phase3 {
label="Phase 3: markdownify";
style=dashed;
color="#ad8f58";
converter [label="WPConverter\n(MarkdownConverter\nsubclass)", fillcolor="#fff8e1"];
figures [label="<figure> → ![alt](url)\n<figcaption> → caption", fillcolor="#fff8e1"];
code [label="<pre><code\nclass=\"language-*\">\n→ fenced code block", fillcolor="#fff8e1"];
headings [label="ATX headings (#)\nbody_width=0\nunicode_snob=True", fillcolor="#fff8e1"];
}
subgraph cluster_phase4 {
label="Phase 4: Post-process";
style=dashed;
color="#666666";
collapse [label="Collapse blank lines\n3+ newlines → 2", fillcolor="#f5f5f5"];
metadata [label="Prepend metadata\n**Categories:** ...\n**Tags:** ...\n**Author:** ...", fillcolor="#f5f5f5"];
flag [label="Flag posts with\nunconverted <html>\nfor manual review", fillcolor="#f5f5f5"];
}
output [label="MPS Content\n\nProduct.description\n(raw markdown)\n\nProduct.description_html\n(rendered HTML)", fillcolor="#e8f5e9", shape=note];
// Flow
raw_html -> strip_gutenberg;
strip_gutenberg -> strip_shortcodes;
strip_shortcodes -> convert_embeds;
convert_embeds -> convert_captions;
convert_captions -> strip_srcset;
strip_srcset -> find_imgs;
find_imgs -> strip_suffix;
strip_suffix -> download;
download -> upload_s3;
upload_s3 -> rewrite;
rewrite -> converter;
converter -> figures;
converter -> code;
converter -> headings;
figures -> collapse [style=invis];
code -> collapse [style=invis];
headings -> collapse;
collapse -> metadata;
metadata -> flag;
flag -> output;
}

View file

@ -0,0 +1,305 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Generated by graphviz version 2.43.0 (0)
-->
<!-- Title: html_conversion Pages: 1 -->
<svg width="2657pt" height="211pt"
viewBox="0.00 0.00 2657.00 211.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 207)">
<title>html_conversion</title>
<polygon fill="white" stroke="transparent" points="-4,4 -4,-207 2653,-207 2653,4 -4,4"/>
<g id="clust1" class="cluster">
<title>cluster_phase1</title>
<polygon fill="none" stroke="#5871ad" stroke-dasharray="5,2" points="207,-57 207,-144 938,-144 938,-57 207,-57"/>
<text text-anchor="middle" x="572.5" y="-128.8" font-family="Times,serif" font-size="14.00">Phase 1: Pre&#45;process (BeautifulSoup)</text>
</g>
<g id="clust2" class="cluster">
<title>cluster_phase2</title>
<polygon fill="none" stroke="#58ad71" stroke-dasharray="5,2" points="958,-57 958,-144 1706,-144 1706,-57 958,-57"/>
<text text-anchor="middle" x="1332" y="-128.8" font-family="Times,serif" font-size="14.00">Phase 2: Image Migration</text>
</g>
<g id="clust3" class="cluster">
<title>cluster_phase3</title>
<polygon fill="none" stroke="#ad8f58" stroke-dasharray="5,2" points="1726,-8 1726,-195 2021,-195 2021,-8 1726,-8"/>
<text text-anchor="middle" x="1873.5" y="-179.8" font-family="Times,serif" font-size="14.00">Phase 3: markdownify</text>
</g>
<g id="clust4" class="cluster">
<title>cluster_phase4</title>
<polygon fill="none" stroke="#666666" stroke-dasharray="5,2" points="2041,-57 2041,-144 2478,-144 2478,-57 2041,-57"/>
<text text-anchor="middle" x="2259.5" y="-128.8" font-family="Times,serif" font-size="14.00">Phase 4: Post&#45;process</text>
</g>
<!-- raw_html -->
<g id="node1" class="node">
<title>raw_html</title>
<polygon fill="#fce4ec" stroke="black" points="173,-123 0,-123 0,-55 179,-55 179,-117 173,-123"/>
<polyline fill="none" stroke="black" points="173,-123 173,-117 "/>
<polyline fill="none" stroke="black" points="179,-117 173,-117 "/>
<text text-anchor="middle" x="89.5" y="-111.8" font-family="monospace" font-size="9.00">Raw WordPress HTML</text>
<text text-anchor="middle" x="89.5" y="-91.8" font-family="monospace" font-size="9.00">&lt;!&#45;&#45; wp:paragraph &#45;&#45;&gt;</text>
<text text-anchor="middle" x="89.5" y="-81.8" font-family="monospace" font-size="9.00">&lt;p class=&quot;has&#45;large&#45;font&#45;size&quot;&gt;</text>
<text text-anchor="middle" x="89.5" y="-71.8" font-family="monospace" font-size="9.00">[gallery ids=&quot;1,2,3&quot;]</text>
<text text-anchor="middle" x="89.5" y="-61.8" font-family="monospace" font-size="9.00">&lt;img srcset=&quot;...&quot; /&gt;</text>
</g>
<!-- strip_gutenberg -->
<g id="node2" class="node">
<title>strip_gutenberg</title>
<path fill="#e8eaf6" stroke="black" d="M319,-108C319,-108 227,-108 227,-108 221,-108 215,-102 215,-96 215,-96 215,-82 215,-82 215,-76 221,-70 227,-70 227,-70 319,-70 319,-70 325,-70 331,-76 331,-82 331,-82 331,-96 331,-96 331,-102 325,-108 319,-108"/>
<text text-anchor="middle" x="273" y="-96.8" font-family="monospace" font-size="9.00">Strip Gutenberg</text>
<text text-anchor="middle" x="273" y="-86.8" font-family="monospace" font-size="9.00">comments</text>
<text text-anchor="middle" x="273" y="-76.8" font-family="monospace" font-size="9.00">&lt;!&#45;&#45; /?wp:\S+.*?&#45;&#45;&gt;</text>
</g>
<!-- raw_html&#45;&gt;strip_gutenberg -->
<g id="edge1" class="edge">
<title>raw_html&#45;&gt;strip_gutenberg</title>
<path fill="none" stroke="black" d="M179.12,-89C187.76,-89 196.42,-89 204.8,-89"/>
<polygon fill="black" stroke="black" points="204.85,-92.5 214.85,-89 204.85,-85.5 204.85,-92.5"/>
</g>
<!-- strip_shortcodes -->
<g id="node3" class="node">
<title>strip_shortcodes</title>
<path fill="#e8eaf6" stroke="black" d="M466,-113C466,-113 379,-113 379,-113 373,-113 367,-107 367,-101 367,-101 367,-77 367,-77 367,-71 373,-65 379,-65 379,-65 466,-65 466,-65 472,-65 478,-71 478,-77 478,-77 478,-101 478,-101 478,-107 472,-113 466,-113"/>
<text text-anchor="middle" x="422.5" y="-101.8" font-family="monospace" font-size="9.00">Strip shortcodes</text>
<text text-anchor="middle" x="422.5" y="-91.8" font-family="monospace" font-size="9.00">[vc_*] [et_pb_*]</text>
<text text-anchor="middle" x="422.5" y="-81.8" font-family="monospace" font-size="9.00">[fusion_*]</text>
<text text-anchor="middle" x="422.5" y="-71.8" font-family="monospace" font-size="9.00">keep inner content</text>
</g>
<!-- strip_gutenberg&#45;&gt;strip_shortcodes -->
<g id="edge2" class="edge">
<title>strip_gutenberg&#45;&gt;strip_shortcodes</title>
<path fill="none" stroke="black" d="M331.03,-89C339.33,-89 347.92,-89 356.37,-89"/>
<polygon fill="black" stroke="black" points="356.56,-92.5 366.56,-89 356.56,-85.5 356.56,-92.5"/>
</g>
<!-- convert_embeds -->
<g id="node4" class="node">
<title>convert_embeds</title>
<path fill="#e8eaf6" stroke="black" d="M613,-108C613,-108 526,-108 526,-108 520,-108 514,-102 514,-96 514,-96 514,-82 514,-82 514,-76 520,-70 526,-70 526,-70 613,-70 613,-70 619,-70 625,-76 625,-82 625,-82 625,-96 625,-96 625,-102 619,-108 613,-108"/>
<text text-anchor="middle" x="569.5" y="-96.8" font-family="monospace" font-size="9.00">Convert embeds</text>
<text text-anchor="middle" x="569.5" y="-86.8" font-family="monospace" font-size="9.00">[embed]URL[/embed]</text>
<text text-anchor="middle" x="569.5" y="-76.8" font-family="monospace" font-size="9.00">→ bare URL</text>
</g>
<!-- strip_shortcodes&#45;&gt;convert_embeds -->
<g id="edge3" class="edge">
<title>strip_shortcodes&#45;&gt;convert_embeds</title>
<path fill="none" stroke="black" d="M478.32,-89C486.53,-89 495.06,-89 503.46,-89"/>
<polygon fill="black" stroke="black" points="503.6,-92.5 513.6,-89 503.6,-85.5 503.6,-92.5"/>
</g>
<!-- convert_captions -->
<g id="node5" class="node">
<title>convert_captions</title>
<path fill="#e8eaf6" stroke="black" d="M771,-107C771,-107 673,-107 673,-107 667,-107 661,-101 661,-95 661,-95 661,-83 661,-83 661,-77 667,-71 673,-71 673,-71 771,-71 771,-71 777,-71 783,-77 783,-83 783,-83 783,-95 783,-95 783,-101 777,-107 771,-107"/>
<text text-anchor="middle" x="722" y="-91.8" font-family="monospace" font-size="9.00">Convert captions</text>
<text text-anchor="middle" x="722" y="-81.8" font-family="monospace" font-size="9.00">[caption] → &lt;figure&gt;</text>
</g>
<!-- convert_embeds&#45;&gt;convert_captions -->
<g id="edge4" class="edge">
<title>convert_embeds&#45;&gt;convert_captions</title>
<path fill="none" stroke="black" d="M625.26,-89C633.6,-89 642.3,-89 650.9,-89"/>
<polygon fill="black" stroke="black" points="650.94,-92.5 660.94,-89 650.94,-85.5 650.94,-92.5"/>
</g>
<!-- strip_srcset -->
<g id="node6" class="node">
<title>strip_srcset</title>
<path fill="#e8eaf6" stroke="black" d="M918,-108C918,-108 831,-108 831,-108 825,-108 819,-102 819,-96 819,-96 819,-82 819,-82 819,-76 825,-70 831,-70 831,-70 918,-70 918,-70 924,-70 930,-76 930,-82 930,-82 930,-96 930,-96 930,-102 924,-108 918,-108"/>
<text text-anchor="middle" x="874.5" y="-96.8" font-family="monospace" font-size="9.00">Strip srcset/sizes</text>
<text text-anchor="middle" x="874.5" y="-86.8" font-family="monospace" font-size="9.00">Strip WP classes</text>
<text text-anchor="middle" x="874.5" y="-76.8" font-family="monospace" font-size="9.00">Unwrap empty divs</text>
</g>
<!-- convert_captions&#45;&gt;strip_srcset -->
<g id="edge5" class="edge">
<title>convert_captions&#45;&gt;strip_srcset</title>
<path fill="none" stroke="black" d="M783.35,-89C791.72,-89 800.35,-89 808.79,-89"/>
<polygon fill="black" stroke="black" points="808.98,-92.5 818.98,-89 808.98,-85.5 808.98,-92.5"/>
</g>
<!-- find_imgs -->
<g id="node7" class="node">
<title>find_imgs</title>
<path fill="#e8f5e9" stroke="black" d="M1076,-108C1076,-108 978,-108 978,-108 972,-108 966,-102 966,-96 966,-96 966,-82 966,-82 966,-76 972,-70 978,-70 978,-70 1076,-70 1076,-70 1082,-70 1088,-76 1088,-82 1088,-82 1088,-96 1088,-96 1088,-102 1082,-108 1076,-108"/>
<text text-anchor="middle" x="1027" y="-96.8" font-family="monospace" font-size="9.00">Find all &lt;img&gt; src</text>
<text text-anchor="middle" x="1027" y="-86.8" font-family="monospace" font-size="9.00">and background&#45;image</text>
<text text-anchor="middle" x="1027" y="-76.8" font-family="monospace" font-size="9.00">URLs</text>
</g>
<!-- strip_srcset&#45;&gt;find_imgs -->
<g id="edge6" class="edge">
<title>strip_srcset&#45;&gt;find_imgs</title>
<path fill="none" stroke="black" d="M930.26,-89C938.6,-89 947.3,-89 955.9,-89"/>
<polygon fill="black" stroke="black" points="955.94,-92.5 965.94,-89 955.94,-85.5 955.94,-92.5"/>
</g>
<!-- strip_suffix -->
<g id="node8" class="node">
<title>strip_suffix</title>
<path fill="#e8f5e9" stroke="black" d="M1234,-113C1234,-113 1136,-113 1136,-113 1130,-113 1124,-107 1124,-101 1124,-101 1124,-77 1124,-77 1124,-71 1130,-65 1136,-65 1136,-65 1234,-65 1234,-65 1240,-65 1246,-71 1246,-77 1246,-77 1246,-101 1246,-101 1246,-107 1240,-113 1234,-113"/>
<text text-anchor="middle" x="1185" y="-101.8" font-family="monospace" font-size="9.00">Strip WP size suffix</text>
<text text-anchor="middle" x="1185" y="-91.8" font-family="monospace" font-size="9.00">&#45;300x200 → original</text>
<text text-anchor="middle" x="1185" y="-81.8" font-family="monospace" font-size="9.00">photo&#45;1024x768.jpg</text>
<text text-anchor="middle" x="1185" y="-71.8" font-family="monospace" font-size="9.00">→ photo.jpg</text>
</g>
<!-- find_imgs&#45;&gt;strip_suffix -->
<g id="edge7" class="edge">
<title>find_imgs&#45;&gt;strip_suffix</title>
<path fill="none" stroke="black" d="M1088.31,-89C1096.6,-89 1105.17,-89 1113.63,-89"/>
<polygon fill="black" stroke="black" points="1113.85,-92.5 1123.85,-89 1113.85,-85.5 1113.85,-92.5"/>
</g>
<!-- download -->
<g id="node9" class="node">
<title>download</title>
<path fill="#e8f5e9" stroke="black" d="M1376,-113C1376,-113 1294,-113 1294,-113 1288,-113 1282,-107 1282,-101 1282,-101 1282,-77 1282,-77 1282,-71 1288,-65 1294,-65 1294,-65 1376,-65 1376,-65 1382,-65 1388,-71 1388,-77 1388,-77 1388,-101 1388,-101 1388,-107 1382,-113 1376,-113"/>
<text text-anchor="middle" x="1335" y="-101.8" font-family="monospace" font-size="9.00">Download original</text>
<text text-anchor="middle" x="1335" y="-91.8" font-family="monospace" font-size="9.00">from WP server</text>
<text text-anchor="middle" x="1335" y="-81.8" font-family="monospace" font-size="9.00">requests.get()</text>
<text text-anchor="middle" x="1335" y="-71.8" font-family="monospace" font-size="9.00">timeout=30s</text>
</g>
<!-- strip_suffix&#45;&gt;download -->
<g id="edge8" class="edge">
<title>strip_suffix&#45;&gt;download</title>
<path fill="none" stroke="black" d="M1246.2,-89C1254.62,-89 1263.29,-89 1271.74,-89"/>
<polygon fill="black" stroke="black" points="1271.92,-92.5 1281.92,-89 1271.92,-85.5 1271.92,-92.5"/>
</g>
<!-- upload_s3 -->
<g id="node10" class="node">
<title>upload_s3</title>
<path fill="#e8f5e9" stroke="black" d="M1549,-113C1549,-113 1436,-113 1436,-113 1430,-113 1424,-107 1424,-101 1424,-101 1424,-77 1424,-77 1424,-71 1430,-65 1436,-65 1436,-65 1549,-65 1549,-65 1555,-65 1561,-71 1561,-77 1561,-77 1561,-101 1561,-101 1561,-107 1555,-113 1549,-113"/>
<text text-anchor="middle" x="1492.5" y="-101.8" font-family="monospace" font-size="9.00">Upload to S3</text>
<text text-anchor="middle" x="1492.5" y="-91.8" font-family="monospace" font-size="9.00">{shop_id}/{product_id}/</text>
<text text-anchor="middle" x="1492.5" y="-81.8" font-family="monospace" font-size="9.00">inline/{filename}</text>
<text text-anchor="middle" x="1492.5" y="-71.8" font-family="monospace" font-size="9.00">ACL=public&#45;read</text>
</g>
<!-- download&#45;&gt;upload_s3 -->
<g id="edge9" class="edge">
<title>download&#45;&gt;upload_s3</title>
<path fill="none" stroke="black" d="M1388.21,-89C1396.46,-89 1405.13,-89 1413.81,-89"/>
<polygon fill="black" stroke="black" points="1413.96,-92.5 1423.96,-89 1413.96,-85.5 1413.96,-92.5"/>
</g>
<!-- rewrite -->
<g id="node11" class="node">
<title>rewrite</title>
<path fill="#e8f5e9" stroke="black" d="M1686,-108C1686,-108 1609,-108 1609,-108 1603,-108 1597,-102 1597,-96 1597,-96 1597,-82 1597,-82 1597,-76 1603,-70 1609,-70 1609,-70 1686,-70 1686,-70 1692,-70 1698,-76 1698,-82 1698,-82 1698,-96 1698,-96 1698,-102 1692,-108 1686,-108"/>
<text text-anchor="middle" x="1647.5" y="-96.8" font-family="monospace" font-size="9.00">Rewrite src URLs</text>
<text text-anchor="middle" x="1647.5" y="-86.8" font-family="monospace" font-size="9.00">old WP URL</text>
<text text-anchor="middle" x="1647.5" y="-76.8" font-family="monospace" font-size="9.00">→ CDN URL</text>
</g>
<!-- upload_s3&#45;&gt;rewrite -->
<g id="edge10" class="edge">
<title>upload_s3&#45;&gt;rewrite</title>
<path fill="none" stroke="black" d="M1561.05,-89C1569.59,-89 1578.28,-89 1586.68,-89"/>
<polygon fill="black" stroke="black" points="1586.77,-92.5 1596.77,-89 1586.77,-85.5 1586.77,-92.5"/>
</g>
<!-- converter -->
<g id="node12" class="node">
<title>converter</title>
<path fill="#fff8e1" stroke="black" d="M1833,-108C1833,-108 1746,-108 1746,-108 1740,-108 1734,-102 1734,-96 1734,-96 1734,-82 1734,-82 1734,-76 1740,-70 1746,-70 1746,-70 1833,-70 1833,-70 1839,-70 1845,-76 1845,-82 1845,-82 1845,-96 1845,-96 1845,-102 1839,-108 1833,-108"/>
<text text-anchor="middle" x="1789.5" y="-96.8" font-family="monospace" font-size="9.00">WPConverter</text>
<text text-anchor="middle" x="1789.5" y="-86.8" font-family="monospace" font-size="9.00">(MarkdownConverter</text>
<text text-anchor="middle" x="1789.5" y="-76.8" font-family="monospace" font-size="9.00">subclass)</text>
</g>
<!-- rewrite&#45;&gt;converter -->
<g id="edge11" class="edge">
<title>rewrite&#45;&gt;converter</title>
<path fill="none" stroke="black" d="M1698.25,-89C1706.41,-89 1714.97,-89 1723.43,-89"/>
<polygon fill="black" stroke="black" points="1723.67,-92.5 1733.67,-89 1723.67,-85.5 1723.67,-92.5"/>
</g>
<!-- figures -->
<g id="node13" class="node">
<title>figures</title>
<path fill="#fff8e1" stroke="black" d="M2001,-52C2001,-52 1893,-52 1893,-52 1887,-52 1881,-46 1881,-40 1881,-40 1881,-28 1881,-28 1881,-22 1887,-16 1893,-16 1893,-16 2001,-16 2001,-16 2007,-16 2013,-22 2013,-28 2013,-28 2013,-40 2013,-40 2013,-46 2007,-52 2001,-52"/>
<text text-anchor="middle" x="1947" y="-36.8" font-family="monospace" font-size="9.00">&lt;figure&gt; → ![alt](url)</text>
<text text-anchor="middle" x="1947" y="-26.8" font-family="monospace" font-size="9.00">&lt;figcaption&gt; → caption</text>
</g>
<!-- converter&#45;&gt;figures -->
<g id="edge12" class="edge">
<title>converter&#45;&gt;figures</title>
<path fill="none" stroke="black" d="M1844.45,-69.92C1857.65,-65.25 1871.86,-60.22 1885.35,-55.45"/>
<polygon fill="black" stroke="black" points="1886.76,-58.67 1895.02,-52.03 1884.43,-52.07 1886.76,-58.67"/>
</g>
<!-- code -->
<g id="node14" class="node">
<title>code</title>
<path fill="#fff8e1" stroke="black" d="M1993,-108C1993,-108 1901,-108 1901,-108 1895,-108 1889,-102 1889,-96 1889,-96 1889,-82 1889,-82 1889,-76 1895,-70 1901,-70 1901,-70 1993,-70 1993,-70 1999,-70 2005,-76 2005,-82 2005,-82 2005,-96 2005,-96 2005,-102 1999,-108 1993,-108"/>
<text text-anchor="middle" x="1947" y="-96.8" font-family="monospace" font-size="9.00">&lt;pre&gt;&lt;code</text>
<text text-anchor="middle" x="1947" y="-86.8" font-family="monospace" font-size="9.00">class=&quot;language&#45;*&quot;&gt;</text>
<text text-anchor="middle" x="1947" y="-76.8" font-family="monospace" font-size="9.00">→ fenced code block</text>
</g>
<!-- converter&#45;&gt;code -->
<g id="edge13" class="edge">
<title>converter&#45;&gt;code</title>
<path fill="none" stroke="black" d="M1845.32,-89C1856.1,-89 1867.53,-89 1878.66,-89"/>
<polygon fill="black" stroke="black" points="1878.78,-92.5 1888.78,-89 1878.78,-85.5 1878.78,-92.5"/>
</g>
<!-- headings -->
<g id="node15" class="node">
<title>headings</title>
<path fill="#fff8e1" stroke="black" d="M1988,-164C1988,-164 1906,-164 1906,-164 1900,-164 1894,-158 1894,-152 1894,-152 1894,-138 1894,-138 1894,-132 1900,-126 1906,-126 1906,-126 1988,-126 1988,-126 1994,-126 2000,-132 2000,-138 2000,-138 2000,-152 2000,-152 2000,-158 1994,-164 1988,-164"/>
<text text-anchor="middle" x="1947" y="-152.8" font-family="monospace" font-size="9.00">ATX headings (#)</text>
<text text-anchor="middle" x="1947" y="-142.8" font-family="monospace" font-size="9.00">body_width=0</text>
<text text-anchor="middle" x="1947" y="-132.8" font-family="monospace" font-size="9.00">unicode_snob=True</text>
</g>
<!-- converter&#45;&gt;headings -->
<g id="edge14" class="edge">
<title>converter&#45;&gt;headings</title>
<path fill="none" stroke="black" d="M1843.58,-108.12C1856.68,-112.83 1870.82,-117.92 1884.29,-122.77"/>
<polygon fill="black" stroke="black" points="1883.36,-126.16 1893.95,-126.26 1885.73,-119.57 1883.36,-126.16"/>
</g>
<!-- collapse -->
<g id="node16" class="node">
<title>collapse</title>
<path fill="#f5f5f5" stroke="black" d="M2159,-107C2159,-107 2061,-107 2061,-107 2055,-107 2049,-101 2049,-95 2049,-95 2049,-83 2049,-83 2049,-77 2055,-71 2061,-71 2061,-71 2159,-71 2159,-71 2165,-71 2171,-77 2171,-83 2171,-83 2171,-95 2171,-95 2171,-101 2165,-107 2159,-107"/>
<text text-anchor="middle" x="2110" y="-91.8" font-family="monospace" font-size="9.00">Collapse blank lines</text>
<text text-anchor="middle" x="2110" y="-81.8" font-family="monospace" font-size="9.00">3+ newlines → 2</text>
</g>
<!-- figures&#45;&gt;collapse -->
<!-- code&#45;&gt;collapse -->
<!-- headings&#45;&gt;collapse -->
<g id="edge17" class="edge">
<title>headings&#45;&gt;collapse</title>
<path fill="none" stroke="black" d="M2000.27,-126.82C2015.33,-121.58 2031.89,-115.82 2047.45,-110.41"/>
<polygon fill="black" stroke="black" points="2048.7,-113.68 2056.99,-107.09 2046.4,-107.07 2048.7,-113.68"/>
</g>
<!-- metadata -->
<g id="node17" class="node">
<title>metadata</title>
<path fill="#f5f5f5" stroke="black" d="M2311,-113C2311,-113 2219,-113 2219,-113 2213,-113 2207,-107 2207,-101 2207,-101 2207,-77 2207,-77 2207,-71 2213,-65 2219,-65 2219,-65 2311,-65 2311,-65 2317,-65 2323,-71 2323,-77 2323,-77 2323,-101 2323,-101 2323,-107 2317,-113 2311,-113"/>
<text text-anchor="middle" x="2265" y="-101.8" font-family="monospace" font-size="9.00">Prepend metadata</text>
<text text-anchor="middle" x="2265" y="-91.8" font-family="monospace" font-size="9.00">**Categories:** ...</text>
<text text-anchor="middle" x="2265" y="-81.8" font-family="monospace" font-size="9.00">**Tags:** ...</text>
<text text-anchor="middle" x="2265" y="-71.8" font-family="monospace" font-size="9.00">**Author:** ...</text>
</g>
<!-- collapse&#45;&gt;metadata -->
<g id="edge18" class="edge">
<title>collapse&#45;&gt;metadata</title>
<path fill="none" stroke="black" d="M2171.03,-89C2179.36,-89 2187.97,-89 2196.43,-89"/>
<polygon fill="black" stroke="black" points="2196.64,-92.5 2206.64,-89 2196.64,-85.5 2196.64,-92.5"/>
</g>
<!-- flag -->
<g id="node18" class="node">
<title>flag</title>
<path fill="#f5f5f5" stroke="black" d="M2458,-108C2458,-108 2371,-108 2371,-108 2365,-108 2359,-102 2359,-96 2359,-96 2359,-82 2359,-82 2359,-76 2365,-70 2371,-70 2371,-70 2458,-70 2458,-70 2464,-70 2470,-76 2470,-82 2470,-82 2470,-96 2470,-96 2470,-102 2464,-108 2458,-108"/>
<text text-anchor="middle" x="2414.5" y="-96.8" font-family="monospace" font-size="9.00">Flag posts with</text>
<text text-anchor="middle" x="2414.5" y="-86.8" font-family="monospace" font-size="9.00">unconverted &lt;html&gt;</text>
<text text-anchor="middle" x="2414.5" y="-76.8" font-family="monospace" font-size="9.00">for manual review</text>
</g>
<!-- metadata&#45;&gt;flag -->
<g id="edge19" class="edge">
<title>metadata&#45;&gt;flag</title>
<path fill="none" stroke="black" d="M2323.03,-89C2331.33,-89 2339.92,-89 2348.37,-89"/>
<polygon fill="black" stroke="black" points="2348.56,-92.5 2358.56,-89 2348.56,-85.5 2348.56,-92.5"/>
</g>
<!-- output -->
<g id="node19" class="node">
<title>output</title>
<polygon fill="#e8f5e9" stroke="black" points="2643,-128 2506,-128 2506,-50 2649,-50 2649,-122 2643,-128"/>
<polyline fill="none" stroke="black" points="2643,-128 2643,-122 "/>
<polyline fill="none" stroke="black" points="2649,-122 2643,-122 "/>
<text text-anchor="middle" x="2577.5" y="-116.8" font-family="monospace" font-size="9.00">MPS Content</text>
<text text-anchor="middle" x="2577.5" y="-96.8" font-family="monospace" font-size="9.00">Product.description</text>
<text text-anchor="middle" x="2577.5" y="-86.8" font-family="monospace" font-size="9.00">(raw markdown)</text>
<text text-anchor="middle" x="2577.5" y="-66.8" font-family="monospace" font-size="9.00">Product.description_html</text>
<text text-anchor="middle" x="2577.5" y="-56.8" font-family="monospace" font-size="9.00">(rendered HTML)</text>
</g>
<!-- flag&#45;&gt;output -->
<g id="edge20" class="edge">
<title>flag&#45;&gt;output</title>
<path fill="none" stroke="black" d="M2470.01,-89C2478.27,-89 2486.94,-89 2495.62,-89"/>
<polygon fill="black" stroke="black" points="2495.78,-92.5 2505.78,-89 2495.78,-85.5 2495.78,-92.5"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 21 KiB

View file

@ -0,0 +1,82 @@
// WordPress Import Pipeline — Overview
// Render: dot -Tsvg docs/wordpress-import-pipeline.dot -o docs/wordpress-import-pipeline.dot.svg
digraph wordpress_import {
rankdir=TB;
node [shape=box, style="rounded,filled", fontname="monospace", fontsize=10];
edge [fontname="monospace", fontsize=9];
subgraph cluster_source {
label="WordPress Source";
style=dashed;
color="#ad5871";
rest_api [label="REST API\n/wp-json/wp/v2/*\n(live site, no auth)", fillcolor="#fce4ec"];
wxr_file [label="WXR XML Export\n(offline file)\nTools > Export", fillcolor="#fce4ec"];
}
subgraph cluster_parser {
label="Source Parser";
style=dashed;
color="#5871ad";
api_parser [label="REST API Client\nrequests + pagination\nper_page=100, ?_embed", fillcolor="#e8eaf6"];
wxr_parser [label="WXR Parser\nxml.etree.ElementTree\nnamespace-aware", fillcolor="#e8eaf6"];
normalize [label="Normalize\nUnified post dict\n(title, html, date,\nmedia_urls, comments)", fillcolor="#e8eaf6"];
}
subgraph cluster_convert {
label="HTML Conversion Pipeline";
style=dashed;
color="#58ad71";
preprocess [label="Phase 1: Pre-process\nBeautifulSoup\n- strip Gutenberg comments\n- strip shortcodes\n- strip srcset/sizes\n- strip WP classes", fillcolor="#e8f5e9"];
images [label="Phase 2: Image Migration\n- strip -WxH suffixes\n- download originals\n- upload to S3\n- rewrite URLs to CDN", fillcolor="#e8f5e9"];
markdown [label="Phase 3: Markdown\nmarkdownify (MIT)\n- custom WPConverter\n- figures, code blocks\n- ATX headings", fillcolor="#e8f5e9"];
postprocess [label="Phase 4: Post-process\n- collapse blank lines\n- prepend metadata\n- flag unconverted HTML", fillcolor="#e8f5e9"];
}
subgraph cluster_mps {
label="MPS (Target)";
style=dashed;
color="#ad8f58";
create_product [label="Create Product\nis_sellable=False\nset title, description,\ntimestamps, visibility", fillcolor="#fff8e1"];
upload_thumb [label="Upload Thumbnail\nput_object to S3\nset_file_metadata\nACL=public-read", fillcolor="#fff8e1"];
create_comments [label="Create Comments\nthreaded via parent_id\nmarkdown + sentiment", fillcolor="#fff8e1"];
reforge [label="Reforge Discovery Ring\nJaccard similarity\nnearest-neighbor ordering", fillcolor="#fff8e1"];
}
subgraph cluster_storage {
label="Storage";
style=dashed;
color="#666666";
sqlite [label="SQLite\nproducts + comments", fillcolor="#f5f5f5"];
s3 [label="S3 / Spaces\nthumbnails + inline images\nBYOB-aware", fillcolor="#f5f5f5"];
cdn [label="CDN\npublic URLs\n?ts= cache bust", fillcolor="#f5f5f5"];
}
// Edges
rest_api -> api_parser;
wxr_file -> wxr_parser;
api_parser -> normalize;
wxr_parser -> normalize;
normalize -> preprocess;
preprocess -> images;
images -> markdown;
markdown -> postprocess;
postprocess -> create_product;
normalize -> create_comments [label="comments\n(optional)", style=dashed];
normalize -> upload_thumb [label="featured\nimage URL", style=dashed];
create_product -> sqlite;
create_comments -> sqlite;
upload_thumb -> s3;
images -> s3 [label="inline\nimages"];
s3 -> cdn;
create_product -> reforge [label="after all\nproducts"];
reforge -> sqlite;
}

View file

@ -0,0 +1,292 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Generated by graphviz version 2.43.0 (0)
-->
<!-- Title: wordpress_import Pages: 1 -->
<svg width="560pt" height="1135pt"
viewBox="0.00 0.00 560.00 1135.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 1131)">
<title>wordpress_import</title>
<polygon fill="white" stroke="transparent" points="-4,4 -4,-1131 556,-1131 556,4 -4,4"/>
<g id="clust1" class="cluster">
<title>cluster_source</title>
<polygon fill="none" stroke="#ad5871" stroke-dasharray="5,2" points="103,-1039 103,-1119 376,-1119 376,-1039 103,-1039"/>
<text text-anchor="middle" x="239.5" y="-1103.8" font-family="Times,serif" font-size="14.00">WordPress Source</text>
</g>
<g id="clust2" class="cluster">
<title>cluster_parser</title>
<polygon fill="none" stroke="#5871ad" stroke-dasharray="5,2" points="85,-851 85,-1020 406,-1020 406,-851 85,-851"/>
<text text-anchor="middle" x="245.5" y="-1004.8" font-family="Times,serif" font-size="14.00">Source Parser</text>
</g>
<g id="clust3" class="cluster">
<title>cluster_convert</title>
<polygon fill="none" stroke="#58ad71" stroke-dasharray="5,2" points="8,-411 8,-832 216,-832 216,-411 8,-411"/>
<text text-anchor="middle" x="112" y="-816.8" font-family="Times,serif" font-size="14.00">HTML Conversion Pipeline</text>
</g>
<g id="clust4" class="cluster">
<title>cluster_mps</title>
<polygon fill="none" stroke="#ad8f58" stroke-dasharray="5,2" points="68,-185 68,-373 544,-373 544,-185 68,-185"/>
<text text-anchor="middle" x="306" y="-357.8" font-family="Times,serif" font-size="14.00">MPS (Target)</text>
</g>
<g id="clust5" class="cluster">
<title>cluster_storage</title>
<polygon fill="none" stroke="#666666" stroke-dasharray="5,2" points="41,-8 41,-166 380,-166 380,-8 41,-8"/>
<text text-anchor="middle" x="210.5" y="-150.8" font-family="Times,serif" font-size="14.00">Storage</text>
</g>
<!-- rest_api -->
<g id="node1" class="node">
<title>rest_api</title>
<path fill="#fce4ec" stroke="black" d="M236.5,-1088C236.5,-1088 123.5,-1088 123.5,-1088 117.5,-1088 111.5,-1082 111.5,-1076 111.5,-1076 111.5,-1059 111.5,-1059 111.5,-1053 117.5,-1047 123.5,-1047 123.5,-1047 236.5,-1047 236.5,-1047 242.5,-1047 248.5,-1053 248.5,-1059 248.5,-1059 248.5,-1076 248.5,-1076 248.5,-1082 242.5,-1088 236.5,-1088"/>
<text text-anchor="middle" x="180" y="-1076" font-family="monospace" font-size="10.00">REST API</text>
<text text-anchor="middle" x="180" y="-1065" font-family="monospace" font-size="10.00">/wp&#45;json/wp/v2/*</text>
<text text-anchor="middle" x="180" y="-1054" font-family="monospace" font-size="10.00">(live site, no auth)</text>
</g>
<!-- api_parser -->
<g id="node3" class="node">
<title>api_parser</title>
<path fill="#e8eaf6" stroke="black" d="M224.5,-989C224.5,-989 105.5,-989 105.5,-989 99.5,-989 93.5,-983 93.5,-977 93.5,-977 93.5,-960 93.5,-960 93.5,-954 99.5,-948 105.5,-948 105.5,-948 224.5,-948 224.5,-948 230.5,-948 236.5,-954 236.5,-960 236.5,-960 236.5,-977 236.5,-977 236.5,-983 230.5,-989 224.5,-989"/>
<text text-anchor="middle" x="165" y="-977" font-family="monospace" font-size="10.00">REST API Client</text>
<text text-anchor="middle" x="165" y="-966" font-family="monospace" font-size="10.00">requests + pagination</text>
<text text-anchor="middle" x="165" y="-955" font-family="monospace" font-size="10.00">per_page=100, ?_embed</text>
</g>
<!-- rest_api&#45;&gt;api_parser -->
<g id="edge1" class="edge">
<title>rest_api&#45;&gt;api_parser</title>
<path fill="none" stroke="black" d="M176.96,-1046.87C174.85,-1033.2 171.99,-1014.71 169.6,-999.24"/>
<polygon fill="black" stroke="black" points="173.02,-998.45 168.03,-989.1 166.1,-999.52 173.02,-998.45"/>
</g>
<!-- wxr_file -->
<g id="node2" class="node">
<title>wxr_file</title>
<path fill="#fce4ec" stroke="black" d="M355.5,-1088C355.5,-1088 278.5,-1088 278.5,-1088 272.5,-1088 266.5,-1082 266.5,-1076 266.5,-1076 266.5,-1059 266.5,-1059 266.5,-1053 272.5,-1047 278.5,-1047 278.5,-1047 355.5,-1047 355.5,-1047 361.5,-1047 367.5,-1053 367.5,-1059 367.5,-1059 367.5,-1076 367.5,-1076 367.5,-1082 361.5,-1088 355.5,-1088"/>
<text text-anchor="middle" x="317" y="-1076" font-family="monospace" font-size="10.00">WXR XML Export</text>
<text text-anchor="middle" x="317" y="-1065" font-family="monospace" font-size="10.00">(offline file)</text>
<text text-anchor="middle" x="317" y="-1054" font-family="monospace" font-size="10.00">Tools &gt; Export</text>
</g>
<!-- wxr_parser -->
<g id="node4" class="node">
<title>wxr_parser</title>
<path fill="#e8eaf6" stroke="black" d="M385.5,-989C385.5,-989 266.5,-989 266.5,-989 260.5,-989 254.5,-983 254.5,-977 254.5,-977 254.5,-960 254.5,-960 254.5,-954 260.5,-948 266.5,-948 266.5,-948 385.5,-948 385.5,-948 391.5,-948 397.5,-954 397.5,-960 397.5,-960 397.5,-977 397.5,-977 397.5,-983 391.5,-989 385.5,-989"/>
<text text-anchor="middle" x="326" y="-977" font-family="monospace" font-size="10.00">WXR Parser</text>
<text text-anchor="middle" x="326" y="-966" font-family="monospace" font-size="10.00">xml.etree.ElementTree</text>
<text text-anchor="middle" x="326" y="-955" font-family="monospace" font-size="10.00">namespace&#45;aware</text>
</g>
<!-- wxr_file&#45;&gt;wxr_parser -->
<g id="edge2" class="edge">
<title>wxr_file&#45;&gt;wxr_parser</title>
<path fill="none" stroke="black" d="M318.82,-1046.87C320.09,-1033.2 321.81,-1014.71 323.24,-999.24"/>
<polygon fill="black" stroke="black" points="326.74,-999.38 324.18,-989.1 319.77,-998.73 326.74,-999.38"/>
</g>
<!-- normalize -->
<g id="node5" class="node">
<title>normalize</title>
<path fill="#e8eaf6" stroke="black" d="M301.5,-911C301.5,-911 182.5,-911 182.5,-911 176.5,-911 170.5,-905 170.5,-899 170.5,-899 170.5,-871 170.5,-871 170.5,-865 176.5,-859 182.5,-859 182.5,-859 301.5,-859 301.5,-859 307.5,-859 313.5,-865 313.5,-871 313.5,-871 313.5,-899 313.5,-899 313.5,-905 307.5,-911 301.5,-911"/>
<text text-anchor="middle" x="242" y="-899" font-family="monospace" font-size="10.00">Normalize</text>
<text text-anchor="middle" x="242" y="-888" font-family="monospace" font-size="10.00">Unified post dict</text>
<text text-anchor="middle" x="242" y="-877" font-family="monospace" font-size="10.00">(title, html, date,</text>
<text text-anchor="middle" x="242" y="-866" font-family="monospace" font-size="10.00">media_urls, comments)</text>
</g>
<!-- api_parser&#45;&gt;normalize -->
<g id="edge3" class="edge">
<title>api_parser&#45;&gt;normalize</title>
<path fill="none" stroke="black" d="M183.64,-947.77C191.94,-938.99 201.93,-928.41 211.29,-918.51"/>
<polygon fill="black" stroke="black" points="213.96,-920.78 218.28,-911.11 208.87,-915.97 213.96,-920.78"/>
</g>
<!-- wxr_parser&#45;&gt;normalize -->
<g id="edge4" class="edge">
<title>wxr_parser&#45;&gt;normalize</title>
<path fill="none" stroke="black" d="M305.67,-947.77C296.52,-938.9 285.49,-928.2 275.2,-918.21"/>
<polygon fill="black" stroke="black" points="277.49,-915.56 267.88,-911.11 272.62,-920.58 277.49,-915.56"/>
</g>
<!-- preprocess -->
<g id="node6" class="node">
<title>preprocess</title>
<path fill="#e8f5e9" stroke="black" d="M195.5,-801C195.5,-801 46.5,-801 46.5,-801 40.5,-801 34.5,-795 34.5,-789 34.5,-789 34.5,-739 34.5,-739 34.5,-733 40.5,-727 46.5,-727 46.5,-727 195.5,-727 195.5,-727 201.5,-727 207.5,-733 207.5,-739 207.5,-739 207.5,-789 207.5,-789 207.5,-795 201.5,-801 195.5,-801"/>
<text text-anchor="middle" x="121" y="-789" font-family="monospace" font-size="10.00">Phase 1: Pre&#45;process</text>
<text text-anchor="middle" x="121" y="-778" font-family="monospace" font-size="10.00">BeautifulSoup</text>
<text text-anchor="middle" x="121" y="-767" font-family="monospace" font-size="10.00">&#45; strip Gutenberg comments</text>
<text text-anchor="middle" x="121" y="-756" font-family="monospace" font-size="10.00">&#45; strip shortcodes</text>
<text text-anchor="middle" x="121" y="-745" font-family="monospace" font-size="10.00">&#45; strip srcset/sizes</text>
<text text-anchor="middle" x="121" y="-734" font-family="monospace" font-size="10.00">&#45; strip WP classes</text>
</g>
<!-- normalize&#45;&gt;preprocess -->
<g id="edge5" class="edge">
<title>normalize&#45;&gt;preprocess</title>
<path fill="none" stroke="black" d="M216.34,-858.76C201.41,-844.08 182.18,-825.17 164.91,-808.18"/>
<polygon fill="black" stroke="black" points="167.23,-805.55 157.64,-801.04 162.32,-810.54 167.23,-805.55"/>
</g>
<!-- upload_thumb -->
<g id="node11" class="node">
<title>upload_thumb</title>
<path fill="#fff8e1" stroke="black" d="M183.5,-342C183.5,-342 88.5,-342 88.5,-342 82.5,-342 76.5,-336 76.5,-330 76.5,-330 76.5,-302 76.5,-302 76.5,-296 82.5,-290 88.5,-290 88.5,-290 183.5,-290 183.5,-290 189.5,-290 195.5,-296 195.5,-302 195.5,-302 195.5,-330 195.5,-330 195.5,-336 189.5,-342 183.5,-342"/>
<text text-anchor="middle" x="136" y="-330" font-family="monospace" font-size="10.00">Upload Thumbnail</text>
<text text-anchor="middle" x="136" y="-319" font-family="monospace" font-size="10.00">put_object to S3</text>
<text text-anchor="middle" x="136" y="-308" font-family="monospace" font-size="10.00">set_file_metadata</text>
<text text-anchor="middle" x="136" y="-297" font-family="monospace" font-size="10.00">ACL=public&#45;read</text>
</g>
<!-- normalize&#45;&gt;upload_thumb -->
<g id="edge11" class="edge">
<title>normalize&#45;&gt;upload_thumb</title>
<path fill="none" stroke="black" stroke-dasharray="5,2" d="M242,-858.93C242,-834.84 242,-797.44 242,-765 242,-765 242,-765 242,-444 242,-421.5 202.27,-378.95 171.46,-349.33"/>
<polygon fill="black" stroke="black" points="173.51,-346.45 163.85,-342.1 168.69,-351.53 173.51,-346.45"/>
<text text-anchor="middle" x="266" y="-601.8" font-family="monospace" font-size="9.00">featured</text>
<text text-anchor="middle" x="266" y="-591.8" font-family="monospace" font-size="9.00">image URL</text>
</g>
<!-- create_comments -->
<g id="node12" class="node">
<title>create_comments</title>
<path fill="#fff8e1" stroke="black" d="M350.5,-336.5C350.5,-336.5 225.5,-336.5 225.5,-336.5 219.5,-336.5 213.5,-330.5 213.5,-324.5 213.5,-324.5 213.5,-307.5 213.5,-307.5 213.5,-301.5 219.5,-295.5 225.5,-295.5 225.5,-295.5 350.5,-295.5 350.5,-295.5 356.5,-295.5 362.5,-301.5 362.5,-307.5 362.5,-307.5 362.5,-324.5 362.5,-324.5 362.5,-330.5 356.5,-336.5 350.5,-336.5"/>
<text text-anchor="middle" x="288" y="-324.5" font-family="monospace" font-size="10.00">Create Comments</text>
<text text-anchor="middle" x="288" y="-313.5" font-family="monospace" font-size="10.00">threaded via parent_id</text>
<text text-anchor="middle" x="288" y="-302.5" font-family="monospace" font-size="10.00">markdown + sentiment</text>
</g>
<!-- normalize&#45;&gt;create_comments -->
<g id="edge10" class="edge">
<title>normalize&#45;&gt;create_comments</title>
<path fill="none" stroke="black" stroke-dasharray="5,2" d="M268.03,-858.7C288.3,-836.2 313,-801.33 313,-765 313,-765 313,-765 313,-444 313,-410.03 304.28,-371.89 297.14,-346.36"/>
<polygon fill="black" stroke="black" points="300.45,-345.2 294.31,-336.56 293.73,-347.14 300.45,-345.2"/>
<text text-anchor="middle" x="339.5" y="-601.8" font-family="monospace" font-size="9.00">comments</text>
<text text-anchor="middle" x="339.5" y="-591.8" font-family="monospace" font-size="9.00">(optional)</text>
</g>
<!-- images -->
<g id="node7" class="node">
<title>images</title>
<path fill="#e8f5e9" stroke="black" d="M189.5,-690C189.5,-690 52.5,-690 52.5,-690 46.5,-690 40.5,-684 40.5,-678 40.5,-678 40.5,-639 40.5,-639 40.5,-633 46.5,-627 52.5,-627 52.5,-627 189.5,-627 189.5,-627 195.5,-627 201.5,-633 201.5,-639 201.5,-639 201.5,-678 201.5,-678 201.5,-684 195.5,-690 189.5,-690"/>
<text text-anchor="middle" x="121" y="-678" font-family="monospace" font-size="10.00">Phase 2: Image Migration</text>
<text text-anchor="middle" x="121" y="-667" font-family="monospace" font-size="10.00">&#45; strip &#45;WxH suffixes</text>
<text text-anchor="middle" x="121" y="-656" font-family="monospace" font-size="10.00">&#45; download originals</text>
<text text-anchor="middle" x="121" y="-645" font-family="monospace" font-size="10.00">&#45; upload to S3</text>
<text text-anchor="middle" x="121" y="-634" font-family="monospace" font-size="10.00">&#45; rewrite URLs to CDN</text>
</g>
<!-- preprocess&#45;&gt;images -->
<g id="edge6" class="edge">
<title>preprocess&#45;&gt;images</title>
<path fill="none" stroke="black" d="M121,-726.8C121,-718.17 121,-708.9 121,-700.09"/>
<polygon fill="black" stroke="black" points="124.5,-700.02 121,-690.02 117.5,-700.02 124.5,-700.02"/>
</g>
<!-- markdown -->
<g id="node8" class="node">
<title>markdown</title>
<path fill="#e8f5e9" stroke="black" d="M190.5,-571C190.5,-571 65.5,-571 65.5,-571 59.5,-571 53.5,-565 53.5,-559 53.5,-559 53.5,-520 53.5,-520 53.5,-514 59.5,-508 65.5,-508 65.5,-508 190.5,-508 190.5,-508 196.5,-508 202.5,-514 202.5,-520 202.5,-520 202.5,-559 202.5,-559 202.5,-565 196.5,-571 190.5,-571"/>
<text text-anchor="middle" x="128" y="-559" font-family="monospace" font-size="10.00">Phase 3: Markdown</text>
<text text-anchor="middle" x="128" y="-548" font-family="monospace" font-size="10.00">markdownify (MIT)</text>
<text text-anchor="middle" x="128" y="-537" font-family="monospace" font-size="10.00">&#45; custom WPConverter</text>
<text text-anchor="middle" x="128" y="-526" font-family="monospace" font-size="10.00">&#45; figures, code blocks</text>
<text text-anchor="middle" x="128" y="-515" font-family="monospace" font-size="10.00">&#45; ATX headings</text>
</g>
<!-- images&#45;&gt;markdown -->
<g id="edge7" class="edge">
<title>images&#45;&gt;markdown</title>
<path fill="none" stroke="black" d="M122.84,-626.76C123.68,-612.79 124.68,-596.06 125.57,-581.08"/>
<polygon fill="black" stroke="black" points="129.07,-581.23 126.17,-571.04 122.08,-580.81 129.07,-581.23"/>
</g>
<!-- s3 -->
<g id="node15" class="node">
<title>s3</title>
<path fill="#f5f5f5" stroke="black" d="M210.5,-135C210.5,-135 61.5,-135 61.5,-135 55.5,-135 49.5,-129 49.5,-123 49.5,-123 49.5,-106 49.5,-106 49.5,-100 55.5,-94 61.5,-94 61.5,-94 210.5,-94 210.5,-94 216.5,-94 222.5,-100 222.5,-106 222.5,-106 222.5,-123 222.5,-123 222.5,-129 216.5,-135 210.5,-135"/>
<text text-anchor="middle" x="136" y="-123" font-family="monospace" font-size="10.00">S3 / Spaces</text>
<text text-anchor="middle" x="136" y="-112" font-family="monospace" font-size="10.00">thumbnails + inline images</text>
<text text-anchor="middle" x="136" y="-101" font-family="monospace" font-size="10.00">BYOB&#45;aware</text>
</g>
<!-- images&#45;&gt;s3 -->
<g id="edge15" class="edge">
<title>images&#45;&gt;s3</title>
<path fill="none" stroke="black" d="M75.62,-626.97C50.38,-606.03 24,-575.72 24,-540.5 24,-540.5 24,-540.5 24,-212.5 24,-180 51.27,-155.96 78.9,-139.94"/>
<polygon fill="black" stroke="black" points="80.78,-142.9 87.85,-135.02 77.4,-136.77 80.78,-142.9"/>
<text text-anchor="middle" x="40" y="-393.8" font-family="monospace" font-size="9.00">inline</text>
<text text-anchor="middle" x="40" y="-383.8" font-family="monospace" font-size="9.00">images</text>
</g>
<!-- postprocess -->
<g id="node9" class="node">
<title>postprocess</title>
<path fill="#e8f5e9" stroke="black" d="M195.5,-471C195.5,-471 64.5,-471 64.5,-471 58.5,-471 52.5,-465 52.5,-459 52.5,-459 52.5,-431 52.5,-431 52.5,-425 58.5,-419 64.5,-419 64.5,-419 195.5,-419 195.5,-419 201.5,-419 207.5,-425 207.5,-431 207.5,-431 207.5,-459 207.5,-459 207.5,-465 201.5,-471 195.5,-471"/>
<text text-anchor="middle" x="130" y="-459" font-family="monospace" font-size="10.00">Phase 4: Post&#45;process</text>
<text text-anchor="middle" x="130" y="-448" font-family="monospace" font-size="10.00">&#45; collapse blank lines</text>
<text text-anchor="middle" x="130" y="-437" font-family="monospace" font-size="10.00">&#45; prepend metadata</text>
<text text-anchor="middle" x="130" y="-426" font-family="monospace" font-size="10.00">&#45; flag unconverted HTML</text>
</g>
<!-- markdown&#45;&gt;postprocess -->
<g id="edge8" class="edge">
<title>markdown&#45;&gt;postprocess</title>
<path fill="none" stroke="black" d="M128.66,-507.96C128.84,-499.5 129.04,-490.25 129.23,-481.54"/>
<polygon fill="black" stroke="black" points="132.74,-481.34 129.45,-471.27 125.74,-481.19 132.74,-481.34"/>
</g>
<!-- create_product -->
<g id="node10" class="node">
<title>create_product</title>
<path fill="#fff8e1" stroke="black" d="M523.5,-342C523.5,-342 392.5,-342 392.5,-342 386.5,-342 380.5,-336 380.5,-330 380.5,-330 380.5,-302 380.5,-302 380.5,-296 386.5,-290 392.5,-290 392.5,-290 523.5,-290 523.5,-290 529.5,-290 535.5,-296 535.5,-302 535.5,-302 535.5,-330 535.5,-330 535.5,-336 529.5,-342 523.5,-342"/>
<text text-anchor="middle" x="458" y="-330" font-family="monospace" font-size="10.00">Create Product</text>
<text text-anchor="middle" x="458" y="-319" font-family="monospace" font-size="10.00">is_sellable=False</text>
<text text-anchor="middle" x="458" y="-308" font-family="monospace" font-size="10.00">set title, description,</text>
<text text-anchor="middle" x="458" y="-297" font-family="monospace" font-size="10.00">timestamps, visibility</text>
</g>
<!-- postprocess&#45;&gt;create_product -->
<g id="edge9" class="edge">
<title>postprocess&#45;&gt;create_product</title>
<path fill="none" stroke="black" d="M207.55,-427.37C256.08,-415.49 319.2,-397.3 372,-373 387,-366.1 402.42,-356.85 415.98,-347.86"/>
<polygon fill="black" stroke="black" points="418.28,-350.53 424.6,-342.02 414.35,-344.73 418.28,-350.53"/>
</g>
<!-- reforge -->
<g id="node13" class="node">
<title>reforge</title>
<path fill="#fff8e1" stroke="black" d="M444.5,-234C444.5,-234 301.5,-234 301.5,-234 295.5,-234 289.5,-228 289.5,-222 289.5,-222 289.5,-205 289.5,-205 289.5,-199 295.5,-193 301.5,-193 301.5,-193 444.5,-193 444.5,-193 450.5,-193 456.5,-199 456.5,-205 456.5,-205 456.5,-222 456.5,-222 456.5,-228 450.5,-234 444.5,-234"/>
<text text-anchor="middle" x="373" y="-222" font-family="monospace" font-size="10.00">Reforge Discovery Ring</text>
<text text-anchor="middle" x="373" y="-211" font-family="monospace" font-size="10.00">Jaccard similarity</text>
<text text-anchor="middle" x="373" y="-200" font-family="monospace" font-size="10.00">nearest&#45;neighbor ordering</text>
</g>
<!-- create_product&#45;&gt;reforge -->
<g id="edge17" class="edge">
<title>create_product&#45;&gt;reforge</title>
<path fill="none" stroke="black" d="M436.77,-289.9C424.49,-275.38 409.02,-257.08 396.39,-242.15"/>
<polygon fill="black" stroke="black" points="398.83,-239.62 389.7,-234.25 393.49,-244.14 398.83,-239.62"/>
<text text-anchor="middle" x="444" y="-264.8" font-family="monospace" font-size="9.00">after all</text>
<text text-anchor="middle" x="444" y="-254.8" font-family="monospace" font-size="9.00">products</text>
</g>
<!-- sqlite -->
<g id="node14" class="node">
<title>sqlite</title>
<path fill="#f5f5f5" stroke="black" d="M359.5,-132.5C359.5,-132.5 252.5,-132.5 252.5,-132.5 246.5,-132.5 240.5,-126.5 240.5,-120.5 240.5,-120.5 240.5,-108.5 240.5,-108.5 240.5,-102.5 246.5,-96.5 252.5,-96.5 252.5,-96.5 359.5,-96.5 359.5,-96.5 365.5,-96.5 371.5,-102.5 371.5,-108.5 371.5,-108.5 371.5,-120.5 371.5,-120.5 371.5,-126.5 365.5,-132.5 359.5,-132.5"/>
<text text-anchor="middle" x="306" y="-117.5" font-family="monospace" font-size="10.00">SQLite</text>
<text text-anchor="middle" x="306" y="-106.5" font-family="monospace" font-size="10.00">products + comments</text>
</g>
<!-- create_product&#45;&gt;sqlite -->
<g id="edge12" class="edge">
<title>create_product&#45;&gt;sqlite</title>
<path fill="none" stroke="black" d="M468.16,-289.75C477.7,-261.56 487.58,-215.85 466,-185 446.34,-156.89 412.75,-140.04 381.4,-130"/>
<polygon fill="black" stroke="black" points="382.35,-126.63 371.77,-127.1 380.33,-133.34 382.35,-126.63"/>
</g>
<!-- upload_thumb&#45;&gt;s3 -->
<g id="edge14" class="edge">
<title>upload_thumb&#45;&gt;s3</title>
<path fill="none" stroke="black" d="M136,-289.73C136,-252.85 136,-184.41 136,-145.18"/>
<polygon fill="black" stroke="black" points="139.5,-145.17 136,-135.17 132.5,-145.17 139.5,-145.17"/>
</g>
<!-- create_comments&#45;&gt;sqlite -->
<g id="edge13" class="edge">
<title>create_comments&#45;&gt;sqlite</title>
<path fill="none" stroke="black" d="M284.23,-295.46C279.91,-269.88 274.24,-223.74 281,-185 283.54,-170.45 288.85,-154.92 293.93,-142.24"/>
<polygon fill="black" stroke="black" points="297.27,-143.33 297.9,-132.75 290.81,-140.63 297.27,-143.33"/>
</g>
<!-- reforge&#45;&gt;sqlite -->
<g id="edge18" class="edge">
<title>reforge&#45;&gt;sqlite</title>
<path fill="none" stroke="black" d="M359.44,-192.87C349.12,-177.92 334.82,-157.23 323.61,-141"/>
<polygon fill="black" stroke="black" points="326.31,-138.75 317.75,-132.51 320.55,-142.72 326.31,-138.75"/>
</g>
<!-- cdn -->
<g id="node16" class="node">
<title>cdn</title>
<path fill="#f5f5f5" stroke="black" d="M177.5,-57C177.5,-57 94.5,-57 94.5,-57 88.5,-57 82.5,-51 82.5,-45 82.5,-45 82.5,-28 82.5,-28 82.5,-22 88.5,-16 94.5,-16 94.5,-16 177.5,-16 177.5,-16 183.5,-16 189.5,-22 189.5,-28 189.5,-28 189.5,-45 189.5,-45 189.5,-51 183.5,-57 177.5,-57"/>
<text text-anchor="middle" x="136" y="-45" font-family="monospace" font-size="10.00">CDN</text>
<text text-anchor="middle" x="136" y="-34" font-family="monospace" font-size="10.00">public URLs</text>
<text text-anchor="middle" x="136" y="-23" font-family="monospace" font-size="10.00">?ts= cache bust</text>
</g>
<!-- s3&#45;&gt;cdn -->
<g id="edge16" class="edge">
<title>s3&#45;&gt;cdn</title>
<path fill="none" stroke="black" d="M136,-93.93C136,-85.77 136,-76.12 136,-67.13"/>
<polygon fill="black" stroke="black" points="139.5,-67.01 136,-57.01 132.5,-67.01 139.5,-67.01"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 21 KiB

View file

@ -0,0 +1,514 @@
# WordPress Import Pipeline
Import WordPress sites into MPS shops. Converts posts/pages into MPS content
items (`is_sellable=False`), downloads and re-hosts media to S3, and optionally
imports threaded comments.
**Goal:** Make MPS a credible WordPress alternative. blog.makepostsell.com already
proves the content model works — this pipeline automates migration at scale.
## Architecture Overview
```dot
// Render: dot -Tsvg docs/wordpress-import-pipeline.dot -o docs/wordpress-import-pipeline.dot.svg
digraph wordpress_import {
rankdir=TB;
node [shape=box, style="rounded,filled", fontname="monospace", fontsize=10];
edge [fontname="monospace", fontsize=9];
subgraph cluster_source {
label="WordPress Source";
style=dashed;
color="#ad5871";
rest_api [label="REST API\n/wp-json/wp/v2/*\n(live site, no auth)", fillcolor="#fce4ec"];
wxr_file [label="WXR XML Export\n(offline file)\nTools > Export", fillcolor="#fce4ec"];
}
subgraph cluster_parser {
label="Source Parser";
style=dashed;
color="#5871ad";
api_parser [label="REST API Client\nrequests + pagination\nper_page=100, ?_embed", fillcolor="#e8eaf6"];
wxr_parser [label="WXR Parser\nxml.etree.ElementTree\nnamespace-aware", fillcolor="#e8eaf6"];
normalize [label="Normalize\nUnified post dict\n(title, html, date,\nmedia_urls, comments)", fillcolor="#e8eaf6"];
}
subgraph cluster_convert {
label="HTML Conversion Pipeline";
style=dashed;
color="#58ad71";
preprocess [label="Phase 1: Pre-process\nBeautifulSoup\n- strip Gutenberg comments\n- strip shortcodes\n- strip srcset/sizes\n- strip WP classes", fillcolor="#e8f5e9"];
images [label="Phase 2: Image Migration\n- strip -WxH suffixes\n- download originals\n- upload to S3\n- rewrite URLs to CDN", fillcolor="#e8f5e9"];
markdown [label="Phase 3: Markdown\nmarkdownify (MIT)\n- custom WPConverter\n- figures, code blocks\n- ATX headings", fillcolor="#e8f5e9"];
postprocess [label="Phase 4: Post-process\n- collapse blank lines\n- prepend metadata\n- flag unconverted HTML", fillcolor="#e8f5e9"];
}
subgraph cluster_mps {
label="MPS (Target)";
style=dashed;
color="#ad8f58";
create_product [label="Create Product\nis_sellable=False\nset title, description,\ntimestamps, visibility", fillcolor="#fff8e1"];
upload_thumb [label="Upload Thumbnail\nput_object to S3\nset_file_metadata\nACL=public-read", fillcolor="#fff8e1"];
create_comments [label="Create Comments\nthreaded via parent_id\nmarkdown + sentiment", fillcolor="#fff8e1"];
reforge [label="Reforge Discovery Ring\nJaccard similarity\nnearest-neighbor ordering", fillcolor="#fff8e1"];
}
subgraph cluster_storage {
label="Storage";
style=dashed;
color="#666666";
sqlite [label="SQLite\nproducts + comments", fillcolor="#f5f5f5"];
s3 [label="S3 / Spaces\nthumbnails + inline images\nBYOB-aware", fillcolor="#f5f5f5"];
cdn [label="CDN\npublic URLs\n?ts= cache bust", fillcolor="#f5f5f5"];
}
// Edges
rest_api -> api_parser;
wxr_file -> wxr_parser;
api_parser -> normalize;
wxr_parser -> normalize;
normalize -> preprocess;
preprocess -> images;
images -> markdown;
markdown -> postprocess;
postprocess -> create_product;
normalize -> create_comments [label="comments\n(optional)", style=dashed];
normalize -> upload_thumb [label="featured\nimage URL", style=dashed];
create_product -> sqlite;
create_comments -> sqlite;
upload_thumb -> s3;
images -> s3 [label="inline\nimages"];
s3 -> cdn;
create_product -> reforge [label="after all\nproducts"];
reforge -> sqlite;
}
```
![Architecture Diagram](wordpress-import-pipeline.dot.svg)
## Two Input Modes
### Mode 1: WP REST API (live site)
```bash
python -m make_post_sell.scripts.import_wordpress \
--source-url https://example.com \
--shop-id SHOP_UUID \
--config data/development.ini
```
Hits the public WordPress REST API. No authentication required for published
content. Uses `?_embed` to inline featured images and taxonomy terms, avoiding
N+1 requests.
**Pagination:** `per_page=100`, iterate pages until `page > X-WP-TotalPages`
header value. Configurable delay between requests (default 200ms) to respect
hosting rate limits.
**Endpoints consumed:**
| Endpoint | Purpose |
|----------|---------|
| `GET /wp-json/` | Discovery — confirm API is available |
| `GET /wp-json/wp/v2/posts?per_page=100&page=N&_embed` | All published posts |
| `GET /wp-json/wp/v2/pages?per_page=100&page=N&_embed` | All published pages |
| `GET /wp-json/wp/v2/media?per_page=100&page=N` | All media (for downloads) |
| `GET /wp-json/wp/v2/categories?per_page=100` | Category taxonomy |
| `GET /wp-json/wp/v2/tags?per_page=100` | Tag taxonomy |
| `GET /wp-json/wp/v2/comments?post=ID&per_page=100` | Comments per post |
| `GET /wp-json/wp/v2/users?per_page=100` | Author info (public fields) |
**Fallback:** If pretty permalinks are disabled, the API lives at
`/?rest_route=/wp/v2/posts` instead of `/wp-json/wp/v2/posts`. The discovery
step detects this.
**When REST API is unavailable:** Some sites disable it via security plugins
(Wordfence, Disable REST API). Detection: `GET /wp-json/` returns 404 or
`rest_disabled`. In this case, fall back to WXR mode or abort with instructions
to the user.
### Mode 2: WXR XML Export (offline file)
```bash
python -m make_post_sell.scripts.import_wordpress \
--wxr-file /path/to/export.xml \
--shop-id SHOP_UUID \
--config data/development.ini
```
Parses a WordPress eXtended RSS (WXR) export file. Works offline — no network
access to the source site needed (except for downloading media assets referenced
by URL in the export).
**How users generate WXR exports:**
- WordPress Admin: `Dashboard > Tools > Export > Download Export File`
- WP-CLI: `wp export --dir=/path/to/output/`
**WXR structure (RSS 2.0 + WordPress namespaces):**
```
<rss>
<channel>
<wp:author> — author definitions
<wp:category> — category hierarchy
<wp:tag> — flat tags
<wp:term> — custom taxonomies
<item> — posts, pages, attachments, nav items, CPTs
<title>
<content:encoded> — full HTML body (CDATA)
<excerpt:encoded> — excerpt (CDATA)
<wp:post_type> — "post", "page", "attachment", etc.
<wp:status> — "publish", "draft", "private", "trash"
<wp:post_date_gmt>
<wp:post_name> — URL slug
<wp:postmeta> — key/value metadata (featured image ID, etc.)
<wp:comment> — threaded comments (wp:comment_parent)
```
**XML parsing:** Use `xml.etree.ElementTree` for files under 50MB. For larger
exports (WP-CLI splits at 15MB by default), use `iterparse()` for streaming.
**Namespace handling:** WXR version affects namespace URIs (1.0, 1.1, 1.2).
Parse `wp:wxr_version` first, then set namespace dict accordingly.
**WXR gotchas:**
- Media files are NOT included — only URLs. Must download separately.
- `content:encoded` contains raw HTML with shortcodes and Gutenberg comments.
- Serialized PHP in `_wp_attachment_metadata` — use `phpserialize` to decode.
- WP-CLI exports may omit `_wp_attached_file` and `_wp_attachment_metadata`.
- May contain invalid UTF-8 or control characters — pre-clean before parsing.
- `wp:post_parent` links pages hierarchically (0 = top-level).
- Attachment items have `wp:attachment_url` with the source file URL.
## Content Mapping
### Posts & Pages → MPS Products (Content)
| WordPress | MPS Product | Notes |
|-----------|-------------|-------|
| `title` | `title` | Truncate to 256 chars |
| `content:encoded` / `content.rendered` | `description` (markdown) + `description_html` | See HTML conversion pipeline below |
| `excerpt:encoded` / `excerpt.rendered` | First line of `description` | Only if no excerpt, use first paragraph |
| `post_date_gmt` | `created_timestamp` | Convert to milliseconds |
| `modified_gmt` | `updated_timestamp` | Convert to milliseconds |
| `status=publish` | `visibility=1` (public) | |
| `status=draft` | `visibility=0` (private) | |
| `status=private` | `visibility=0` (private) | |
| `status=pending` | `visibility=0` (private) | |
| `featured_media` / `_thumbnail_id` | `thumbnail1` on S3 | Download + upload |
| Categories + tags | Metadata line in description | `**Categories:** Tech, Python` |
| `post_type=post` | `is_sellable=False` | Content item |
| `post_type=page` | `is_sellable=False` | Content item |
| `slug` | Used in `absolute_url()` | MPS auto-generates from title |
| `author` | Stored in description metadata | MPS has no per-product author field |
| `comment_status=open` | Comments enabled | Per-shop setting in MPS |
### Media/Attachments → S3
| WordPress | MPS S3 | Notes |
|-----------|--------|-------|
| Featured image | `{shop_id}/{product_id}/thumbnail1` | Download original (strip `-WxH` suffix) |
| Inline images in content | `{shop_id}/{product_id}/inline/{filename}` | Download, rewrite URLs in content |
| `wp-content/uploads/YYYY/MM/file.jpg` | Flat structure under product S3 path | |
| `srcset` / `sizes` attributes | Stripped | MPS serves single-resolution |
### Comments → MPS Comments
| WordPress | MPS Comment | Notes |
|-----------|-------------|-------|
| `comment_content` | `data` (markdown) + `data_html` | HTML→markdown conversion |
| `comment_date_gmt` | `created_timestamp` | Milliseconds |
| `comment_parent` | `parent_id` | 0 → null (top-level) |
| `comment_approved=1` | `approved=True` | |
| `comment_approved=0` | `approved=False` | |
| `comment_approved=spam/trash` | Skipped | |
| `comment_type=pingback/trackback` | Skipped | |
| `comment_author` | `data` attribution line | MPS comments require a user; attribute to shop owner |
## HTML Conversion Pipeline
WordPress content is HTML with WordPress-specific markup. Conversion to MPS
markdown happens in 4 phases:
```dot
// Render: dot -Tsvg docs/wordpress-import-html.dot -o docs/wordpress-import-html.dot.svg
digraph html_conversion {
rankdir=LR;
node [shape=box, style="rounded,filled", fontname="monospace", fontsize=9];
edge [fontname="monospace", fontsize=8];
raw_html [label="Raw WordPress HTML\n\n<!-- wp:paragraph -->\n<p class=\"has-large-font-size\">\n[gallery ids=\"1,2,3\"]\n<img srcset=\"...\" />", fillcolor="#fce4ec", shape=note];
subgraph cluster_phase1 {
label="Phase 1: Pre-process (BeautifulSoup)";
style=dashed;
color="#5871ad";
strip_gutenberg [label="Strip Gutenberg\ncomments\n<!-- /?wp:\\S+.*?-->", fillcolor="#e8eaf6"];
strip_shortcodes [label="Strip shortcodes\n[vc_*] [et_pb_*]\n[fusion_*]\nkeep inner content", fillcolor="#e8eaf6"];
convert_embeds [label="Convert embeds\n[embed]URL[/embed]\n→ bare URL", fillcolor="#e8eaf6"];
convert_captions [label="Convert captions\n[caption] → <figure>", fillcolor="#e8eaf6"];
strip_srcset [label="Strip srcset/sizes\nStrip WP classes\nUnwrap empty divs", fillcolor="#e8eaf6"];
}
subgraph cluster_phase2 {
label="Phase 2: Image Migration";
style=dashed;
color="#58ad71";
find_imgs [label="Find all <img> src\nand background-image\nURLs", fillcolor="#e8f5e9"];
strip_suffix [label="Strip WP size suffix\n-300x200 → original\nphoto-1024x768.jpg\n→ photo.jpg", fillcolor="#e8f5e9"];
download [label="Download original\nfrom WP server\nrequests.get()\ntimeout=30s", fillcolor="#e8f5e9"];
upload_s3 [label="Upload to S3\n{shop_id}/{product_id}/\ninline/{filename}\nACL=public-read", fillcolor="#e8f5e9"];
rewrite [label="Rewrite src URLs\nold WP URL\n→ CDN URL", fillcolor="#e8f5e9"];
}
subgraph cluster_phase3 {
label="Phase 3: markdownify";
style=dashed;
color="#ad8f58";
converter [label="WPConverter\n(MarkdownConverter\nsubclass)", fillcolor="#fff8e1"];
figures [label="<figure> → ![alt](url)\n<figcaption> → caption", fillcolor="#fff8e1"];
code [label="<pre><code\nclass=\"language-*\">\n→ fenced code block", fillcolor="#fff8e1"];
headings [label="ATX headings (#)\nbody_width=0\nunicode_snob=True", fillcolor="#fff8e1"];
}
subgraph cluster_phase4 {
label="Phase 4: Post-process";
style=dashed;
color="#666666";
collapse [label="Collapse blank lines\n3+ newlines → 2", fillcolor="#f5f5f5"];
metadata [label="Prepend metadata\n**Categories:** ...\n**Tags:** ...\n**Author:** ...", fillcolor="#f5f5f5"];
flag [label="Flag posts with\nunconverted <html>\nfor manual review", fillcolor="#f5f5f5"];
}
output [label="MPS Content\n\nProduct.description\n(raw markdown)\n\nProduct.description_html\n(rendered HTML)", fillcolor="#e8f5e9", shape=note];
// Flow
raw_html -> strip_gutenberg;
strip_gutenberg -> strip_shortcodes;
strip_shortcodes -> convert_embeds;
convert_embeds -> convert_captions;
convert_captions -> strip_srcset;
strip_srcset -> find_imgs;
find_imgs -> strip_suffix;
strip_suffix -> download;
download -> upload_s3;
upload_s3 -> rewrite;
rewrite -> converter;
converter -> figures;
converter -> code;
converter -> headings;
figures -> collapse [style=invis];
code -> collapse [style=invis];
headings -> collapse;
collapse -> metadata;
metadata -> flag;
flag -> output;
}
```
![HTML Conversion Pipeline](wordpress-import-html.dot.svg)
### Library Choice: markdownify (MIT license)
`markdownify` over `html2text` (GPL-3.0) because:
- MIT license vs GPL-3.0
- Subclassing allows per-tag override for WP-specific patterns
- Better handling of nested lists, figures, code blocks
- BeautifulSoup backend enables pre-processing in the same pipeline
### Shortcode Handling
| Shortcode | Strategy |
|-----------|----------|
| `[gallery ids="1,2,3"]` | Resolve attachment IDs to image URLs, emit markdown images |
| `[caption]...[/caption]` | Convert to `<figure>`, let markdownify handle |
| `[embed]URL[/embed]` | Extract bare URL |
| `[video]` / `[audio]` | Extract src URL |
| `[vc_*]` (WPBakery) | Strip shortcode tags, keep inner content |
| `[et_pb_*]` (Divi) | Strip shortcode tags, keep inner content |
| `[fusion_*]` (Avada) | Strip shortcode tags, keep inner content |
| Unknown shortcodes | Strip tags, keep inner content, log warning |
### Page Builder Content
Page builders (Elementor, Divi, WPBakery, Beaver Builder) store layout as
shortcodes or custom block markup. After stripping layout shortcodes, the
remaining text content is usually extractable. Posts with heavy page builder
usage are flagged for manual review in the import report.
**Best practice:** If the WordPress site is still running, install the
"Export Without Shortcodes" plugin before exporting. This renders shortcodes
to HTML during WXR export, producing much cleaner content.
## Product Creation Flow
For each imported post/page, the script follows the same code path as
`views/product.py:product_new()`:
```python
product = Product(title, description_markdown)
product.shop = shop
product.is_sellable = False
product.is_bundle = False
product.is_physical = False
product.visibility = visibility # mapped from WP status
product.created_timestamp = wp_timestamp_to_ms(post_date_gmt)
product.updated_timestamp = wp_timestamp_to_ms(modified_gmt)
# Override description_html with the pre-rendered HTML
# (markdownify round-trip may differ from MPS markdown renderer)
product.description_html = rendered_html
dbsession.add(product)
dbsession.flush()
```
### S3 Upload (bypassing browser upload flow)
The import script writes directly to S3, bypassing the presigned-URL webhook
flow used by the browser:
```python
s3_client.put_object(
Bucket=bucket_name,
Key=f"{product.s3_path}/thumbnail1",
Body=image_bytes,
ContentType=content_type,
ACL="public-read", # content thumbnails are always public
CacheControl="private, max-age=172800",
)
product.set_file_metadata("thumbnail1", extension, original_filename)
product.file_bytes = {"thumbnail1": len(image_bytes)}
```
### Discovery Ring
After all products are imported, trigger a single discovery ring reforge:
```python
from make_post_sell.models.shop import reforge_discovery_ring
reforge_discovery_ring(shop)
```
This computes the greedy nearest-neighbor ordering across all public products
using stemmed word Jaccard similarity.
## CLI Interface
```
usage: import_wordpress.py [-h] --config INI --shop-id UUID
[--source-url URL | --wxr-file FILE]
[--dry-run] [--skip-media] [--skip-comments]
[--delay MS] [--verbose]
Import a WordPress site into an MPS shop.
required:
--config INI Path to MPS .ini config (e.g. data/development.ini)
--shop-id UUID Target MPS shop UUID
source (one required):
--source-url URL WordPress site URL (uses REST API)
--wxr-file FILE Path to WXR XML export file
options:
--dry-run Parse and report without writing to DB or S3
--skip-media Skip image download/upload (keep original URLs)
--skip-comments Skip comment import
--delay MS Delay between API requests in ms (default: 200)
--verbose Print detailed progress
```
## Import Report
The script prints a summary after completion:
```
WordPress Import Complete
─────────────────────────
Source: https://example.com (REST API)
Target: My Shop (shop_id: abc123...)
Duration: 2m 34s
Posts imported: 47 / 50 (3 skipped: empty content)
Pages imported: 12 / 12
Images downloaded: 183
Images uploaded: 183
Comments imported: 234
Categories found: 8
Tags found: 24
Warnings:
- 3 posts had page builder shortcodes (flagged for review)
- 2 images returned 404 (kept original URLs)
- 1 post title truncated from 312 to 256 chars
Discovery ring reforged with 59 products.
```
## Dependencies
| Package | Purpose | License |
|---------|---------|---------|
| `markdownify` | HTML→Markdown conversion | MIT |
| `beautifulsoup4` | HTML pre-processing | MIT |
| `requests` | HTTP client (REST API + image downloads) | Apache-2.0 |
| `lxml` | Fast XML parsing for large WXR files | BSD |
All are pip-installable. `requests` and `beautifulsoup4` are likely already
in the MPS dependency tree.
## Competitive Landscape
How other platforms handle WordPress migration:
| Platform | Input | Posts | Pages | Images | Comments | Shortcodes |
|----------|-------|-------|-------|--------|----------|------------|
| **Ghost** | WXR | Yes | Yes | Scraped from live site | No (no comment system) | Partial (vc_, et_) |
| **Squarespace** | WXR | Yes | Yes | Linked (not re-hosted) | Yes | No |
| **Shopify** | Third-party apps | Yes | Limited | Via apps | No | No |
| **Substack** | WXR | Yes | No | Linked | No | No |
| **Eleventy** | REST API | Yes | Yes | Downloaded + re-hosted | No | No |
| **Hugo (wp2hugo)** | WXR | Yes | Yes | Downloaded | Optional | Best (converts to Hugo shortcodes) |
| **MPS (this)** | REST API + WXR | Yes | Yes | Downloaded + re-hosted to S3 | Yes (threaded) | Strip + keep content |
### MPS advantages over competitors
1. **Both input modes** — REST API (zero-touch) and WXR (offline). Most competitors support only one.
2. **Image re-hosting** — Downloads and uploads to S3/CDN. Squarespace and Substack leave images on the old server (break when it goes down).
3. **Comments** — Threaded comment import. Ghost and Substack have no comment system. Squarespace imports but loses threading.
4. **Existing content features** — Search, RSS/Atom feeds, sitemap, comments, watch mode, discovery ring all work on imported content with zero additional setup.
5. **No vendor lock-in** — BYOB (Bring Your Own Bucket) means media stays on infrastructure the shop owner controls.
### Common migration complaints (from competitor users)
1. **Images break**#1 complaint across all platforms. We solve this by downloading + re-hosting.
2. **Shortcode garbage** — Page builder content becomes unreadable. We strip layout shortcodes and keep text content, flagging posts for review.
3. **Formatting loss** — We preserve HTML and also generate markdown for future editing.
4. **SEO loss** — Average 523-day recovery from botched migration. We preserve dates, slugs, and content structure.
5. **Timeouts on large sites** — We paginate (REST API) and stream-parse (WXR) to handle any size.
## Future Enhancements
- **Web UI** — Upload WXR file through shop settings (new `form_section: import-settings`)
- **WooCommerce products** — Import sellable products with prices (`is_sellable=True`)
- **URL redirect map** — Generate nginx/Caddy redirect rules from old URLs to new MPS URLs
- **Incremental sync** — Re-run import to pick up new posts (skip existing by slug match)
- **Elementor JSON** — Parse Elementor's post meta JSON for richer content extraction
- **Multi-author** — Create MPS editor accounts per WordPress author