diff --git a/docs/wordpress-import-html.dot b/docs/wordpress-import-html.dot new file mode 100644 index 0000000..2d9931d --- /dev/null +++ b/docs/wordpress-import-html.dot @@ -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\n
\n[gallery ids=\"1,2,3\"]\n \n[gallery ids=\"1,2,3\"]\n", fillcolor="#fce4ec", shape=note];
+
+ subgraph cluster_phase1 {
+ label="Phase 1: Pre-process (BeautifulSoup)";
+ style=dashed;
+ color="#5871ad";
+
+ strip_gutenberg [label="Strip Gutenberg\ncomments\n", 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] →
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="
\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 \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;
+}
diff --git a/docs/wordpress-import-html.dot.svg b/docs/wordpress-import-html.dot.svg
new file mode 100644
index 0000000..6a46588
--- /dev/null
+++ b/docs/wordpress-import-html.dot.svg
@@ -0,0 +1,305 @@
+
+
+
+
+
diff --git a/docs/wordpress-import-pipeline.dot b/docs/wordpress-import-pipeline.dot
new file mode 100644
index 0000000..a576fb7
--- /dev/null
+++ b/docs/wordpress-import-pipeline.dot
@@ -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;
+}
diff --git a/docs/wordpress-import-pipeline.dot.svg b/docs/wordpress-import-pipeline.dot.svg
new file mode 100644
index 0000000..401e2e6
--- /dev/null
+++ b/docs/wordpress-import-pipeline.dot.svg
@@ -0,0 +1,292 @@
+
+
+
+
+
diff --git a/docs/wordpress-import-pipeline.md b/docs/wordpress-import-pipeline.md
new file mode 100644
index 0000000..1d0f758
--- /dev/null
+++ b/docs/wordpress-import-pipeline.md
@@ -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):**
+
+```
+", fillcolor="#fce4ec", shape=note];
+
+ subgraph cluster_phase1 {
+ label="Phase 1: Pre-process (BeautifulSoup)";
+ style=dashed;
+ color="#5871ad";
+
+ strip_gutenberg [label="Strip Gutenberg\ncomments\n", 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] →
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="
\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 \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 `