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:
parent
8649e6aaae
commit
01bd9086fe
5 changed files with 1274 additions and 0 deletions
81
docs/wordpress-import-html.dot
Normal file
81
docs/wordpress-import-html.dot
Normal 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> → \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;
|
||||
}
|
||||
305
docs/wordpress-import-html.dot.svg
Normal file
305
docs/wordpress-import-html.dot.svg
Normal 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-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-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"><!-- wp:paragraph --></text>
|
||||
<text text-anchor="middle" x="89.5" y="-81.8" font-family="monospace" font-size="9.00"><p class="has-large-font-size"></text>
|
||||
<text text-anchor="middle" x="89.5" y="-71.8" font-family="monospace" font-size="9.00">[gallery ids="1,2,3"]</text>
|
||||
<text text-anchor="middle" x="89.5" y="-61.8" font-family="monospace" font-size="9.00"><img srcset="..." /></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"><!-- /?wp:\S+.*?--></text>
|
||||
</g>
|
||||
<!-- raw_html->strip_gutenberg -->
|
||||
<g id="edge1" class="edge">
|
||||
<title>raw_html->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->strip_shortcodes -->
|
||||
<g id="edge2" class="edge">
|
||||
<title>strip_gutenberg->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->convert_embeds -->
|
||||
<g id="edge3" class="edge">
|
||||
<title>strip_shortcodes->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] → <figure></text>
|
||||
</g>
|
||||
<!-- convert_embeds->convert_captions -->
|
||||
<g id="edge4" class="edge">
|
||||
<title>convert_embeds->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->strip_srcset -->
|
||||
<g id="edge5" class="edge">
|
||||
<title>convert_captions->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 <img> src</text>
|
||||
<text text-anchor="middle" x="1027" y="-86.8" font-family="monospace" font-size="9.00">and background-image</text>
|
||||
<text text-anchor="middle" x="1027" y="-76.8" font-family="monospace" font-size="9.00">URLs</text>
|
||||
</g>
|
||||
<!-- strip_srcset->find_imgs -->
|
||||
<g id="edge6" class="edge">
|
||||
<title>strip_srcset->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">-300x200 → original</text>
|
||||
<text text-anchor="middle" x="1185" y="-81.8" font-family="monospace" font-size="9.00">photo-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->strip_suffix -->
|
||||
<g id="edge7" class="edge">
|
||||
<title>find_imgs->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->download -->
|
||||
<g id="edge8" class="edge">
|
||||
<title>strip_suffix->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-read</text>
|
||||
</g>
|
||||
<!-- download->upload_s3 -->
|
||||
<g id="edge9" class="edge">
|
||||
<title>download->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->rewrite -->
|
||||
<g id="edge10" class="edge">
|
||||
<title>upload_s3->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->converter -->
|
||||
<g id="edge11" class="edge">
|
||||
<title>rewrite->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"><figure> → </text>
|
||||
<text text-anchor="middle" x="1947" y="-26.8" font-family="monospace" font-size="9.00"><figcaption> → caption</text>
|
||||
</g>
|
||||
<!-- converter->figures -->
|
||||
<g id="edge12" class="edge">
|
||||
<title>converter->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"><pre><code</text>
|
||||
<text text-anchor="middle" x="1947" y="-86.8" font-family="monospace" font-size="9.00">class="language-*"></text>
|
||||
<text text-anchor="middle" x="1947" y="-76.8" font-family="monospace" font-size="9.00">→ fenced code block</text>
|
||||
</g>
|
||||
<!-- converter->code -->
|
||||
<g id="edge13" class="edge">
|
||||
<title>converter->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->headings -->
|
||||
<g id="edge14" class="edge">
|
||||
<title>converter->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->collapse -->
|
||||
<!-- code->collapse -->
|
||||
<!-- headings->collapse -->
|
||||
<g id="edge17" class="edge">
|
||||
<title>headings->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->metadata -->
|
||||
<g id="edge18" class="edge">
|
||||
<title>collapse->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 <html></text>
|
||||
<text text-anchor="middle" x="2414.5" y="-76.8" font-family="monospace" font-size="9.00">for manual review</text>
|
||||
</g>
|
||||
<!-- metadata->flag -->
|
||||
<g id="edge19" class="edge">
|
||||
<title>metadata->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->output -->
|
||||
<g id="edge20" class="edge">
|
||||
<title>flag->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 |
82
docs/wordpress-import-pipeline.dot
Normal file
82
docs/wordpress-import-pipeline.dot
Normal 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;
|
||||
}
|
||||
292
docs/wordpress-import-pipeline.dot.svg
Normal file
292
docs/wordpress-import-pipeline.dot.svg
Normal 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-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->api_parser -->
|
||||
<g id="edge1" class="edge">
|
||||
<title>rest_api->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 > 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-aware</text>
|
||||
</g>
|
||||
<!-- wxr_file->wxr_parser -->
|
||||
<g id="edge2" class="edge">
|
||||
<title>wxr_file->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->normalize -->
|
||||
<g id="edge3" class="edge">
|
||||
<title>api_parser->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->normalize -->
|
||||
<g id="edge4" class="edge">
|
||||
<title>wxr_parser->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-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">- strip Gutenberg comments</text>
|
||||
<text text-anchor="middle" x="121" y="-756" font-family="monospace" font-size="10.00">- strip shortcodes</text>
|
||||
<text text-anchor="middle" x="121" y="-745" font-family="monospace" font-size="10.00">- strip srcset/sizes</text>
|
||||
<text text-anchor="middle" x="121" y="-734" font-family="monospace" font-size="10.00">- strip WP classes</text>
|
||||
</g>
|
||||
<!-- normalize->preprocess -->
|
||||
<g id="edge5" class="edge">
|
||||
<title>normalize->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-read</text>
|
||||
</g>
|
||||
<!-- normalize->upload_thumb -->
|
||||
<g id="edge11" class="edge">
|
||||
<title>normalize->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->create_comments -->
|
||||
<g id="edge10" class="edge">
|
||||
<title>normalize->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">- strip -WxH suffixes</text>
|
||||
<text text-anchor="middle" x="121" y="-656" font-family="monospace" font-size="10.00">- download originals</text>
|
||||
<text text-anchor="middle" x="121" y="-645" font-family="monospace" font-size="10.00">- upload to S3</text>
|
||||
<text text-anchor="middle" x="121" y="-634" font-family="monospace" font-size="10.00">- rewrite URLs to CDN</text>
|
||||
</g>
|
||||
<!-- preprocess->images -->
|
||||
<g id="edge6" class="edge">
|
||||
<title>preprocess->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">- custom WPConverter</text>
|
||||
<text text-anchor="middle" x="128" y="-526" font-family="monospace" font-size="10.00">- figures, code blocks</text>
|
||||
<text text-anchor="middle" x="128" y="-515" font-family="monospace" font-size="10.00">- ATX headings</text>
|
||||
</g>
|
||||
<!-- images->markdown -->
|
||||
<g id="edge7" class="edge">
|
||||
<title>images->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-aware</text>
|
||||
</g>
|
||||
<!-- images->s3 -->
|
||||
<g id="edge15" class="edge">
|
||||
<title>images->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-process</text>
|
||||
<text text-anchor="middle" x="130" y="-448" font-family="monospace" font-size="10.00">- collapse blank lines</text>
|
||||
<text text-anchor="middle" x="130" y="-437" font-family="monospace" font-size="10.00">- prepend metadata</text>
|
||||
<text text-anchor="middle" x="130" y="-426" font-family="monospace" font-size="10.00">- flag unconverted HTML</text>
|
||||
</g>
|
||||
<!-- markdown->postprocess -->
|
||||
<g id="edge8" class="edge">
|
||||
<title>markdown->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->create_product -->
|
||||
<g id="edge9" class="edge">
|
||||
<title>postprocess->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-neighbor ordering</text>
|
||||
</g>
|
||||
<!-- create_product->reforge -->
|
||||
<g id="edge17" class="edge">
|
||||
<title>create_product->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->sqlite -->
|
||||
<g id="edge12" class="edge">
|
||||
<title>create_product->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->s3 -->
|
||||
<g id="edge14" class="edge">
|
||||
<title>upload_thumb->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->sqlite -->
|
||||
<g id="edge13" class="edge">
|
||||
<title>create_comments->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->sqlite -->
|
||||
<g id="edge18" class="edge">
|
||||
<title>reforge->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->cdn -->
|
||||
<g id="edge16" class="edge">
|
||||
<title>s3->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 |
514
docs/wordpress-import-pipeline.md
Normal file
514
docs/wordpress-import-pipeline.md
Normal 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;
|
||||
}
|
||||
```
|
||||
|
||||

|
||||
|
||||
## 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> → \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;
|
||||
}
|
||||
```
|
||||
|
||||

|
||||
|
||||
### 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue